diff --git a/docs/src/lib/oauth-client.ts b/docs/src/lib/oauth-client.ts index 6ed0478..4692cc3 100644 --- a/docs/src/lib/oauth-client.ts +++ b/docs/src/lib/oauth-client.ts @@ -3,6 +3,9 @@ import { OAuthClient } from "@atproto/oauth-client"; import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver"; import { createStateStore, createSessionStore } from "./kv-stores"; +export const OAUTH_SCOPE = + "atproto repo:site.standard.graph.subscription?action=create&action=delete"; + export function createOAuthClient(kv: KVNamespace, clientUrl: string) { const clientId = `${clientUrl}/oauth/client-metadata.json`; const redirectUri = `${clientUrl}/oauth/callback`; diff --git a/docs/src/lib/session.ts b/docs/src/lib/session.ts index 4c51d1f..d106b19 100644 --- a/docs/src/lib/session.ts +++ b/docs/src/lib/session.ts @@ -11,8 +11,7 @@ function baseCookieOptions(clientUrl: string) { const hostname = new URL(clientUrl).hostname; return { httpOnly: true as const, - // Allow the SESSION_COOKIE_NAME to be sent for existing subscription checks. - sameSite: "None" as const, + sameSite: "Lax" as const, path: "/", ...(isLocalhost ? {} : { domain: `.${hostname}`, secure: true }), }; diff --git a/docs/src/routes/auth.ts b/docs/src/routes/auth.ts index 7b88ad9..aa2ba39 100644 --- a/docs/src/routes/auth.ts +++ b/docs/src/routes/auth.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { createOAuthClient } from "../lib/oauth-client"; +import { createOAuthClient, OAUTH_SCOPE } from "../lib/oauth-client"; import { getSessionDid, setSessionCookie, @@ -27,7 +27,7 @@ auth.get("/client-metadata.json", (c) => { redirect_uris: [redirectUri], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], - scope: "atproto repo:site.standard.graph.subscription?action=create", + scope: OAUTH_SCOPE, token_endpoint_auth_method: "none", application_type: "web", dpop_bound_access_tokens: true, @@ -44,7 +44,7 @@ auth.get("/login", async (c) => { const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); const authUrl = await client.authorize(handle, { - scope: "atproto repo:site.standard.graph.subscription?action=create", + scope: OAUTH_SCOPE, }); return c.redirect(authUrl.toString()); diff --git a/docs/src/routes/subscribe.ts b/docs/src/routes/subscribe.ts index 21aafcb..3b32ffb 100644 --- a/docs/src/routes/subscribe.ts +++ b/docs/src/routes/subscribe.ts @@ -41,6 +41,24 @@ const REDIRECT_DELAY_SECONDS = 5; // Helpers // ============================================================================ +/** + * Append a query parameter to a returnTo URL, preserving existing params. + */ +function withReturnToParam( + returnTo: string | undefined, + key: string, + value: string, +): string | undefined { + if (!returnTo) return undefined; + try { + const url = new URL(returnTo); + url.searchParams.set(key, value); + return url.toString(); + } catch { + return returnTo; + } +} + /** * Scan the user's repo for an existing site.standard.graph.subscription * matching the given publication URI. Returns the record AT-URI if found. @@ -201,6 +219,19 @@ subscribe.get("/", async (c) => { rkey, }); } + + // Strip sequoia_did from returnTo so the component doesn't re-store it + let cleanReturnTo = returnTo; + if (cleanReturnTo) { + try { + const rtUrl = new URL(cleanReturnTo); + rtUrl.searchParams.delete("sequoia_did"); + cleanReturnTo = rtUrl.toString(); + } catch { + // keep as-is + } + } + return c.html( renderSuccess( publicationUri, @@ -210,7 +241,7 @@ subscribe.get("/", async (c) => { ? "You've successfully unsubscribed!" : "You weren't subscribed to this publication.", styleHref, - returnTo, + withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"), ), ); } @@ -220,6 +251,8 @@ subscribe.get("/", async (c) => { did, publicationUri, ); + const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did); + if (existingUri) { return c.html( renderSuccess( @@ -228,7 +261,7 @@ subscribe.get("/", async (c) => { "Subscribed ✓", "You're already subscribed to this publication.", styleHref, - returnTo, + returnToWithDid, ), ); } @@ -249,7 +282,7 @@ subscribe.get("/", async (c) => { "Subscribed ✓", "You've successfully subscribed!", styleHref, - returnTo, + returnToWithDid, ), ); } catch (error) { @@ -286,8 +319,10 @@ subscribe.get("/check", async (c) => { return c.json({ error: "Missing or invalid publicationUri" }, 400); } - const did = getSessionDid(c); - if (!did) { + // Prefer the server-side session DID; fall back to a client-provided DID + // (stored by the web component from a previous subscribe flow). + const did = getSessionDid(c) ?? c.req.query("did") ?? null; + if (!did || !did.startsWith("did:")) { return c.json({ authenticated: false }, 401); } diff --git a/packages/cli/src/components/sequoia-subscribe.js b/packages/cli/src/components/sequoia-subscribe.js index 46b1111..1ce804e 100644 --- a/packages/cli/src/components/sequoia-subscribe.js +++ b/packages/cli/src/components/sequoia-subscribe.js @@ -110,6 +110,100 @@ const BLUESKY_ICON = ` `; +// ============================================================================ +// DID Storage +// ============================================================================ + +/** + * Store the subscriber DID. Tries a cookie first; falls back to localStorage. + * @param {string} did + */ +function storeSubscriberDid(did) { + try { + const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString(); + document.cookie = `sequoia_did=${encodeURIComponent(did)}; expires=${expires}; path=/; SameSite=Lax`; + } catch { + // Cookie write may fail in some embedded contexts + } + try { + localStorage.setItem("sequoia_did", did); + } catch { + // localStorage may be unavailable + } +} + +/** + * Retrieve the stored subscriber DID. Checks cookie first, then localStorage. + * @returns {string | null} + */ +function getStoredSubscriberDid() { + try { + const match = document.cookie.match(/(?:^|;\s*)sequoia_did=([^;]+)/); + if (match) { + const did = decodeURIComponent(match[1]); + if (did.startsWith("did:")) return did; + } + } catch { + // ignore + } + try { + const did = localStorage.getItem("sequoia_did"); + if (did?.startsWith("did:")) return did; + } catch { + // ignore + } + return null; +} + +/** + * Remove the stored subscriber DID from both cookie and localStorage. + */ +function clearSubscriberDid() { + try { + document.cookie = "sequoia_did=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax"; + } catch { + // ignore + } + try { + localStorage.removeItem("sequoia_did"); + } catch { + // ignore + } +} + +/** + * Check the current page URL for sequoia_did / sequoia_unsubscribed params + * set by the subscribe redirect flow. Consumes them by removing from the URL. + */ +function consumeReturnParams() { + const url = new URL(window.location.href); + const did = url.searchParams.get("sequoia_did"); + const unsubscribed = url.searchParams.get("sequoia_unsubscribed"); + + let changed = false; + + if (unsubscribed === "1") { + clearSubscriberDid(); + url.searchParams.delete("sequoia_unsubscribed"); + changed = true; + } + + if (did && did.startsWith("did:")) { + storeSubscriberDid(did); + url.searchParams.delete("sequoia_did"); + changed = true; + } + + if (changed) { + const cleanUrl = url.pathname + (url.search || "") + (url.hash || ""); + try { + window.history.replaceState(null, "", cleanUrl); + } catch { + // ignore + } + } +} + // ============================================================================ // AT Protocol Functions // ============================================================================ @@ -177,6 +271,7 @@ class SequoiaSubscribe extends BaseElement { } connectedCallback() { + consumeReturnParams(); this.checkPublication(); } @@ -223,12 +318,18 @@ class SequoiaSubscribe extends BaseElement { async checkSubscription(publicationUri) { try { - const res = await fetch( - `${this.callbackUri}/check?publicationUri=${encodeURIComponent(publicationUri)}`, - { - credentials: "include", - }, - ); + const checkUrl = new URL(`${this.callbackUri}/check`); + checkUrl.searchParams.set("publicationUri", publicationUri); + + // Pass the stored DID so the server can check without a session cookie + const storedDid = getStoredSubscriberDid(); + if (storedDid) { + checkUrl.searchParams.set("did", storedDid); + } + + const res = await fetch(checkUrl.toString(), { + credentials: "include", + }); if (!res.ok) return; const data = await res.json(); if (data.subscribed) { @@ -287,6 +388,15 @@ class SequoiaSubscribe extends BaseElement { } const { recordUri } = data; + + // Store the DID from the record URI (at://did:aaa:bbb/...) + if (recordUri) { + const didMatch = recordUri.match(/^at:\/\/(did:[^/]+)/); + if (didMatch) { + storeSubscriberDid(didMatch[1]); + } + } + this.subscribed = true; this.state = { type: "idle" }; this.render();