diff --git a/web/src/lib/auth.svelte.ts b/web/src/lib/auth.svelte.ts index 115dd858e..6ef93647c 100644 --- a/web/src/lib/auth.svelte.ts +++ b/web/src/lib/auth.svelte.ts @@ -32,6 +32,7 @@ import { saveAccounts, upsertAccount } from "./auth/accounts"; +import { writePendingLogin } from "./auth/pending"; export const AUTH_KEY = Symbol("auth"); const DEV_REDIRECT_URI = "http://127.0.0.1:5173/oauth/callback"; @@ -99,6 +100,7 @@ export interface Auth { signIn(identifier: string, returnTo?: string): Promise; addAccount(identifier: string, returnTo?: string): Promise; completeSignIn(): Promise; + completePendingSignIn(hash: string): Promise; switchAccount(did: Did, returnTo?: string): Promise; agentFor(did: Did): Promise; removeAccount(did: Did): Promise; @@ -206,6 +208,7 @@ export const createAuth = ( ); let accounts = $state([]); const otherAgents = new SvelteMap(); + let pendingExchange: Promise | null = null; // merge atcute's stored sessions with persisted account metadata. const syncAccounts = () => { @@ -293,6 +296,8 @@ export const createAuth = ( // allowStale would let a dead session through, the write then fails with the raw pds error const agentFor = async (did: Did): Promise => { configure(); + // a sign-in exchange may still be storing this session + if (pendingExchange) await pendingExchange.catch(() => {}); const active = currentAgent(); const cached = active?.sub === did ? active : (otherAgents.get(did) ?? null); try { @@ -370,14 +375,20 @@ export const createAuth = ( } try { - const url = await createAuthorizationUrl({ - target: { - type: "account", - identifier: trimmed as ActorIdentifier - }, - scope: OAUTH_SCOPE, - state: { returnTo } - }); + // createAuthorizationUrl resolves too but never returns it, and the + // callback needs the did to seed cookies + const [url, profile] = await Promise.all([ + createAuthorizationUrl({ + target: { + type: "account", + identifier: trimmed as ActorIdentifier + }, + scope: OAUTH_SCOPE, + state: { returnTo } + }), + resolveProfile(trimmed, bobbinUrlValue) + ]); + if (profile) writePendingLogin({ did: profile.did, handle: profile.handle, returnTo }); window.location.assign(url.toString()); } catch (cause) { @@ -405,6 +416,25 @@ export const createAuth = ( } }; + // no authenticating flip, the seeded profile keeps the topbar settled + const completePendingSignIn = async (hash: string): Promise => { + configure(); + + const params = new SvelteURLSearchParams(hash.startsWith("#") ? hash.slice(1) : hash); + const exchange = finalizeAuthorization(params); + pendingExchange = exchange; + try { + const { session } = await exchange; + adoptSession(session); + } catch (cause) { + // the callback already seeded cookies for a login that didn't happen + resetLoggedOut(); + throw cause; + } finally { + pendingExchange = null; + } + }; + const switchAccount = async (did: Did, returnTo = "/") => { if (!browser) return false; configure(); @@ -504,6 +534,7 @@ export const createAuth = ( signIn, addAccount: signIn, completeSignIn, + completePendingSignIn, switchAccount, removeAccount, signOut, diff --git a/web/src/lib/auth.test.ts b/web/src/lib/auth.test.ts index 39b93b75e..6eccde3e3 100644 --- a/web/src/lib/auth.test.ts +++ b/web/src/lib/auth.test.ts @@ -1,10 +1,12 @@ import type { Did } from "@atcute/lexicons/syntax"; import type { Session } from "@atcute/oauth-browser-client"; import { + LoginError, OAuthResponseError, OAuthUserAgent, TokenRefreshError, deleteStoredSession, + finalizeAuthorization, getSession } from "@atcute/oauth-browser-client"; import { type Mock, beforeEach, describe, expect, it, vi } from "vitest"; @@ -44,6 +46,7 @@ const MockedUserAgent = OAuthUserAgent as unknown as { instances: MockAgent[] }; const mockedGetSession = vi.mocked(getSession); const mockedDeleteStoredSession = vi.mocked(deleteStoredSession); +const mockedFinalize = vi.mocked(finalizeAuthorization); const alice = "did:plc:alice" as Did; const liveSession = (did: Did): Session => ({ info: { sub: did } }) as unknown as Session; @@ -108,3 +111,52 @@ describe("agentFor", () => { expect(mockedDeleteStoredSession).toHaveBeenCalledWith(alice); }); }); + +describe("completePendingSignIn", () => { + const seeded = { did: alice, handle: "alice.example" }; + + it("adopts the exchanged session without disturbing the seeded profile", async () => { + mockedFinalize.mockResolvedValue({ session: liveSession(alice), state: null }); + const auth = createAuth("http://127.0.0.1:1", seeded); + + const completion = auth.completePendingSignIn("#state=sid&code=c&iss=https://pds.example"); + // the topbar must not drop to the skeleton mid-exchange + expect(auth.resolving).toBe(false); + expect(auth.currentUser).toEqual({ did: alice, handle: "alice.example" }); + await completion; + + expect(auth.currentUser).toEqual({ did: alice, handle: "alice.example" }); + expect(mockedFinalize).toHaveBeenCalledTimes(1); + expect(mockedFinalize.mock.calls[0][0].get("code")).toBe("c"); + }); + + it("drops the seeded account and rethrows when the exchange fails", async () => { + mockedFinalize.mockRejectedValue(new LoginError("unknown state provided")); + const auth = createAuth("http://127.0.0.1:1", seeded); + + await expect(auth.completePendingSignIn("#state=sid&code=c")).rejects.toThrow( + /unknown state/ + ); + expect(auth.state.kind).toBe("logged-out"); + expect(auth.currentUser).toBeNull(); + }); + + it("blocks agentFor until the pending exchange has stored the session", async () => { + const gate = Promise.withResolvers<{ session: Session; state: null }>(); + mockedFinalize.mockReturnValue(gate.promise); + mockedGetSession.mockResolvedValue(liveSession(alice)); + const auth = createAuth("http://127.0.0.1:1", seeded); + + const completion = auth.completePendingSignIn("#state=sid&code=c"); + const agentPromise = auth.agentFor(alice); + // flush microtasks so agentFor reaches its park point on the exchange + for (let i = 0; i < 5; i++) await Promise.resolve(); + expect(mockedGetSession).not.toHaveBeenCalled(); + + gate.resolve({ session: liveSession(alice), state: null }); + await completion; + // the adopted session satisfies agentFor without a storage reload + expect((await agentPromise).sub).toBe(alice); + expect(mockedGetSession).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/auth/pending.ts b/web/src/lib/auth/pending.ts new file mode 100644 index 000000000..36b50c7d8 --- /dev/null +++ b/web/src/lib/auth/pending.ts @@ -0,0 +1,77 @@ +import { browser } from "$app/environment"; +import type { Did } from "@atcute/lexicons/syntax"; + +const PENDING_LOGIN_KEY = "tangled.pendingLogin"; +const PENDING_CALLBACK_KEY = "tangled.pendingCallback"; +const PENDING_LOGIN_MAX_AGE_MS = 10 * 60 * 1000; + +export interface PendingLogin { + did: Did; + handle: string; + returnTo: string; + at: number; +} + +export interface PendingCallback { + hash: string; + login: PendingLogin; +} + +const isPendingLogin = (value: unknown): value is PendingLogin => + !!value && + typeof value === "object" && + typeof (value as PendingLogin).did === "string" && + (value as PendingLogin).did.startsWith("did:") && + typeof (value as PendingLogin).handle === "string" && + typeof (value as PendingLogin).returnTo === "string" && + typeof (value as PendingLogin).at === "number"; + +export const writePendingLogin = (login: Omit): void => { + if (!browser) return; + localStorage.setItem(PENDING_LOGIN_KEY, JSON.stringify({ ...login, at: Date.now() })); +}; + +export const readPendingLogin = (): PendingLogin | null => { + if (!browser) return null; + try { + const raw = localStorage.getItem(PENDING_LOGIN_KEY); + if (!raw) return null; + const parsed: unknown = JSON.parse(raw); + if (!isPendingLogin(parsed)) return null; + if (Date.now() - parsed.at > PENDING_LOGIN_MAX_AGE_MS) return null; + return parsed; + } catch { + return null; + } +}; + +export const clearPendingLogin = (): void => { + if (!browser) return; + localStorage.removeItem(PENDING_LOGIN_KEY); +}; + +export const stashPendingCallback = (callback: PendingCallback): void => { + if (!browser) return; + sessionStorage.setItem(PENDING_CALLBACK_KEY, JSON.stringify(callback)); +}; + +export const consumePendingCallback = (): PendingCallback | null => { + if (!browser) return null; + try { + const raw = sessionStorage.getItem(PENDING_CALLBACK_KEY); + if (!raw) return null; + sessionStorage.removeItem(PENDING_CALLBACK_KEY); + const parsed: unknown = JSON.parse(raw); + if ( + !parsed || + typeof parsed !== "object" || + typeof (parsed as PendingCallback).hash !== "string" || + !isPendingLogin((parsed as PendingCallback).login) + ) { + return null; + } + return parsed as PendingCallback; + } catch { + return null; + } +}; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 62e24ea33..ff0650af9 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -4,7 +4,9 @@ import { goto, invalidateAll } from "$app/navigation"; import { resolve } from "$app/paths"; import type { Did } from "@atcute/lexicons/syntax"; + import { AuthorizationError } from "@atcute/oauth-browser-client"; import { createAuth, AUTH_KEY } from "$lib/auth.svelte"; + import { consumePendingCallback } from "$lib/auth/pending"; import Agentation from "$lib/components/dev/Agentation.svelte"; import Footer from "$lib/components/shell/Footer.svelte"; import Topbar from "$lib/components/shell/Topbar.svelte"; @@ -20,21 +22,39 @@ setContext(AUTH_KEY, auth); onMount(() => { - if (page.url.pathname !== "/oauth/callback") void auth.refresh(); + if (page.url.pathname === "/oauth/callback") return; + const pending = consumePendingCallback(); + if (!pending) { + void auth.refresh(); + return; + } + void (async () => { + try { + await auth.completePendingSignIn(pending.hash); + } catch (cause) { + // a previous account may still be live, recover it first + await auth.refresh(); + await loginErrorRedirect( + cause instanceof AuthorizationError ? "access_denied" : "exchange", + pending.login.handle, + pending.login.returnTo + ); + } + })(); }); + const loginErrorRedirect = (error: string, identifier: string, returnTo: string) => { + const query = new URLSearchParams({ error, identifier, return_url: returnTo }); + return goto(resolve(`/login?${query}` as "/login")); + }; + const switchAccount = async (did: Did) => { const returnTo = page.url.pathname + page.url.search; const handle = auth.accounts.find((account) => account.did === did)?.handle ?? did; try { if (await auth.switchAccount(did, returnTo)) await invalidateAll(); } catch { - const query = new URLSearchParams({ - error: "session", - identifier: handle, - return_url: returnTo - }); - await goto(resolve(`/login?${query}` as "/login")); + await loginErrorRedirect("session", handle, returnTo); } }; diff --git a/web/src/routes/oauth/callback/+page.svelte b/web/src/routes/oauth/callback/+page.svelte index 05ac5d68c..cead5df21 100644 --- a/web/src/routes/oauth/callback/+page.svelte +++ b/web/src/routes/oauth/callback/+page.svelte @@ -1,17 +1,32 @@