A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
14 kB · 462 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver";import { DidResolverCommon } from "@atproto-labs/did-resolver";import type { DidDocument } from "@atproto/did";import { HappyViewOAuthClient, HappyViewSession, importJwk, jwkThumbprint, InvalidStateError, OAuthCallbackError, ResolutionError, TokenExchangeError, type SessionEventHooks, type StorageAdapter,} from "@happyview/oauth-client";
export interface HappyViewNodeClientOptions { instanceUrl: string; clientId: string; clientKey: string; clientSecret?: string; redirectUri: string; scopes?: string; storage: StorageAdapter; sessionHooks?: SessionEventHooks; fetch?: typeof globalThis.fetch;}
export interface AuthorizeOptions { scope?: string; /** @deprecated Use `scope` instead. */ scopes?: string; state?: string; redirect_uri?: string; signal?: AbortSignal; display?: "page" | "popup" | "touch" | "wap"; prompt?: string; nonce?: string; max_age?: number; ui_locales?: string; dpop_jkt?: string; claims?: Record<string, Record<string, null | Record<string, unknown>>>; authorization_details?: unknown[]; id_token_hint?: string;}
export interface CallbackOptions { redirect_uri?: string;}
interface PendingAuthState { did: string; provisionId: string; rawJwk: JsonWebKey; provisionPkceVerifier: string; authPkceVerifier: string; pdsUrl: string; tokenEndpoint: string; state: string; issuer: string; authUrl?: string;}
interface AuthServerMetadata { issuer: string; authorization_endpoint: string; token_endpoint: string; pushed_authorization_request_endpoint?: string; dpop_signing_alg_values_supported?: string[];}
export class HappyViewNodeClient extends HappyViewOAuthClient { readonly handleResolver: AtprotoDohHandleResolver; readonly didResolver: DidResolverCommon; private readonly clientId: string; private readonly redirectUri: string; private readonly scopes: string;
constructor(options: HappyViewNodeClientOptions) { const fetchFn = options.fetch ?? (((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch);
super({ instanceUrl: options.instanceUrl, clientKey: options.clientKey, clientSecret: options.clientSecret, storage: options.storage, sessionHooks: options.sessionHooks, fetch: fetchFn, });
this.clientId = options.clientId; this.redirectUri = options.redirectUri; this.scopes = options.scopes ?? "atproto"; this.handleResolver = new AtprotoDohHandleResolver({ dohEndpoint: "https://dns.google/resolve", fetch: fetchFn, }); this.didResolver = new DidResolverCommon({ fetch: fetchFn }); }
async authorize( handle: string, options?: AuthorizeOptions, ): Promise<URL> { const resolvedDid = await this.handleResolver.resolve(handle); if (!resolvedDid) { throw new ResolutionError(`Failed to resolve handle: ${handle}`); } const did = resolvedDid as string;
const didDoc = await this.didResolver.resolve(resolvedDid); const pdsUrl = extractPdsUrl(didDoc); const authMeta = await this.fetchAuthServerMetadata(pdsUrl);
const scopes = options?.scope ?? options?.scopes ?? this.scopes;
const { provisionId, rawJwk, pkceVerifier: provisionPkceVerifier } = await this.provisionDpopKey();
const authPkceVerifier = generatePkceVerifier(); const authPkceChallenge = await computePkceChallenge(authPkceVerifier);
const state = options?.state ?? randomHex(16);
const pendingState: PendingAuthState = { did, provisionId, rawJwk, provisionPkceVerifier: provisionPkceVerifier!, authPkceVerifier, pdsUrl, tokenEndpoint: authMeta.token_endpoint, state, issuer: authMeta.issuer, }; await this.storage.set( `pending-auth:${state}`, JSON.stringify(pendingState), );
const redirectUri = options?.redirect_uri ?? this.redirectUri;
const authParams = new URLSearchParams({ response_type: "code", client_id: this.clientId, redirect_uri: redirectUri, state, scope: scopes, code_challenge: authPkceChallenge, code_challenge_method: "S256", login_hint: handle, });
if (options?.display) authParams.set("display", options.display); if (options?.prompt) authParams.set("prompt", options.prompt); if (options?.nonce) authParams.set("nonce", options.nonce); if (options?.max_age != null) authParams.set("max_age", String(options.max_age)); if (options?.ui_locales) authParams.set("ui_locales", options.ui_locales); // ⚠ THE AUTHORIZATION REQUEST MUST BE BOUND TO THE DPoP KEY, and this client // is the only thing that can do it — `provisionDpopKey` above returned the // very key that signs the token-endpoint proof in `callback`, and it never // leaves the SDK. Sent unbound, the PAR carries neither a `DPoP` header nor // `dpop_jkt`; bsky's PDS tolerates that, but the tolerance is deprecated // (https://atproto.com/blog/oauth-improvements#deprecation-notice) and other // implementations reject the request outright. A caller-supplied `dpop_jkt` // still wins, for anyone driving the key themselves. authParams.set("dpop_jkt", options?.dpop_jkt ?? (await jwkThumbprint(rawJwk))); if (options?.id_token_hint) authParams.set("id_token_hint", options.id_token_hint); if (options?.claims) authParams.set("claims", JSON.stringify(options.claims)); if (options?.authorization_details) authParams.set("authorization_details", JSON.stringify(options.authorization_details));
const parEndpoint = authMeta.pushed_authorization_request_endpoint; if (parEndpoint) { const parResp = await this._fetch(parEndpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", }, body: authParams, });
if (!parResp.ok) { const err = await parResp.text(); throw new ResolutionError( `PAR request failed: ${parResp.status} ${err}`, ); }
const parData = (await parResp.json()) as { request_uri: string }; const url = new URL( `${authMeta.authorization_endpoint}?` + new URLSearchParams({ client_id: this.clientId, request_uri: parData.request_uri, }), ); pendingState.authUrl = url.href; await this.storage.set(`pending-auth:${state}`, JSON.stringify(pendingState)); await this.storage.set(`pending-auth-url:${url.href}`, state); return url; }
const url = new URL( `${authMeta.authorization_endpoint}?${authParams}`, ); pendingState.authUrl = url.href; await this.storage.set(`pending-auth:${state}`, JSON.stringify(pendingState)); await this.storage.set(`pending-auth-url:${url.href}`, state); return url; }
async callback( params: URLSearchParams, options?: CallbackOptions, ): Promise<{ session: HappyViewSession; state: string | null }> { const code = params.get("code"); const state = params.get("state");
if (!state) { throw new OAuthCallbackError(params, 'Missing "state" parameter'); }
const pendingJson = await this.storage.get(`pending-auth:${state}`); if (!pendingJson) { throw new OAuthCallbackError( params, `Unknown authorization session "${state}"`, state, ); }
if (params.has("error")) { await this.storage.delete(`pending-auth:${state}`); throw new OAuthCallbackError(params, undefined, state); }
if (!code) { throw new OAuthCallbackError( params, 'Missing "code" parameter', state, ); } const pending: PendingAuthState = JSON.parse(pendingJson);
try { const dpopKey = await importJwk(pending.rawJwk); const { d: _, ...publicJwk } = pending.rawJwk; const redirectUri = options?.redirect_uri ?? this.redirectUri;
let dpopNonce: string | undefined; let tokenResp!: Response;
for (let attempt = 0; attempt < 2; attempt++) { const proof = await dpopKey.createJwt( { alg: "ES256", typ: "dpop+jwt", jwk: publicJwk as any, }, { htm: "POST", htu: pending.tokenEndpoint, iat: Math.floor(Date.now() / 1000), jti: randomHex(16), ...(dpopNonce ? { nonce: dpopNonce } : {}), }, );
tokenResp = await this._fetch(pending.tokenEndpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", dpop: proof, }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: redirectUri, client_id: this.clientId, code_verifier: pending.authPkceVerifier, }), });
if (!tokenResp.ok && attempt === 0) { const nonceHeader = tokenResp.headers.get("dpop-nonce"); if (nonceHeader) { const errorBody = await tokenResp.text(); if (errorBody.includes("use_dpop_nonce")) { dpopNonce = nonceHeader; continue; } throw new TokenExchangeError( `Token exchange failed: ${tokenResp.status} ${errorBody}`, tokenResp.status, errorBody, ); } }
break; }
if (!tokenResp!.ok) { const err = await tokenResp!.text(); throw new TokenExchangeError( `Token exchange failed: ${tokenResp!.status} ${err}`, tokenResp!.status, err, ); }
const tokens = (await tokenResp.json()) as { access_token: string; refresh_token?: string; scope?: string; sub?: string; iss?: string; };
const session = await this.registerSession({ provisionId: pending.provisionId, pkceVerifier: pending.provisionPkceVerifier, did: pending.did, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, scopes: tokens.scope ?? this.scopes, pdsUrl: pending.pdsUrl, issuer: tokens.iss ?? pending.issuer, dpopKey: pending.rawJwk, });
await this.storage.delete(`pending-auth:${state}`); if (pending.authUrl) { await this.storage.delete(`pending-auth-url:${pending.authUrl}`); }
return { session, state }; } catch (err) { throw OAuthCallbackError.from(err, params, state); } }
override async restore(did?: string, _refresh?: boolean | "auto"): Promise<HappyViewSession> { if (!did) { throw new InvalidStateError( "DID is required for restore() in the Node client", ); } const session = await this.restoreSession(did); if (!session) { throw new InvalidStateError(`No session found for ${did}`); } return session; }
async revoke(did: string): Promise<void> { await this.deleteSession(did); }
async abortRequest(url: URL): Promise<void> { const urlKey = `pending-auth-url:${url.href}`; const state = await this.storage.get(urlKey); if (state) { await this.storage.delete(`pending-auth:${state}`); await this.storage.delete(urlKey); } }
private async fetchAuthServerMetadata( pdsUrl: string, ): Promise<AuthServerMetadata> { const base = pdsUrl.replace(/\/+$/, "");
const resourceResp = await this._fetch( `${base}/.well-known/oauth-protected-resource`, ); if (!resourceResp.ok) { throw new ResolutionError( `Failed to fetch protected resource metadata from ${pdsUrl}: ${resourceResp.status}`, ); } const resource = (await resourceResp.json()) as { authorization_servers?: string[]; }; const authServer = resource.authorization_servers?.[0]; if (!authServer) { throw new ResolutionError( `No authorization server found in protected resource metadata from ${pdsUrl}`, ); }
const metaResp = await this._fetch( `${authServer.replace(/\/+$/, "")}/.well-known/oauth-authorization-server`, ); if (!metaResp.ok) { throw new ResolutionError( `Failed to fetch auth server metadata from ${authServer}: ${metaResp.status}`, ); } return metaResp.json() as Promise<AuthServerMetadata>; }}
function extractPdsUrl(doc: DidDocument): string { const services = doc.service ?? []; for (const service of services) { if ( service.id === "#atproto_pds" || (typeof service.id === "string" && service.id.endsWith("#atproto_pds")) ) { if (typeof service.serviceEndpoint === "string") { return service.serviceEndpoint; } throw new ResolutionError( `#atproto_pds service endpoint is not a string URL in DID document for ${doc.id}`, ); } } throw new ResolutionError( `No #atproto_pds service found in DID document for ${doc.id}`, );}
function randomHex(byteLength: number): string { const bytes = crypto.getRandomValues(new Uint8Array(byteLength)); return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join( "", );}
function generatePkceVerifier(): string { const bytes = crypto.getRandomValues(new Uint8Array(32)); let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, "");}
async function computePkceChallenge(verifier: string): Promise<string> { const hash = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(verifier), ); const bytes = new Uint8Array(hash); let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, "");}