an atproto based link aggregator
at main 116 lines 3.8 kB view raw
1import { 2 NodeOAuthClient, 3 type OAuthClientMetadataInput, 4 Keyset, 5 JoseKey 6} from '@atproto/oauth-client-node'; 7import { StateStore, SessionStore } from './storage'; 8import type { Database } from '../db'; 9import { env } from '$env/dynamic/private'; 10 11// Environment variables 12// PUBLIC_URL: e.g., https://papili.one (set in production) 13// PRIVATE_KEY_ES256: JWK string for signing (required in production) 14// COOKIE_SECRET: Secret for iron-session cookies 15 16// Granular OAuth scopes - only request access to our own collections 17// See: https://github.com/bluesky-social/atproto/discussions/4118 18const OAUTH_SCOPE = 'atproto repo:one.papili.post repo:one.papili.comment'; 19 20/** 21 * Creates an OAuth client for the given database connection. 22 * Handles both development (loopback) and production (confidential client) modes. 23 */ 24export async function createOAuthClient(db: Database): Promise<NodeOAuthClient> { 25 const PUBLIC_URL = env.PUBLIC_URL; 26 const PRIVATE_KEY_ES256 = env.PRIVATE_KEY_ES256; 27 28 // Build keyset for confidential client (production only) 29 let keyset: Keyset | undefined; 30 31 if (PUBLIC_URL && PRIVATE_KEY_ES256) { 32 try { 33 const jwk = JSON.parse(PRIVATE_KEY_ES256); 34 const key = await JoseKey.fromJWK(jwk); 35 keyset = new Keyset([key]); 36 } catch (err) { 37 console.error('Failed to parse PRIVATE_KEY_ES256:', err); 38 throw new Error('Invalid PRIVATE_KEY_ES256. Must be a valid ES256 JWK JSON string.'); 39 } 40 } 41 42 // For production, we need a keyset 43 if (PUBLIC_URL && !keyset) { 44 throw new Error( 45 'Production mode requires PRIVATE_KEY_ES256 environment variable. ' + 46 'Generate one with: npx @atproto/oauth-client-node gen-jwk' 47 ); 48 } 49 50 const hasSigningKey = keyset && keyset.size > 0; 51 52 // Build client metadata based on environment 53 const clientMetadata: OAuthClientMetadataInput = PUBLIC_URL 54 ? { 55 // Production: Confidential client 56 client_name: 'papili.one', 57 client_id: `${PUBLIC_URL}/oauth/client-metadata.json`, 58 client_uri: PUBLIC_URL, 59 redirect_uris: [`${PUBLIC_URL}/oauth/callback`], 60 scope: OAUTH_SCOPE, 61 grant_types: ['authorization_code', 'refresh_token'], 62 response_types: ['code'], 63 application_type: 'web', 64 token_endpoint_auth_method: hasSigningKey ? 'private_key_jwt' : 'none', 65 token_endpoint_auth_signing_alg: hasSigningKey ? 'ES256' : undefined, 66 dpop_bound_access_tokens: true, 67 jwks_uri: `${PUBLIC_URL}/oauth/jwks.json` 68 } 69 : { 70 // Development: Loopback client (RFC 8252) 71 // ATProto requires 127.0.0.1 for loopback, not localhost 72 client_name: 'papili.one (Development)', 73 client_id: `http://localhost?redirect_uri=${encodeURIComponent( 74 'http://127.0.0.1:5173/oauth/callback' 75 )}&scope=${encodeURIComponent(OAUTH_SCOPE)}`, 76 redirect_uris: ['http://127.0.0.1:5173/oauth/callback'], 77 scope: OAUTH_SCOPE, 78 grant_types: ['authorization_code', 'refresh_token'], 79 response_types: ['code'], 80 application_type: 'web', 81 token_endpoint_auth_method: 'none', 82 dpop_bound_access_tokens: true 83 }; 84 85 return new NodeOAuthClient({ 86 clientMetadata, 87 keyset, 88 stateStore: new StateStore(db), 89 sessionStore: new SessionStore(db) 90 // Note: D1 doesn't support advisory locks, so we skip requestLock 91 // This means concurrent token refreshes could race, but it's acceptable for MVP 92 }); 93} 94 95/** 96 * Cookie secret for iron-session. 97 */ 98export function getCookieSecret(): string { 99 const COOKIE_SECRET = env.COOKIE_SECRET; 100 101 if (!COOKIE_SECRET) { 102 if (env.PUBLIC_URL) { 103 throw new Error('COOKIE_SECRET is required in production'); 104 } 105 // Default for development 106 return 'development-secret-at-least-32-characters-long'; 107 } 108 return COOKIE_SECRET; 109} 110 111/** 112 * Check if we're in production mode (has PUBLIC_URL). 113 */ 114export function isProduction(): boolean { 115 return !!env.PUBLIC_URL; 116}