A lexicon-driven AppView for ATProto.
Something went wrong. Try again.
4.4 kB · 147 lines
TypeScript
at dev
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148import type { Key } from "@atproto/jwk";import type { TokenInfo } from "./types";
export interface HappyViewSessionOptions { did: string; dpopKey: Key; accessToken: string; clientKey: string; instanceUrl: string; scopes?: string; pdsUrl?: string; issuer?: string; fetch?: typeof globalThis.fetch; onSignOut?: () => Promise<void>;}
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 base64url(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, "");}
export class HappyViewSession { readonly did: string; readonly scopes: string[];
private readonly dpopKey: Key; private readonly accessToken: string; private readonly clientKey: string; private readonly instanceUrl: string; private readonly _scopes: string | undefined; private readonly _pdsUrl: string | undefined; private readonly _issuer: string | undefined; private readonly _fetch: typeof globalThis.fetch; private readonly _onSignOut: (() => Promise<void>) | undefined; private dpopNonce: string | undefined;
constructor(options: HappyViewSessionOptions) { this.did = options.did; this.scopes = options.scopes ? options.scopes.split(" ") : []; this.dpopKey = options.dpopKey; this.accessToken = options.accessToken; this.clientKey = options.clientKey; this.instanceUrl = options.instanceUrl.replace(/\/+$/, ""); this._scopes = options.scopes; this._pdsUrl = options.pdsUrl; this._issuer = options.issuer; this._fetch = options.fetch ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init)) as typeof globalThis.fetch; this._onSignOut = options.onSignOut; }
get sub(): string { return this.did; }
getTokenInfo(): TokenInfo { return { scope: this._scopes, iss: this._issuer, aud: this._pdsUrl, sub: this.did, }; }
async signOut(): Promise<void> { if (this._onSignOut) { await this._onSignOut(); } }
async fetchHandler(url: string, init: RequestInit): Promise<Response> { const fullUrl = /^https?:\/\//i.test(url) ? url : `${this.instanceUrl}${url}`; const method = (init.method ?? "GET").toUpperCase(); const htu = fullUrl.split("?")[0];
const sendWithProof = async (): Promise<Response> => { const tokenHash = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(this.accessToken), ); const ath = base64url(tokenHash);
const proof = await this.dpopKey.createJwt( { alg: "ES256", typ: "dpop+jwt", jwk: this.dpopKey.publicJwk!, }, { htm: method, htu, iat: Math.floor(Date.now() / 1000), jti: randomHex(16), ath, nonce: this.dpopNonce, }, );
const headers = new Headers(init.headers); headers.set("authorization", `DPoP ${this.accessToken}`); headers.set("dpop", proof); headers.set("x-client-key", this.clientKey);
return this._fetch(fullUrl, { ...init, headers }); };
let response: Response; try { response = await sendWithProof(); } catch (e) { const info = `_fetch type: ${typeof this._fetch}, toString: ${String(this._fetch).slice(0, 120)}, url: ${fullUrl}`; throw new Error(`fetchHandler failed: ${(e as Error).message}\n\nDEBUG: ${info}`); }
// Retry once if the server requires a DPoP nonce we didn't have const nonce = response.headers.get("dpop-nonce"); if (nonce && nonce !== this.dpopNonce && (response.status === 401 || response.status === 400)) { this.dpopNonce = nonce; try { response = await sendWithProof(); } catch (e) { throw new Error(`fetchHandler retry failed: ${(e as Error).message}`); } const retryNonce = response.headers.get("dpop-nonce"); if (retryNonce) { this.dpopNonce = retryNonce; } } else if (nonce) { this.dpopNonce = nonce; }
return response; }}