diff --git a/web/src/lib/api/actAs.test.ts b/web/src/lib/api/actAs.test.ts new file mode 100644 index 000000000..50174944b --- /dev/null +++ b/web/src/lib/api/actAs.test.ts @@ -0,0 +1,124 @@ +import type { Did } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent, Session } from "@atcute/oauth-browser-client"; +import { describe, expect, it, vi } from "vitest"; +import { + createDelegatedSession, + delegatedSessionController, + type DelegatedSessionDependencies +} from "$lib/api/actAs"; + +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; +}; + +describe("delegatedSessionController", () => { + it("returns the controller DID from the access token act claim", () => { + const controller = "did:plc:controller" as Did; + expect(delegatedSessionController(sessionWithClaims({ act: { sub: controller } }))).toBe( + controller + ); + }); + + 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 delegatedDid = "did:plc:org" as Did; + const requestUri = "urn:ietf:params:oauth:request_uri:request-1"; + const handle = vi.fn(async (_path: string, _init: RequestInit) => + Response.json({ token: "service-token" }, { status: 200 }) + ); + const controllerAgent = { + sub: "did:plc:controller", + session: { + info: { aud: controllerPds }, + token: { scope: "atproto repo:sh.tangled.repo" } + }, + handle + } as unknown as OAuthUserAgent; + const session = { + info: { sub: delegatedDid, aud: "https://pds.example" }, + token: { + access: sessionWithClaims({ act: { sub: "did:plc:controller" } }).token.access, + scope: "atproto" + }, + dpopKey: {} + } as Session; + const delegatedAgent = { session } as OAuthUserAgent; + const createAuthorization = vi.fn(async () => { + const url = new URL("https://pds.example/oauth/authorize"); + url.searchParams.set("request_uri", requestUri); + return url; + }); + const fetchFn = vi + .fn() + .mockResolvedValueOnce(Response.json({ success: true })) + .mockResolvedValueOnce( + Response.json({ + scopes: [ + { scope: "atproto" }, + { scope: "repo:allowed" }, + { scope: "repo:restricted", restricted: true } + ], + permission_sets: [{ include_scope: "include:example.permissions" }] + }) + ) + .mockResolvedValueOnce( + Response.json({ + redirect_uri: "https://pds.example/oauth/authorize/redirect?code=code-1&state=state-1" + }) + ); + const finalize = vi.fn(async (_params: URLSearchParams) => ({ session, state: null })); + const createAgent = vi.fn(() => delegatedAgent); + const dependencies = { + createAuthorizationUrl: createAuthorization, + finalizeAuthorization: finalize, + fetch: fetchFn, + createAgent + } as unknown as DelegatedSessionDependencies; + + const agent = await createDelegatedSession(controllerAgent, delegatedDid, dependencies); + + expect(agent).toBe(delegatedAgent); + expect(createAgent).toHaveBeenCalledWith(session); + 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(fetchFn.mock.calls[0][0])).toBe( + "https://pds.example/oauth/delegation/auth-token" + ); + expect(fetchFn.mock.calls[0][1]).toMatchObject({ + headers: { authorization: "Bearer service-token" }, + body: JSON.stringify({ request_uri: requestUri, delegated_did: delegatedDid }) + }); + expect(String(fetchFn.mock.calls[1][0])).toContain( + "https://pds.example/oauth/authorize/consent?" + ); + expect(fetchFn).toHaveBeenCalledTimes(3); + const approval = fetchFn.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(finalize).toHaveBeenCalledTimes(1); + const params = finalize.mock.calls[0][0]; + expect(Object.fromEntries(params)).toEqual({ + iss: "https://pds.example", + code: "code-1", + state: "state-1" + }); + } + ); +}); diff --git a/web/src/lib/api/actAs.ts b/web/src/lib/api/actAs.ts new file mode 100644 index 000000000..9d41b2cef --- /dev/null +++ b/web/src/lib/api/actAs.ts @@ -0,0 +1,229 @@ +import type { ActorIdentifier, Did } from "@atcute/lexicons/syntax"; +import { + OAuthUserAgent, + TokenRefreshError, + createAuthorizationUrl, + deleteStoredSession, + finalizeAuthorization, + getSession, + type Session +} from "@atcute/oauth-browser-client"; +import { mintServiceAuth, serviceDidForHost } from "$lib/auth/agent"; + +export const AUTHORIZE_DELEGATE_NSID = "farm.tranquil.delegation.authorize"; +import { markOrgDid, orgControllerFor } from "$lib/auth/accounts"; + +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; +} + +export interface DelegatedSessionDependencies { + createAuthorizationUrl: typeof createAuthorizationUrl; + finalizeAuthorization: typeof finalizeAuthorization; + fetch: typeof globalThis.fetch; + createAgent: (session: Session) => OAuthUserAgent; +} + +const defaultDependencies: DelegatedSessionDependencies = { + createAuthorizationUrl, + finalizeAuthorization, + fetch: globalThis.fetch, + createAgent: (session) => new OAuthUserAgent(session) +}; + +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, + createUrl: typeof createAuthorizationUrl +): Promise => { + const authorizationUrl = await createUrl({ + 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, + fetchFn: typeof globalThis.fetch +): Promise => { + const token = await mintServiceAuth(controllerAgent, { + aud: serviceDidForHost(pds.host), + lxm: AUTHORIZE_DELEGATE_NSID + }); + const response = await fetchFn(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 data: { success?: boolean; error?: string } = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.error || "Could not bind the org account's authorization request."); + } +}; + +const consentEndpoint = (pds: URL): URL => new URL("/oauth/authorize/consent", pds); + +const loadConsent = async ( + pds: URL, + requestUri: string, + fetchFn: typeof globalThis.fetch +): Promise => { + const url = consentEndpoint(pds); + url.searchParams.set("request_uri", requestUri); + const response = await fetchFn(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, + fetchFn: typeof globalThis.fetch +): Promise => { + const response = await fetchFn(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 => { + // Tranquil returns its own intermediate redirect. Its code and state are + // already sufficient to construct the final callback without following it. + 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, + dependencies: DelegatedSessionDependencies = defaultDependencies +): Promise => { + const authorizationUrl = await createAuthorizationRequest( + controllerAgent, + delegatedDid, + dependencies.createAuthorizationUrl + ); + const requestUri = authorizationUrl.searchParams.get("request_uri")!; + await bindAuthorizationRequest( + controllerAgent, + delegatedDid, + requestUri, + authorizationUrl, + dependencies.fetch + ); + const consent = await loadConsent(authorizationUrl, requestUri, dependencies.fetch); + const redirect = await approveConsent(authorizationUrl, requestUri, consent, dependencies.fetch); + const params = callbackParams(redirect, authorizationUrl.origin); + const { session } = await dependencies.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 dependencies.createAgent(session); +}; + +// prefer this over createDelegatedSession directly +export const getOrCreateDelegatedSession = async ( + controllerAgent: OAuthUserAgent, + delegatedDid: Did +): Promise => { + const controllerDid = controllerAgent.sub as Did; + const create = async () => { + const agent = await createDelegatedSession(controllerAgent, delegatedDid, defaultDependencies); + markOrgDid(delegatedDid, controllerDid); + return agent; + }; + + const recordedController = orgControllerFor(delegatedDid); + if (recordedController && recordedController !== controllerDid) { + deleteStoredSession(delegatedDid); + return create(); + } + + try { + const session = await getSession(delegatedDid); + if (delegatedSessionController(session) !== controllerDid) { + deleteStoredSession(delegatedDid); + return create(); + } + markOrgDid(delegatedDid, controllerDid); + return new OAuthUserAgent(session); + } catch (cause) { + if (!(cause instanceof TokenRefreshError)) throw cause; + return create(); + } +}; diff --git a/web/src/lib/auth.svelte.ts b/web/src/lib/auth.svelte.ts index a38430326..18ea111e6 100644 --- a/web/src/lib/auth.svelte.ts +++ b/web/src/lib/auth.svelte.ts @@ -26,6 +26,7 @@ import { clearActive, dropAccount, loadAccounts, + loadOrgDids, persistActive, readActiveDid, reconcileAccounts, @@ -36,7 +37,7 @@ import { consumePendingCallback, writePendingLogin } from "$lib/auth/pending"; import { safeReturnTo } from "$lib/auth/returnTo"; 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,7 +152,7 @@ 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; }; @@ -161,6 +162,21 @@ const isDeadSessionError = (cause: unknown): boolean => 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, bobbinUrl: string @@ -219,7 +235,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 +278,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); }; @@ -350,10 +370,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) { @@ -471,7 +493,7 @@ export const createAuth = ( otherAgents.delete(did); saveAccounts(dropAccount(loadAccounts(), did)); - accounts = reconcileAccounts(listStoredSessions(), loadAccounts()); + accounts = reconcileAccounts(listStoredSessions(), loadAccounts(), loadOrgDids()); if (wasActive) { for (const account of accounts) { 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 730a40d9e..c2f57b218 100644 --- a/web/src/lib/auth/accounts.ts +++ b/web/src/lib/auth/accounts.ts @@ -6,6 +6,8 @@ import 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; @@ -48,22 +50,78 @@ 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 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[],