ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto
16
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: oauth to hono

byarielm.fyi 50bace61 e0e224d1

verified
+241
+81
packages/api/src/infrastructure/oauth/OAuthClientFactory.ts
··· 1 + import { 2 + NodeOAuthClient, 3 + atprotoLoopbackClientMetadata, 4 + } from "@atproto/oauth-client-node"; 5 + import { JoseKey } from "@atproto/jwk-jose"; 6 + import { Context } from "hono"; 7 + import { ApiError } from "../../errors"; 8 + import { stateStore, sessionStore } from "./stores"; 9 + import { getOAuthConfig, CONFIG } from "./config"; 10 + 11 + /** 12 + * Normalizes OAuth private key format (handles escaped newlines) 13 + */ 14 + function normalizePrivateKey(key: string): string { 15 + if (!key.includes("\n") && key.includes("\\n")) { 16 + return key.replace(/\\n/g, "\n"); 17 + } 18 + return key; 19 + } 20 + 21 + /** 22 + * Creates an AT Protocol OAuth client configured for the current environment 23 + * @param c - Hono context (for determining host/environment) 24 + * @returns Configured NodeOAuthClient instance 25 + */ 26 + export async function createOAuthClient(c: Context): Promise<NodeOAuthClient> { 27 + const config = getOAuthConfig(c); 28 + const isDev = config.clientType === "loopback"; 29 + 30 + if (isDev) { 31 + console.log("[oauth-client] Creating loopback OAuth client"); 32 + const clientMetadata = atprotoLoopbackClientMetadata(config.clientId); 33 + 34 + return new NodeOAuthClient({ 35 + clientMetadata, 36 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 37 + stateStore: stateStore as any, 38 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 39 + sessionStore: sessionStore as any, 40 + }); 41 + } 42 + 43 + // Production client with private key 44 + console.log("[oauth-client] Creating production OAuth client"); 45 + 46 + if (!process.env.OAUTH_PRIVATE_KEY) { 47 + throw new ApiError( 48 + "OAuth client key missing", 49 + 500, 50 + "OAUTH_PRIVATE_KEY environment variable is required for production client setup.", 51 + ); 52 + } 53 + 54 + const normalizedKey = normalizePrivateKey(process.env.OAUTH_PRIVATE_KEY); 55 + const privateKey = await JoseKey.fromImportable( 56 + normalizedKey, 57 + CONFIG.OAUTH_KEY_ID, 58 + ); 59 + 60 + return new NodeOAuthClient({ 61 + clientMetadata: { 62 + client_id: config.clientId, 63 + client_name: "ATlast", 64 + client_uri: config.clientId.replace("/api/auth/client-metadata.json", ""), 65 + redirect_uris: [config.redirectUri], 66 + scope: CONFIG.OAUTH_SCOPES, 67 + grant_types: ["authorization_code", "refresh_token"], 68 + response_types: ["code"], 69 + application_type: "web", 70 + token_endpoint_auth_method: "private_key_jwt", 71 + token_endpoint_auth_signing_alg: "ES256", 72 + dpop_bound_access_tokens: true, 73 + jwks_uri: config.jwksUri, 74 + }, 75 + keyset: [privateKey], 76 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 77 + stateStore: stateStore as any, 78 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 79 + sessionStore: sessionStore as any, 80 + }); 81 + }
+108
packages/api/src/infrastructure/oauth/config.ts
··· 1 + import { OAuthConfig } from "./types"; 2 + import { Context } from "hono"; 3 + 4 + // OAuth configuration constants 5 + const OAUTH_SCOPES = "atproto transition:generic"; 6 + const STATE_EXPIRY = 10 * 60 * 1000; // 10 minutes 7 + const SESSION_EXPIRY = 7 * 24 * 60 * 60 * 1000; // 7 days 8 + 9 + export const CONFIG = { 10 + OAUTH_SCOPES, 11 + OAUTH_KEY_ID: "atlast-key-1", 12 + STATE_EXPIRY, 13 + SESSION_EXPIRY, 14 + }; 15 + 16 + // Simple in-memory cache for OAuth configs (5 min expiry) 17 + const configCache = new Map< 18 + string, 19 + { config: OAuthConfig; expiresAt: number } 20 + >(); 21 + 22 + /** 23 + * Get OAuth configuration based on request context 24 + * Handles both local development (loopback) and production (confidential client) 25 + */ 26 + export function getOAuthConfig(c: Context): OAuthConfig { 27 + // Determine host from request 28 + const host = c.req.header("host"); 29 + const forwardedHost = c.req.header("x-forwarded-host"); 30 + const forwardedProto = c.req.header("x-forwarded-proto") || "https"; 31 + 32 + console.log("[oauth-config] Host from headers:", { 33 + host, 34 + forwardedHost, 35 + forwardedProto, 36 + }); 37 + 38 + // Check cache 39 + const cacheKey = `oauth-config-${host || "default"}`; 40 + const cached = configCache.get(cacheKey); 41 + if (cached && cached.expiresAt > Date.now()) { 42 + return cached.config; 43 + } 44 + 45 + // Determine if we're running locally 46 + const isLocal = 47 + !forwardedHost && 48 + (!host || host.includes("localhost") || host.includes("127.0.0.1")); 49 + 50 + // Local OAuth configuration (loopback) 51 + if (isLocal) { 52 + const currentHost = host || "localhost:3000"; 53 + const protocol = currentHost.includes("127.0.0.1") 54 + ? "http://127.0.0.1" 55 + : "http://localhost"; 56 + 57 + const port = currentHost.split(":")[1] || "3000"; 58 + const redirectUri = `${protocol}:${port}/api/auth/oauth-callback`; 59 + 60 + // ClientID must start with localhost 61 + const clientId = `http://localhost?${new URLSearchParams([ 62 + ["redirect_uri", redirectUri], 63 + ["scope", CONFIG.OAUTH_SCOPES], 64 + ])}`; 65 + 66 + console.log("[oauth-config] Using loopback OAuth for local development"); 67 + 68 + const config: OAuthConfig = { 69 + clientId, 70 + redirectUri, 71 + jwksUri: undefined, 72 + clientType: "loopback", 73 + }; 74 + 75 + // Cache for 5 minutes 76 + configCache.set(cacheKey, { 77 + config, 78 + expiresAt: Date.now() + 5 * 60 * 1000, 79 + }); 80 + 81 + return config; 82 + } 83 + 84 + // Production OAuth configuration (confidential client) 85 + const baseHost = forwardedHost || host; 86 + if (!baseHost) { 87 + throw new Error("No base URL available for OAuth configuration"); 88 + } 89 + 90 + const baseUrl = `${forwardedProto}://${baseHost}`; 91 + console.log("[oauth-config] Using confidential OAuth client for:", baseUrl); 92 + 93 + const config: OAuthConfig = { 94 + clientId: `${baseUrl}/api/auth/client-metadata.json`, 95 + redirectUri: `${baseUrl}/api/auth/oauth-callback`, 96 + jwksUri: `${baseUrl}/api/auth/jwks`, 97 + clientType: "discoverable", 98 + usePrivateKey: true, 99 + }; 100 + 101 + // Cache for 5 minutes 102 + configCache.set(cacheKey, { 103 + config, 104 + expiresAt: Date.now() + 5 * 60 * 1000, 105 + }); 106 + 107 + return config; 108 + }
+4
packages/api/src/infrastructure/oauth/index.ts
··· 1 + export * from "./types"; 2 + export * from "./config"; 3 + export * from "./OAuthClientFactory"; 4 + export * from "./stores";
+48
packages/api/src/infrastructure/oauth/types.ts
··· 1 + /** 2 + * OAuth types for AT Protocol authentication 3 + */ 4 + 5 + /** 6 + * OAuth configuration 7 + */ 8 + export interface OAuthConfig { 9 + clientId: string; 10 + redirectUri: string; 11 + jwksUri?: string; 12 + clientType: "loopback" | "discoverable"; 13 + usePrivateKey?: boolean; 14 + } 15 + 16 + /** 17 + * State data stored during OAuth flow 18 + */ 19 + export interface StateData { 20 + iss: string; 21 + dpopKey: unknown; // JWK object from @atproto/oauth-client-node 22 + verifier?: string; 23 + appState?: string; 24 + } 25 + 26 + /** 27 + * Session data for OAuth sessions 28 + */ 29 + export interface SessionData { 30 + dpopJwk: unknown; // JWK object 31 + tokenSet: { 32 + access_token: string; 33 + refresh_token?: string; 34 + token_type: string; 35 + expires_in?: number; 36 + scope?: string; 37 + sub: string; 38 + }; 39 + authMethod: string; 40 + } 41 + 42 + /** 43 + * User session data 44 + */ 45 + export interface UserSessionData { 46 + did: string; 47 + fingerprint?: string; 48 + }