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
5 file(s) · +163 −16
Stores as a cookie and in local storage as a fallback. Passes from origin to sequoia.pub. Co-Authored-By: @stevedylan.dev
| 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`; |
| 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 | }; |
| 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()); |
|
| 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 | ||
| 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(); |
|