Something went wrong. Try again.
AtAuth
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219/** * OAuth Service * * Handles AT Protocol OAuth flow using @atproto/oauth-client-node */
import { NodeOAuthClient, NodeSavedState, NodeSavedSession } from '@atproto/oauth-client-node';import { DatabaseService } from './database.js';import type { OAuthState } from '../types/index.js';
// In-memory storage for OAuth client sessionsconst sessionStore = new Map<string, NodeSavedSession>();const stateStore = new Map<string, NodeSavedState>();
// Capture the most recently set state keylet pendingStateKey: string | null = null;
export interface OAuthResult { did: string; handle: string; accessJwt?: string; refreshJwt?: string;}
export class OAuthService { private client: NodeOAuthClient | null = null; private db: DatabaseService; private clientId: string; private redirectUri: string;
constructor(db: DatabaseService, clientId: string, redirectUri: string) { this.db = db; this.clientId = clientId; this.redirectUri = redirectUri; }
async initialize(additionalRedirectUris?: string[]): Promise<void> { // Derive proxy callback from the primary redirect URI's base const baseUrl = this.redirectUri.replace(/\/[^/]*$/, ''); const proxyCallbackUri = `${baseUrl}/proxy/callback`;
// Collect all redirect URIs: primary, proxy, and any additional (e.g. OIDC callback) const redirectUris: [string, ...string[]] = [this.redirectUri, proxyCallbackUri]; if (additionalRedirectUris) { for (const uri of additionalRedirectUris) { if (!redirectUris.includes(uri)) { redirectUris.push(uri); } } }
this.client = new NodeOAuthClient({ clientMetadata: { client_id: this.clientId, client_name: 'ATAuth Gateway', client_uri: this.clientId, redirect_uris: redirectUris, grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], scope: 'atproto transition:generic', application_type: 'web', token_endpoint_auth_method: 'none', dpop_bound_access_tokens: true, }, stateStore: { async get(key: string): Promise<NodeSavedState | undefined> { return stateStore.get(key); }, async set(key: string, state: NodeSavedState): Promise<void> { stateStore.set(key, state); pendingStateKey = key; }, async del(key: string): Promise<void> { stateStore.delete(key); }, }, sessionStore: { async get(key: string): Promise<NodeSavedSession | undefined> { return sessionStore.get(key); }, async set(key: string, session: NodeSavedSession): Promise<void> { sessionStore.set(key, session); }, async del(key: string): Promise<void> { sessionStore.delete(key); }, }, }); }
/** * Generate OAuth authorization URL * * @param appId - The application identifier * @param handle - The user's AT Protocol handle * @param customRedirect - Optional custom redirect URI */ async generateAuthUrl(appId: string, handle: string, customRedirect?: string, appRedirectUri?: string): Promise<{ url: string; state: string; }> { if (!this.client) { throw new Error('OAuth client not initialized'); }
if (!handle) { throw new Error('Handle is required for OAuth authorization'); }
pendingStateKey = null;
const authorizeOptions: Record<string, string> = { scope: 'atproto transition:generic', }; if (customRedirect) { authorizeOptions.redirect_uri = customRedirect; }
const url = await this.client.authorize(handle, authorizeOptions);
const state = pendingStateKey; if (!state) { throw new Error('OAuth library did not store state'); }
const oauthState: OAuthState = { state, code_verifier: '', app_id: appId, redirect_uri: appRedirectUri || customRedirect || this.redirectUri, created_at: Math.floor(Date.now() / 1000), }; this.db.saveOAuthState(oauthState);
console.log(`OAuth state saved: ${state} for app ${appId}`);
return { url: url.toString(), state }; }
/** * Handle OAuth callback and exchange code for tokens * * @param params - URL search params from the callback * @param redirectUri - The redirect_uri used during authorization (must match) */ async handleCallback(params: URLSearchParams, redirectUri?: string): Promise<OAuthResult> { if (!this.client) { throw new Error('OAuth client not initialized'); }
const callbackOptions: Record<string, string> = {}; if (redirectUri) { callbackOptions.redirect_uri = redirectUri; }
const { session } = await this.client.callback(params, callbackOptions);
const did: string = session.did;
let handle: string = did; try { const resolved = await this.resolveDidToHandle(did); if (resolved) { handle = resolved; } } catch { // Fall back to DID }
return { did, handle }; }
/** * Restore a PDS session for a user and make an authenticated xRPC call. * Returns the response from the user's PDS. */ async proxyPdsRequest(did: string, pathname: string, init?: RequestInit): Promise<Response> { if (!this.client) { throw new Error('OAuth client not initialized'); }
const session = await this.client.restore(did); return session.fetchHandler(pathname, init); }
/** * Check if a PDS session exists for a DID. */ hasPdsSession(did: string): boolean { return sessionStore.has(did); }
private async resolveDidToHandle(did: string): Promise<string | null> { try { const response = await fetch( `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` ); if (!response.ok) return null; const data = (await response.json()) as { handle: string }; return data.handle; } catch { return null; } }
async resolveHandle(handle: string): Promise<string | null> { try { const response = await fetch( `https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` ); if (!response.ok) return null; const data = (await response.json()) as { did: string }; return data.did; } catch { return null; } }}