Pass DID through query parameter b6b1f627
Stores as a cookie and in local storage as a fallback. Passes from origin to sequoia.pub.

Co-Authored-By: @stevedylan.dev
Heath Stewart · 2026-03-02 21:58 5 file(s) · +163 −16
docs/src/lib/oauth-client.ts +3 −0
3 3
import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver";
4 4
import { createStateStore, createSessionStore } from "./kv-stores";
5 5
6 +
export const OAUTH_SCOPE =
7 +
	"atproto repo:site.standard.graph.subscription?action=create&action=delete";
8 +
6 9
export function createOAuthClient(kv: KVNamespace, clientUrl: string) {
7 10
	const clientId = `${clientUrl}/oauth/client-metadata.json`;
8 11
	const redirectUri = `${clientUrl}/oauth/callback`;
docs/src/lib/session.ts +1 −2
11 11
	const hostname = new URL(clientUrl).hostname;
12 12
	return {
13 13
		httpOnly: true as const,
14 -
		// Allow the SESSION_COOKIE_NAME to be sent for existing subscription checks.
15 -
		sameSite: "None" as const,
14 +
		sameSite: "Lax" as const,
16 15
		path: "/",
17 16
		...(isLocalhost ? {} : { domain: `.${hostname}`, secure: true }),
18 17
	};
docs/src/routes/auth.ts +3 −3
1 1
import { Hono } from "hono";
2 -
import { createOAuthClient } from "../lib/oauth-client";
2 +
import { createOAuthClient, OAUTH_SCOPE } from "../lib/oauth-client";
3 3
import {
4 4
	getSessionDid,
5 5
	setSessionCookie,
27 27
		redirect_uris: [redirectUri],
28 28
		grant_types: ["authorization_code", "refresh_token"],
29 29
		response_types: ["code"],
30 -
		scope: "atproto repo:site.standard.graph.subscription?action=create",
30 +
		scope: OAUTH_SCOPE,
31 31
		token_endpoint_auth_method: "none",
32 32
		application_type: "web",
33 33
		dpop_bound_access_tokens: true,
44 44
45 45
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
46 46
		const authUrl = await client.authorize(handle, {
47 -
			scope: "atproto repo:site.standard.graph.subscription?action=create",
47 +
			scope: OAUTH_SCOPE,
48 48
		});
49 49
50 50
		return c.redirect(authUrl.toString());
docs/src/routes/subscribe.ts +40 −5
42 42
// ============================================================================
43 43
44 44
/**
45 +
 * Append a query parameter to a returnTo URL, preserving existing params.
46 +
 */
47 +
function withReturnToParam(
48 +
	returnTo: string | undefined,
49 +
	key: string,
50 +
	value: string,
51 +
): string | undefined {
52 +
	if (!returnTo) return undefined;
53 +
	try {
54 +
		const url = new URL(returnTo);
55 +
		url.searchParams.set(key, value);
56 +
		return url.toString();
57 +
	} catch {
58 +
		return returnTo;
59 +
	}
60 +
}
61 +
62 +
/**
45 63
 * Scan the user's repo for an existing site.standard.graph.subscription
46 64
 * matching the given publication URI. Returns the record AT-URI if found.
47 65
 */
201 219
					rkey,
202 220
				});
203 221
			}
222 +
223 +
			// Strip sequoia_did from returnTo so the component doesn't re-store it
224 +
			let cleanReturnTo = returnTo;
225 +
			if (cleanReturnTo) {
226 +
				try {
227 +
					const rtUrl = new URL(cleanReturnTo);
228 +
					rtUrl.searchParams.delete("sequoia_did");
229 +
					cleanReturnTo = rtUrl.toString();
230 +
				} catch {
231 +
					// keep as-is
232 +
				}
233 +
			}
234 +
204 235
			return c.html(
205 236
				renderSuccess(
206 237
					publicationUri,
210 241
						? "You've successfully unsubscribed!"
211 242
						: "You weren't subscribed to this publication.",
212 243
					styleHref,
213 -
					returnTo,
244 +
					withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"),
214 245
				),
215 246
			);
216 247
		}
220 251
			did,
221 252
			publicationUri,
222 253
		);
254 +
		const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did);
255 +
223 256
		if (existingUri) {
224 257
			return c.html(
225 258
				renderSuccess(
228 261
					"Subscribed ✓",
229 262
					"You're already subscribed to this publication.",
230 263
					styleHref,
231 -
					returnTo,
264 +
					returnToWithDid,
232 265
				),
233 266
			);
234 267
		}
249 282
				"Subscribed ✓",
250 283
				"You've successfully subscribed!",
251 284
				styleHref,
252 -
				returnTo,
285 +
				returnToWithDid,
253 286
			),
254 287
		);
255 288
	} catch (error) {
286 319
		return c.json({ error: "Missing or invalid publicationUri" }, 400);
287 320
	}
288 321
289 -
	const did = getSessionDid(c);
290 -
	if (!did) {
322 +
	// Prefer the server-side session DID; fall back to a client-provided DID
323 +
	// (stored by the web component from a previous subscribe flow).
324 +
	const did = getSessionDid(c) ?? c.req.query("did") ?? null;
325 +
	if (!did || !did.startsWith("did:")) {
291 326
		return c.json({ authenticated: false }, 401);
292 327
	}
293 328
packages/cli/src/components/sequoia-subscribe.js +116 −6
111 111
</svg>`;
112 112
113 113
// ============================================================================
114 +
// DID Storage
115 +
// ============================================================================
116 +
117 +
/**
118 +
 * Store the subscriber DID. Tries a cookie first; falls back to localStorage.
119 +
 * @param {string} did
120 +
 */
121 +
function storeSubscriberDid(did) {
122 +
	try {
123 +
		const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
124 +
		document.cookie = `sequoia_did=${encodeURIComponent(did)}; expires=${expires}; path=/; SameSite=Lax`;
125 +
	} catch {
126 +
		// Cookie write may fail in some embedded contexts
127 +
	}
128 +
	try {
129 +
		localStorage.setItem("sequoia_did", did);
130 +
	} catch {
131 +
		// localStorage may be unavailable
132 +
	}
133 +
}
134 +
135 +
/**
136 +
 * Retrieve the stored subscriber DID. Checks cookie first, then localStorage.
137 +
 * @returns {string | null}
138 +
 */
139 +
function getStoredSubscriberDid() {
140 +
	try {
141 +
		const match = document.cookie.match(/(?:^|;\s*)sequoia_did=([^;]+)/);
142 +
		if (match) {
143 +
			const did = decodeURIComponent(match[1]);
144 +
			if (did.startsWith("did:")) return did;
145 +
		}
146 +
	} catch {
147 +
		// ignore
148 +
	}
149 +
	try {
150 +
		const did = localStorage.getItem("sequoia_did");
151 +
		if (did?.startsWith("did:")) return did;
152 +
	} catch {
153 +
		// ignore
154 +
	}
155 +
	return null;
156 +
}
157 +
158 +
/**
159 +
 * Remove the stored subscriber DID from both cookie and localStorage.
160 +
 */
161 +
function clearSubscriberDid() {
162 +
	try {
163 +
		document.cookie = "sequoia_did=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax";
164 +
	} catch {
165 +
		// ignore
166 +
	}
167 +
	try {
168 +
		localStorage.removeItem("sequoia_did");
169 +
	} catch {
170 +
		// ignore
171 +
	}
172 +
}
173 +
174 +
/**
175 +
 * Check the current page URL for sequoia_did / sequoia_unsubscribed params
176 +
 * set by the subscribe redirect flow. Consumes them by removing from the URL.
177 +
 */
178 +
function consumeReturnParams() {
179 +
	const url = new URL(window.location.href);
180 +
	const did = url.searchParams.get("sequoia_did");
181 +
	const unsubscribed = url.searchParams.get("sequoia_unsubscribed");
182 +
183 +
	let changed = false;
184 +
185 +
	if (unsubscribed === "1") {
186 +
		clearSubscriberDid();
187 +
		url.searchParams.delete("sequoia_unsubscribed");
188 +
		changed = true;
189 +
	}
190 +
191 +
	if (did && did.startsWith("did:")) {
192 +
		storeSubscriberDid(did);
193 +
		url.searchParams.delete("sequoia_did");
194 +
		changed = true;
195 +
	}
196 +
197 +
	if (changed) {
198 +
		const cleanUrl = url.pathname + (url.search || "") + (url.hash || "");
199 +
		try {
200 +
			window.history.replaceState(null, "", cleanUrl);
201 +
		} catch {
202 +
			// ignore
203 +
		}
204 +
	}
205 +
}
206 +
207 +
// ============================================================================
114 208
// AT Protocol Functions
115 209
// ============================================================================
116 210
177 271
	}
178 272
179 273
	connectedCallback() {
274 +
		consumeReturnParams();
180 275
		this.checkPublication();
181 276
	}
182 277
223 318
224 319
	async checkSubscription(publicationUri) {
225 320
		try {
226 -
			const res = await fetch(
227 -
				`${this.callbackUri}/check?publicationUri=${encodeURIComponent(publicationUri)}`,
228 -
				{
229 -
					credentials: "include",
230 -
				},
231 -
			);
321 +
			const checkUrl = new URL(`${this.callbackUri}/check`);
322 +
			checkUrl.searchParams.set("publicationUri", publicationUri);
323 +
324 +
			// Pass the stored DID so the server can check without a session cookie
325 +
			const storedDid = getStoredSubscriberDid();
326 +
			if (storedDid) {
327 +
				checkUrl.searchParams.set("did", storedDid);
328 +
			}
329 +
330 +
			const res = await fetch(checkUrl.toString(), {
331 +
				credentials: "include",
332 +
			});
232 333
			if (!res.ok) return;
233 334
			const data = await res.json();
234 335
			if (data.subscribed) {
287 388
			}
288 389
289 390
			const { recordUri } = data;
391 +
392 +
			// Store the DID from the record URI (at://did:aaa:bbb/...)
393 +
			if (recordUri) {
394 +
				const didMatch = recordUri.match(/^at:\/\/(did:[^/]+)/);
395 +
				if (didMatch) {
396 +
					storeSubscriberDid(didMatch[1]);
397 +
				}
398 +
			}
399 +
290 400
			this.subscribed = true;
291 401
			this.state = { type: "idle" };
292 402
			this.render();