diff --git a/web/src/lib/api/actAs.test.ts b/web/src/lib/api/actAs.test.ts new file mode 100644 index 000000000..9cf52f9a7 --- /dev/null +++ b/web/src/lib/api/actAs.test.ts @@ -0,0 +1,224 @@ +import type { Did } from "@atcute/lexicons/syntax"; +import { + OAuthResponseError, + OAuthUserAgent, + TokenRefreshError, + createAuthorizationUrl, + deleteStoredSession, + finalizeAuthorization, + getSession, + type Session +} from "@atcute/oauth-browser-client"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createDelegatedSession, + delegatedSessionController, + EMPTY_ORG_PROFILE, + getOrCreateDelegatedSession +} from "$lib/api/actAs"; +import { ensureProfile } from "$lib/api/profile"; + +vi.mock("@atcute/oauth-browser-client", async (importOriginal) => { + const mod = await importOriginal>(); + class MockOAuthUserAgent { + readonly sub: string; + constructor(readonly session: Session) { + this.sub = session.info.sub; + } + } + return { + ...mod, + OAuthUserAgent: MockOAuthUserAgent, + createAuthorizationUrl: vi.fn(), + finalizeAuthorization: vi.fn(), + getSession: vi.fn(), + deleteStoredSession: vi.fn() + }; +}); + +vi.mock("$lib/api/profile", () => ({ ensureProfile: vi.fn(async () => {}) })); + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +const controllerDid = "did:plc:controller" as Did; +const delegatedDid = "did:plc:org" as Did; +const requestUri = "urn:ietf:params:oauth:request_uri:request-1"; + +const sessionWithClaims = (claims: unknown): Session => { + const payload = btoa(JSON.stringify(claims)) + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + return { token: { access: `header.${payload}.signature` } } as Session; +}; + +// a session on pds.example for the org, minted by `controller` +const orgSession = (controller: string): Session => + ({ + info: { sub: delegatedDid, aud: "https://pds.example" }, + token: { + access: sessionWithClaims({ act: { sub: controller } }).token.access, + scope: "atproto" + }, + dpopKey: {} + }) as Session; + +const controllerAgentOn = (pds: string): OAuthUserAgent => + ({ + sub: controllerDid, + session: { info: { aud: pds }, token: { scope: "atproto repo:sh.tangled.repo" } }, + handle: vi.fn(async (_path: string, _init: RequestInit) => + Response.json({ token: "service-token" }, { status: 200 }) + ) + }) as unknown as OAuthUserAgent; + +// the headless flow on pds.example, answered the way tranquil would +const answerHeadlessFlow = ( + minted: Session, + consent: unknown = { scopes: [{ scope: "atproto" }] } +) => { + vi.mocked(createAuthorizationUrl).mockImplementation(async () => { + const url = new URL("https://pds.example/oauth/authorize"); + url.searchParams.set("request_uri", requestUri); + return url; + }); + fetchMock + .mockResolvedValueOnce(Response.json({ success: true })) + .mockResolvedValueOnce(Response.json(consent)) + .mockResolvedValueOnce( + Response.json({ + redirect_uri: + "https://pds.example/oauth/authorize/redirect?code=code-1&state=state-1" + }) + ); + vi.mocked(finalizeAuthorization).mockResolvedValue({ session: minted, state: null }); +}; + +beforeEach(() => { + vi.clearAllMocks(); + fetchMock.mockReset(); +}); + +describe("delegatedSessionController", () => { + it("returns the controller DID from the access token act claim", () => { + expect(delegatedSessionController(sessionWithClaims({ act: { sub: controllerDid } }))).toBe( + controllerDid + ); + }); + + it("rejects sessions without a valid controller claim", () => { + expect(delegatedSessionController(sessionWithClaims({ sub: "did:plc:org" }))).toBeNull(); + expect(delegatedSessionController({ token: { access: "opaque" } } as Session)).toBeNull(); + }); +}); + +describe("createDelegatedSession", () => { + it.each(["https://pds.example", "https://remote.example"])( + "authorizes headlessly for a controller on %s", + async (controllerPds) => { + const controllerAgent = controllerAgentOn(controllerPds); + const session = orgSession(controllerDid); + answerHeadlessFlow(session, { + scopes: [ + { scope: "atproto" }, + { scope: "repo:allowed" }, + { scope: "repo:restricted", restricted: true } + ], + permission_sets: [{ include_scope: "include:example.permissions" }] + }); + + const agent = await createDelegatedSession(controllerAgent, delegatedDid); + + expect(agent).toBeInstanceOf(OAuthUserAgent); + expect(agent.session).toBe(session); + const handle = vi.mocked(controllerAgent.handle); + expect(handle).toHaveBeenCalledTimes(1); + const authParams = new URL(String(handle.mock.calls[0][0]), controllerPds).searchParams; + expect(authParams.get("aud")).toBe("did:web:pds.example"); + expect(authParams.get("lxm")).toBe("farm.tranquil.delegation.authorize"); + expect(String(fetchMock.mock.calls[0][0])).toBe( + "https://pds.example/oauth/delegation/auth-token" + ); + expect(fetchMock.mock.calls[0][1]).toMatchObject({ + headers: { authorization: "Bearer service-token" }, + body: JSON.stringify({ request_uri: requestUri, delegated_did: delegatedDid }) + }); + expect(String(fetchMock.mock.calls[1][0])).toContain( + "https://pds.example/oauth/authorize/consent?" + ); + expect(fetchMock).toHaveBeenCalledTimes(3); + const approval = fetchMock.mock.calls[2][1] as RequestInit; + expect(JSON.parse(String(approval.body))).toEqual({ + request_uri: requestUri, + approved_scopes: ["atproto", "repo:allowed", "include:example.permissions"], + remember: false + }); + expect(finalizeAuthorization).toHaveBeenCalledTimes(1); + const params = vi.mocked(finalizeAuthorization).mock.calls[0][0]; + expect(Object.fromEntries(params)).toEqual({ + iss: "https://pds.example", + code: "code-1", + state: "state-1" + }); + } + ); +}); + +describe("getOrCreateDelegatedSession", () => { + const controllerAgent = controllerAgentOn("https://pds.example"); + + it("reuses a stored session minted by this controller and makes sure the org has a profile", async () => { + const stored = orgSession(controllerDid); + vi.mocked(getSession).mockResolvedValue(stored); + + const agent = await getOrCreateDelegatedSession(controllerAgent, delegatedDid); + + expect(agent.session).toBe(stored); + expect(createAuthorizationUrl).not.toHaveBeenCalled(); + expect(deleteStoredSession).not.toHaveBeenCalled(); + expect(ensureProfile).toHaveBeenCalledWith(agent, EMPTY_ORG_PROFILE); + }); + + it.each([ + ["a revoked refresh token", new TokenRefreshError(delegatedDid, "session was revoked")], + [ + "an invalid_token answer from the token endpoint", + new OAuthResponseError(new Response(null, { status: 400 }), { + error: "invalid_token", + error_description: '"exp" claim timestamp check failed' + }) + ] + ])("remints after %s", async (_label, cause) => { + const minted = orgSession(controllerDid); + vi.mocked(getSession).mockRejectedValue(cause); + answerHeadlessFlow(minted); + + const agent = await getOrCreateDelegatedSession(controllerAgent, delegatedDid); + + expect(finalizeAuthorization).toHaveBeenCalledTimes(1); + expect(agent.session).toBe(minted); + expect(ensureProfile).toHaveBeenCalledWith(agent, EMPTY_ORG_PROFILE); + }); + + it("remints when the stored session was minted by another controller", async () => { + vi.mocked(getSession).mockResolvedValue(orgSession("did:plc:other")); + answerHeadlessFlow(orgSession(controllerDid)); + + await getOrCreateDelegatedSession(controllerAgent, delegatedDid); + + expect(deleteStoredSession).toHaveBeenCalledWith(delegatedDid); + expect(finalizeAuthorization).toHaveBeenCalledTimes(1); + }); + + it("keeps a session that only failed transiently", async () => { + const cause = new TypeError("fetch failed"); + vi.mocked(getSession).mockRejectedValue(cause); + + await expect(getOrCreateDelegatedSession(controllerAgent, delegatedDid)).rejects.toBe( + cause + ); + expect(finalizeAuthorization).not.toHaveBeenCalled(); + expect(ensureProfile).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/api/actAs.ts b/web/src/lib/api/actAs.ts new file mode 100644 index 000000000..3b5ed4898 --- /dev/null +++ b/web/src/lib/api/actAs.ts @@ -0,0 +1,208 @@ +import type { ActorIdentifier, Did } from "@atcute/lexicons/syntax"; +import { + OAuthUserAgent, + createAuthorizationUrl, + deleteStoredSession, + finalizeAuthorization, + getSession, + type Session +} from "@atcute/oauth-browser-client"; +import { ensureProfile } from "$lib/api/profile"; +import type { ProfileRecord } from "$lib/api/records"; +import { markOrgDid, orgControllerFor } from "$lib/auth/accounts"; +import { mintServiceAuth, serviceDidForHost } from "$lib/auth/agent"; +import { isDeadSessionError } from "$lib/auth/session"; + +export const EMPTY_ORG_PROFILE: ProfileRecord = { + $type: "sh.tangled.actor.profile", + bluesky: false, + isOrganization: true +}; + +interface ConsentScope { + scope: string; + restricted?: boolean; +} + +interface ConsentPermissionSet { + include_scope: string; + restricted?: boolean; +} + +interface ConsentData { + scopes: ConsentScope[]; + permission_sets?: ConsentPermissionSet[]; +} + +interface ApprovalData { + redirect_uri?: string; + error?: string; + error_description?: string; +} + +type AccessTokenClaims = { + act?: { sub?: unknown }; +}; + +export const delegatedSessionController = (session: Pick): Did | null => { + try { + const encodedPayload = session.token.access.split(".")[1]; + if (!encodedPayload) return null; + const base64 = encodedPayload.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + const claims = JSON.parse(atob(padded)) as AccessTokenClaims; + const controller = claims.act?.sub; + return typeof controller === "string" && controller.startsWith("did:") + ? (controller as Did) + : null; + } catch { + return null; + } +}; + +const approvedScopesOf = (consent: ConsentData): string[] => { + const scopes = new Set([ + ...consent.scopes.filter((s) => !s.restricted).map((s) => s.scope), + ...(consent.permission_sets ?? []).filter((s) => !s.restricted).map((s) => s.include_scope) + ]); + if (consent.scopes.some((s) => s.scope === "atproto")) { + scopes.add("atproto"); + } + return scopes.size > 0 ? [...scopes] : ["atproto"]; +}; + +const createAuthorizationRequest = async ( + controllerAgent: OAuthUserAgent, + delegatedDid: Did +): Promise => { + const authorizationUrl = await createAuthorizationUrl({ + target: { type: "account", identifier: delegatedDid as ActorIdentifier }, + scope: controllerAgent.session.token.scope + }); + const requestUri = authorizationUrl.searchParams.get("request_uri"); + if (!requestUri) throw new Error("tranquil did not return a request_uri for the org account."); + return authorizationUrl; +}; + +const bindAuthorizationRequest = async ( + controllerAgent: OAuthUserAgent, + delegatedDid: Did, + requestUri: string, + pds: URL +): Promise => { + const token = await mintServiceAuth(controllerAgent, { + aud: serviceDidForHost(pds.host), + lxm: "farm.tranquil.delegation.authorize" + }); + const response = await fetch(new URL("/oauth/delegation/auth-token", pds), { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, + body: JSON.stringify({ request_uri: requestUri, delegated_did: delegatedDid }) + }); + const fallback = "Could not bind the org account's authorization request."; + // error bodies may not be json (proxy pages, timeouts), so parse leniently + const data: { success?: boolean; error?: string } = await response.json().catch(() => ({})); + if (!response.ok || !data.success) throw new Error(data.error || fallback); +}; + +const consentEndpoint = (pds: URL): URL => new URL("/oauth/authorize/consent", pds); + +const loadConsent = async (pds: URL, requestUri: string): Promise => { + const url = consentEndpoint(pds); + url.searchParams.set("request_uri", requestUri); + const response = await fetch(url); + if (!response.ok) throw new Error("Could not load the org account's authorization request."); + return response.json(); +}; + +const approveConsent = async ( + pds: URL, + requestUri: string, + consent: ConsentData +): Promise => { + const response = await fetch(consentEndpoint(pds), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + request_uri: requestUri, + approved_scopes: approvedScopesOf(consent), + remember: false + }) + }); + const data: ApprovalData = await response.json(); + if (!data.redirect_uri) { + throw new Error( + data.error_description || data.error || "Authorizing the org account failed." + ); + } + return data.redirect_uri; +}; + +const callbackParams = (intermediateRedirect: string, issuer: string): URLSearchParams => { + const intermediate = new URL(intermediateRedirect); + const code = intermediate.searchParams.get("code"); + if (!code) throw new Error("tranquil did not return an authorization code."); + + const params = new URLSearchParams({ iss: new URL(issuer).origin, code }); + const state = intermediate.searchParams.get("state"); + if (state) params.set("state", state); + return params; +}; + +export const createDelegatedSession = async ( + controllerAgent: OAuthUserAgent, + delegatedDid: Did +): Promise => { + const authorizationUrl = await createAuthorizationRequest(controllerAgent, delegatedDid); + const requestUri = authorizationUrl.searchParams.get("request_uri")!; + await bindAuthorizationRequest(controllerAgent, delegatedDid, requestUri, authorizationUrl); + const consent = await loadConsent(authorizationUrl, requestUri); + const redirect = await approveConsent(authorizationUrl, requestUri, consent); + const params = callbackParams(redirect, authorizationUrl.origin); + const { session } = await finalizeAuthorization(params); + if ( + session.info.sub !== delegatedDid || + delegatedSessionController(session) !== controllerAgent.sub + ) { + deleteStoredSession(session.info.sub); + throw new Error("The authorized organization or controller did not match the request."); + } + return new OAuthUserAgent(session); +}; + +const storedDelegatedSession = async ( + controllerDid: Did, + delegatedDid: Did +): Promise => { + const recordedController = orgControllerFor(delegatedDid); + if (recordedController && recordedController !== controllerDid) { + deleteStoredSession(delegatedDid); + return null; + } + try { + const session = await getSession(delegatedDid); + if (delegatedSessionController(session) !== controllerDid) { + deleteStoredSession(delegatedDid); + return null; + } + return session; + } catch (cause) { + if (!isDeadSessionError(cause)) throw cause; + return null; + } +}; + +// prefer this over createDelegatedSession directly +export const getOrCreateDelegatedSession = async ( + controllerAgent: OAuthUserAgent, + delegatedDid: Did +): Promise => { + const controllerDid = controllerAgent.sub as Did; + const stored = await storedDelegatedSession(controllerDid, delegatedDid); + const agent = stored + ? new OAuthUserAgent(stored) + : await createDelegatedSession(controllerAgent, delegatedDid); + markOrgDid(delegatedDid, controllerDid); + await ensureProfile(agent, EMPTY_ORG_PROFILE); + return agent; +}; diff --git a/web/src/lib/api/profile.test.ts b/web/src/lib/api/profile.test.ts index 1f20db8c4..cd77977c4 100644 --- a/web/src/lib/api/profile.test.ts +++ b/web/src/lib/api/profile.test.ts @@ -1,8 +1,9 @@ -import { expect, it, vi } from "vitest"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { describe, expect, it, vi } from "vitest"; import { createBobbinClient } from "$lib/api/client"; import { FOLLOW_VIEWER, STAR_VIEWER } from "$lib/api/descriptors"; import { TYPE_VIEWER } from "$lib/api/enrich"; -import { getTrendingWithViewerState } from "$lib/api/profile"; +import { ensureProfile, getTrendingWithViewerState, hasProfile } from "$lib/api/profile"; it("gets viewer-neutral trending data with relationship state in the enrichment sidecar", async () => { const fetchMock = vi.fn().mockResolvedValue( @@ -34,3 +35,73 @@ it("gets viewer-neutral trending data with relationship state in the enrichment ] }); }); + +describe("profile existence", () => { + const pds = "https://pds.example"; + const cid = "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a"; + const agent = (handle: ReturnType) => + ({ + sub: "did:plc:org", + session: { info: { aud: pds } }, + handle + }) as unknown as OAuthUserAgent; + const found = () => + Response.json({ + uri: "at://did:plc:org/sh.tangled.actor.profile/self", + cid, + value: { $type: "sh.tangled.actor.profile", bluesky: false } + }); + const missing = () => + Response.json( + { error: "RecordNotFound", message: "Could not locate record" }, + { status: 400 } + ); + + it("reads the record from the account's own pds", async () => { + const handle = vi.fn(async () => found()); + + expect(await hasProfile(agent(handle))).toBe(true); + const [path] = handle.mock.calls[0] as unknown as [string]; + const url = new URL(path, pds); + expect(url.pathname).toBe("/xrpc/com.atproto.repo.getRecord"); + expect(url.searchParams.get("repo")).toBe("did:plc:org"); + expect(url.searchParams.get("collection")).toBe("sh.tangled.actor.profile"); + expect(url.searchParams.get("rkey")).toBe("self"); + }); + + it("treats a missing record as no profile, and anything else as an error", async () => { + expect(await hasProfile(agent(vi.fn(async () => missing())))).toBe(false); + await expect( + hasProfile( + agent( + vi.fn(async () => + Response.json({ error: "UpstreamFailure", message: "" }, { status: 502 }) + ) + ) + ) + ).rejects.toThrow(); + }); + + it("writes the record only when it is missing", async () => { + const record = { + $type: "sh.tangled.actor.profile", + bluesky: false, + isOrganization: true + } as const; + const written = vi + .fn() + .mockResolvedValueOnce(missing()) + .mockResolvedValueOnce( + Response.json({ uri: "at://did:plc:org/sh.tangled.actor.profile/self", cid }) + ); + await ensureProfile(agent(written), record); + expect(written).toHaveBeenCalledTimes(2); + const [path, init] = written.mock.calls[1] as unknown as [string, RequestInit]; + expect(new URL(path, pds).pathname).toBe("/xrpc/com.atproto.repo.putRecord"); + expect(JSON.parse(String(init.body))).toMatchObject({ rkey: "self", record }); + + const untouched = vi.fn(async () => found()); + await ensureProfile(agent(untouched), record); + expect(untouched).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/lib/api/profile.ts b/web/src/lib/api/profile.ts index e43db86e7..0923c050d 100644 --- a/web/src/lib/api/profile.ts +++ b/web/src/lib/api/profile.ts @@ -1,4 +1,5 @@ -import { ok } from "@atcute/client"; +import { ClientResponseError, ok } from "@atcute/client"; +import { mainSchema as getRecordSchema } from "@atcute/atproto/types/repo/getRecord"; import { mainSchema as uploadBlobSchema } from "@atcute/atproto/types/repo/uploadBlob"; import type { Nsid } from "@atcute/lexicons/syntax"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; @@ -53,3 +54,29 @@ export const putProfile = async ( ): Promise => { return putRecord(agent, PROFILE_COLLECTION, "self", record); }; + +// asks the account's own pds rather than bobbin: a fresh account is not indexed +// yet, and a stale index answer here would overwrite a profile the user edited +export const hasProfile = async (agent: OAuthUserAgent): Promise => { + try { + await ok( + createClient(agent).call(getRecordSchema, { + params: { repo: agent.sub, collection: PROFILE_COLLECTION, rkey: "self" } + }) + ); + return true; + } catch (cause) { + if (cause instanceof ClientResponseError && cause.error === "RecordNotFound") return false; + throw cause; + } +}; + +// the counterpart of the home page's getProfile check that sends new users to +// /welcome; accounts nobody logs into as themselves get their profile written here +export const ensureProfile = async ( + agent: OAuthUserAgent, + record: ProfileRecord +): Promise => { + if (await hasProfile(agent)) return; + await putProfile(agent, record); +}; diff --git a/web/src/lib/auth.svelte.ts b/web/src/lib/auth.svelte.ts index a38430326..eb8c9bebf 100644 --- a/web/src/lib/auth.svelte.ts +++ b/web/src/lib/auth.svelte.ts @@ -8,9 +8,7 @@ import { } from "@atcute/identity-resolver"; import type { ActorIdentifier, Did } from "@atcute/lexicons/syntax"; import { - OAuthResponseError, OAuthUserAgent, - TokenRefreshError, configureOAuth, createAuthorizationUrl, deleteStoredSession, @@ -26,17 +24,20 @@ import { clearActive, dropAccount, loadAccounts, + loadOrgDids, persistActive, readActiveDid, reconcileAccounts, saveAccounts, upsertAccount } from "$lib/auth/accounts"; +import { revokeAllOrgSessions, revokeOrgSessionsFor } from "$lib/auth/orgSessions"; import { consumePendingCallback, writePendingLogin } from "$lib/auth/pending"; import { safeReturnTo } from "$lib/auth/returnTo"; +import { isDeadSessionError } from "$lib/auth/session"; export const AUTH_KEY = Symbol("auth"); -const DEV_REDIRECT_URI = "http://127.0.0.1:5173/oauth/callback"; +const DEV_REDIRECT_URI = `http://127.0.0.1:${import.meta.env.VITE_DEV_PORT ?? "5173"}/oauth/callback`; const DEV_CLIENT_ID = `http://localhost?redirect_uri=${encodeURIComponent(DEV_REDIRECT_URI)}&scope=${encodeURIComponent(oauthMetadata.scope)}`; // local dev (localinfra) points these at the local pds/plc; defaults are the public network. @@ -151,15 +152,24 @@ const configure = () => { const errorMessage = (cause: unknown) => { const message = cause instanceof Error ? cause.message : String(cause); return message.toLowerCase().includes("unknown state") - ? "Could not resume OAuth state. In local development, start login from http://127.0.0.1:5173 instead of localhost." + ? `Could not resume OAuth state. In local development, start login from ${new URL(DEV_REDIRECT_URI).origin} instead of localhost.` : message; }; -const isDeadSessionError = (cause: unknown): boolean => - cause instanceof TokenRefreshError || - (cause instanceof OAuthResponseError && - cause.status === 400 && - (cause.error === "invalid_grant" || cause.error === "invalid_token")); +export const refreshSessionCandidates = ( + activeDid: Did | null, + currentDid: Did | null, + storedDids: readonly Did[], + orgDids: readonly Did[] +): Did[] => { + const candidates: Did[] = []; + for (const candidate of [activeDid, currentDid, ...storedDids]) { + if (candidate && !orgDids.includes(candidate) && !candidates.includes(candidate)) { + candidates.push(candidate); + } + } + return candidates; +}; const resolveProfile = async ( identifier: string, @@ -219,7 +229,11 @@ export const createAuth = ( // merge atcute's stored sessions with persisted account metadata. const syncAccounts = () => { - accounts = reconcileAccounts(browser ? listStoredSessions() : [], loadAccounts()); + accounts = reconcileAccounts( + browser ? listStoredSessions() : [], + loadAccounts(), + loadOrgDids() + ); saveAccounts(accounts); }; @@ -258,7 +272,7 @@ export const createAuth = ( addedAt: Math.floor(Date.now() / 1000) }); saveAccounts(meta); - accounts = reconcileAccounts(listStoredSessions(), meta); + accounts = reconcileAccounts(listStoredSessions(), meta, loadOrgDids()); persistActive(did, nextProfile.handle); }; @@ -283,6 +297,8 @@ export const createAuth = ( otherAgents.delete(did); deleteStoredSession(did); saveAccounts(dropAccount(loadAccounts(), did)); + // the controller is gone, its org sessions have no one left to act for + void revokeOrgSessionsFor(did); }; const activate = async (did: Did): Promise => { @@ -350,10 +366,12 @@ export const createAuth = ( state = { kind: "loading", did: previousDid, profile: previousProfile }; syncAccounts(); - const candidates: Did[] = []; - for (const candidate of [readActiveDid(), currentDid(), ...listStoredSessions()]) { - if (candidate && !candidates.includes(candidate)) candidates.push(candidate); - } + const candidates = refreshSessionCandidates( + readActiveDid(), + currentDid(), + listStoredSessions(), + loadOrgDids() + ); let lastFailure: string | undefined; for (const candidate of candidates) { @@ -470,8 +488,9 @@ export const createAuth = ( } otherAgents.delete(did); + await revokeOrgSessionsFor(did); saveAccounts(dropAccount(loadAccounts(), did)); - accounts = reconcileAccounts(listStoredSessions(), loadAccounts()); + accounts = reconcileAccounts(listStoredSessions(), loadAccounts(), loadOrgDids()); if (wasActive) { for (const account of accounts) { @@ -492,7 +511,10 @@ export const createAuth = ( const signOutAll = async () => { const agents = [currentAgent(), ...otherAgents.values()].filter((agent) => agent !== null); - await Promise.allSettled(agents.map((agent) => agent.signOut())); + await Promise.allSettled([ + ...agents.map((agent) => agent.signOut()), + revokeAllOrgSessions() + ]); otherAgents.clear(); if (browser) { for (const did of listStoredSessions()) deleteStoredSession(did); diff --git a/web/src/lib/auth.test.ts b/web/src/lib/auth.test.ts index 4f4ff08b0..d16ac2a1c 100644 --- a/web/src/lib/auth.test.ts +++ b/web/src/lib/auth.test.ts @@ -10,7 +10,7 @@ import { getSession } from "@atcute/oauth-browser-client"; import { type Mock, beforeEach, describe, expect, it, vi } from "vitest"; -import { createAuth, resumeSignIn } from "$lib/auth.svelte"; +import { createAuth, refreshSessionCandidates, resumeSignIn } from "$lib/auth.svelte"; import { consumePendingCallback } from "$lib/auth/pending"; interface MockAgent { @@ -63,6 +63,21 @@ beforeEach(() => { MockedUserAgent.instances.length = 0; }); +describe("refreshSessionCandidates", () => { + it("excludes delegated organization sessions even when one was active", () => { + const org = "did:plc:org" as Did; + const bob = "did:plc:bob" as Did; + + expect(refreshSessionCandidates(org, org, [org, alice, bob], [org])).toEqual([alice, bob]); + }); + + it("deduplicates personal session candidates in priority order", () => { + const bob = "did:plc:bob" as Did; + + expect(refreshSessionCandidates(alice, bob, [bob, alice], [])).toEqual([alice, bob]); + }); +}); + describe("agentFor", () => { it("returns a live agent and reuses it", async () => { mockedGetSession.mockResolvedValue(liveSession(alice)); diff --git a/web/src/lib/auth/accounts.ts b/web/src/lib/auth/accounts.ts index 48c05c229..d629d60a4 100644 --- a/web/src/lib/auth/accounts.ts +++ b/web/src/lib/auth/accounts.ts @@ -6,6 +6,8 @@ import { isDid, type Did } from "@atcute/lexicons/syntax"; export const CURRENT_DID_KEY = "tangled.currentDid"; export const CURRENT_HANDLE_KEY = "tangled.currentHandle"; const ACCOUNTS_KEY = "tangled.accounts"; +const ORG_DIDS_KEY = "tangled.orgDids"; +const ORG_CONTROLLERS_KEY = "tangled.orgControllers"; // appview account cap parity export const MAX_ACCOUNTS = 20; @@ -45,22 +47,102 @@ export const saveAccounts = (accounts: readonly AuthAccount[]): void => { localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); }; -// stored sessions are authoritative, metadata only supplies order and handle +// stored sessions are authoritative, metadata only supplies order and handle. +// +// org dids are passed in so that they're excluded here +// rather than showing up in the account switcher export const reconcileAccounts = ( stored: readonly Did[], - meta: readonly AuthAccount[] + meta: readonly AuthAccount[], + orgDids: readonly Did[] = [] ): AuthAccount[] => { + const orgSet = new Set(orgDids); const storedSet = new Set(stored); const known = new Set(meta.map((account) => account.did)); - const ordered = meta.filter((account) => storedSet.has(account.did)); + const ordered = meta.filter( + (account) => storedSet.has(account.did) && !orgSet.has(account.did) + ); for (const did of stored) { - if (!known.has(did)) { + if (!known.has(did) && !orgSet.has(did)) { ordered.push({ did, handle: did, addedAt: Math.floor(Date.now() / 1000) }); } } return ordered; }; +const isDidArray = (value: unknown): value is Did[] => Array.isArray(value) && value.every(isDid); + +export const loadOrgDids = (): Did[] => { + if (!browser) return []; + try { + const raw = localStorage.getItem(ORG_DIDS_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + return isDidArray(parsed) ? parsed : []; + } catch { + return []; + } +}; + +export const loadOrgControllers = (): Record => { + if (!browser) return {}; + try { + const raw = localStorage.getItem(ORG_CONTROLLERS_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const controllers: Record = {}; + for (const [delegatedDid, controllerDid] of Object.entries(parsed)) { + if (isDid(delegatedDid) && isDid(controllerDid)) { + controllers[delegatedDid] = controllerDid; + } + } + return controllers; + } catch { + return {}; + } +}; + +export const orgControllerFor = (did: Did): Did | undefined => loadOrgControllers()[did]; + +export const orgDidsControlledBy = ( + controllers: Readonly>, + controllerDid: Did +): Did[] => + Object.entries(controllers) + .filter(([, controller]) => controller === controllerDid) + .map(([orgDid]) => orgDid as Did); + +export const forgetOrgDid = (did: Did): void => { + if (!browser) return; + localStorage.setItem( + ORG_DIDS_KEY, + JSON.stringify(loadOrgDids().filter((orgDid) => orgDid !== did)) + ); + const { [did]: _dropped, ...controllers } = loadOrgControllers(); + localStorage.setItem(ORG_CONTROLLERS_KEY, JSON.stringify(controllers)); +}; + +export const clearOrgDids = (): void => { + if (!browser) return; + localStorage.removeItem(ORG_DIDS_KEY); + localStorage.removeItem(ORG_CONTROLLERS_KEY); +}; + +export const markOrgDid = (did: Did, controllerDid?: Did): void => { + if (!browser) return; + const current = loadOrgDids(); + if (!current.includes(did)) { + localStorage.setItem(ORG_DIDS_KEY, JSON.stringify([...current, did])); + } + if (controllerDid) { + localStorage.setItem( + ORG_CONTROLLERS_KEY, + JSON.stringify({ ...loadOrgControllers(), [did]: controllerDid }) + ); + } +}; + // dedupe by did, preserve insertion order, and keep the original addedAt. export const upsertAccount = ( accounts: readonly AuthAccount[], diff --git a/web/src/lib/auth/orgSessions.test.ts b/web/src/lib/auth/orgSessions.test.ts new file mode 100644 index 000000000..bc9c30820 --- /dev/null +++ b/web/src/lib/auth/orgSessions.test.ts @@ -0,0 +1,83 @@ +import type { Did } from "@atcute/lexicons/syntax"; +import { TokenRefreshError, type Session } from "@atcute/oauth-browser-client"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { orgDidsControlledBy } from "$lib/auth/accounts"; +import { + revokeAllOrgSessions, + revokeOrgSessionsFor, + type OrgSessionDependencies +} from "$lib/auth/orgSessions"; + +const alice = "did:plc:alice" as Did; +const bob = "did:plc:bob" as Did; +const acme = "did:plc:acme" as Did; +const globex = "did:plc:globex" as Did; +const initech = "did:plc:initech" as Did; + +const controllers: Record = { [acme]: alice, [globex]: alice, [initech]: bob }; + +const signOut = vi.fn(async () => {}); +const deps: OrgSessionDependencies = { + loadOrgDids: vi.fn(() => Object.keys(controllers) as Did[]), + loadOrgControllers: vi.fn(() => controllers), + getSession: vi.fn(async (sub: Did) => ({ info: { sub } }) as unknown as Session), + createAgent: vi.fn(() => ({ signOut })), + deleteStoredSession: vi.fn(), + forgetOrgDid: vi.fn(), + clearOrgDids: vi.fn() +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("orgDidsControlledBy", () => { + it("lists only the orgs minted from the given controller", () => { + expect(orgDidsControlledBy(controllers, alice).sort()).toEqual([acme, globex].sort()); + expect(orgDidsControlledBy(controllers, "did:plc:nobody" as Did)).toEqual([]); + }); +}); + +describe("revokeOrgSessionsFor", () => { + it("revokes the controller's org sessions without a refresh and forgets them", async () => { + await revokeOrgSessionsFor(alice, deps); + + expect(deps.getSession).toHaveBeenCalledWith(acme, { allowStale: true }); + expect(deps.getSession).toHaveBeenCalledWith(globex, { allowStale: true }); + expect(deps.getSession).not.toHaveBeenCalledWith(initech, expect.anything()); + expect(signOut).toHaveBeenCalledTimes(2); + expect(deps.forgetOrgDid).toHaveBeenCalledWith(acme); + expect(deps.forgetOrgDid).toHaveBeenCalledWith(globex); + expect(deps.forgetOrgDid).not.toHaveBeenCalledWith(initech); + }); + + it("drops the local copy when there is nothing left to revoke", async () => { + vi.mocked(deps.getSession).mockRejectedValueOnce( + new TokenRefreshError(acme, "session deleted by another tab") + ); + + await revokeOrgSessionsFor(alice, deps); + + expect(deps.deleteStoredSession).toHaveBeenCalledWith(acme); + expect(deps.deleteStoredSession).not.toHaveBeenCalledWith(globex); + expect(deps.forgetOrgDid).toHaveBeenCalledWith(acme); + }); + + it("still forgets an org whose revocation request failed", async () => { + signOut.mockRejectedValueOnce(new TypeError("fetch failed")); + + await revokeOrgSessionsFor(alice, deps); + + expect(deps.deleteStoredSession).toHaveBeenCalledTimes(1); + expect(deps.forgetOrgDid).toHaveBeenCalledTimes(2); + }); +}); + +describe("revokeAllOrgSessions", () => { + it("revokes every recorded org session and clears the bookkeeping", async () => { + await revokeAllOrgSessions(deps); + + expect(signOut).toHaveBeenCalledTimes(3); + expect(deps.clearOrgDids).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/lib/auth/orgSessions.ts b/web/src/lib/auth/orgSessions.ts new file mode 100644 index 000000000..8de3a9e22 --- /dev/null +++ b/web/src/lib/auth/orgSessions.ts @@ -0,0 +1,64 @@ +import type { Did } from "@atcute/lexicons/syntax"; +import { + OAuthUserAgent, + deleteStoredSession, + getSession, + type Session +} from "@atcute/oauth-browser-client"; +import { + clearOrgDids, + forgetOrgDid, + loadOrgControllers, + loadOrgDids, + orgDidsControlledBy +} from "$lib/auth/accounts"; + +// org sessions are minted from a controller's session and are useless to the +// user once that controller signs out, so they get revoked alongside it +export interface OrgSessionDependencies { + loadOrgDids: () => Did[]; + loadOrgControllers: () => Record; + getSession: (sub: Did, options?: { allowStale?: boolean }) => Promise; + createAgent: (session: Session) => Pick; + deleteStoredSession: (sub: Did) => void; + forgetOrgDid: (did: Did) => void; + clearOrgDids: () => void; +} + +const defaultDependencies: OrgSessionDependencies = { + loadOrgDids, + loadOrgControllers, + getSession, + createAgent: (session) => new OAuthUserAgent(session), + deleteStoredSession, + forgetOrgDid, + clearOrgDids +}; + +const revokeOne = async (orgDid: Did, deps: OrgSessionDependencies): Promise => { + try { + // allowStale skips a refresh, the token is about to be revoked anyway + const session = await deps.getSession(orgDid, { allowStale: true }); + await deps.createAgent(session).signOut(); + } catch { + // nothing to revoke, or the pds refused, either way drop the local copy + deps.deleteStoredSession(orgDid); + } +}; + +export const revokeOrgSessionsFor = async ( + controllerDid: Did, + deps: OrgSessionDependencies = defaultDependencies +): Promise => { + const orgDids = orgDidsControlledBy(deps.loadOrgControllers(), controllerDid); + await Promise.allSettled(orgDids.map((orgDid) => revokeOne(orgDid, deps))); + for (const orgDid of orgDids) deps.forgetOrgDid(orgDid); +}; + +export const revokeAllOrgSessions = async ( + deps: OrgSessionDependencies = defaultDependencies +): Promise => { + const orgDids = deps.loadOrgDids(); + await Promise.allSettled(orgDids.map((orgDid) => revokeOne(orgDid, deps))); + deps.clearOrgDids(); +}; diff --git a/web/src/lib/auth/session.ts b/web/src/lib/auth/session.ts new file mode 100644 index 000000000..dabf67443 --- /dev/null +++ b/web/src/lib/auth/session.ts @@ -0,0 +1,9 @@ +import { OAuthResponseError, TokenRefreshError } from "@atcute/oauth-browser-client"; + +// a refresh that the pds rejected outright, the session is gone and only a new +// authorization brings it back. anything else (network, 5xx) is transient. +export const isDeadSessionError = (cause: unknown): boolean => + cause instanceof TokenRefreshError || + (cause instanceof OAuthResponseError && + cause.status === 400 && + (cause.error === "invalid_grant" || cause.error === "invalid_token"));