import { NodeOAuthClient, type OAuthClientMetadataInput, Keyset, JoseKey } from '@atproto/oauth-client-node'; import { StateStore, SessionStore } from './storage'; import type { Database } from '../db'; import { env } from '$env/dynamic/private'; // Environment variables // PUBLIC_URL: e.g., https://papili.one (set in production) // PRIVATE_KEY_ES256: JWK string for signing (required in production) // COOKIE_SECRET: Secret for iron-session cookies // Granular OAuth scopes - only request access to our own collections // See: https://github.com/bluesky-social/atproto/discussions/4118 const OAUTH_SCOPE = 'atproto repo:one.papili.post repo:one.papili.comment'; /** * Creates an OAuth client for the given database connection. * Handles both development (loopback) and production (confidential client) modes. */ export async function createOAuthClient(db: Database): Promise { const PUBLIC_URL = env.PUBLIC_URL; const PRIVATE_KEY_ES256 = env.PRIVATE_KEY_ES256; // Build keyset for confidential client (production only) let keyset: Keyset | undefined; if (PUBLIC_URL && PRIVATE_KEY_ES256) { try { const jwk = JSON.parse(PRIVATE_KEY_ES256); const key = await JoseKey.fromJWK(jwk); keyset = new Keyset([key]); } catch (err) { console.error('Failed to parse PRIVATE_KEY_ES256:', err); throw new Error('Invalid PRIVATE_KEY_ES256. Must be a valid ES256 JWK JSON string.'); } } // For production, we need a keyset if (PUBLIC_URL && !keyset) { throw new Error( 'Production mode requires PRIVATE_KEY_ES256 environment variable. ' + 'Generate one with: npx @atproto/oauth-client-node gen-jwk' ); } const hasSigningKey = keyset && keyset.size > 0; // Build client metadata based on environment const clientMetadata: OAuthClientMetadataInput = PUBLIC_URL ? { // Production: Confidential client client_name: 'papili.one', client_id: `${PUBLIC_URL}/oauth/client-metadata.json`, client_uri: PUBLIC_URL, redirect_uris: [`${PUBLIC_URL}/oauth/callback`], scope: OAUTH_SCOPE, grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], application_type: 'web', token_endpoint_auth_method: hasSigningKey ? 'private_key_jwt' : 'none', token_endpoint_auth_signing_alg: hasSigningKey ? 'ES256' : undefined, dpop_bound_access_tokens: true, jwks_uri: `${PUBLIC_URL}/oauth/jwks.json` } : { // Development: Loopback client (RFC 8252) // ATProto requires 127.0.0.1 for loopback, not localhost client_name: 'papili.one (Development)', client_id: `http://localhost?redirect_uri=${encodeURIComponent( 'http://127.0.0.1:5173/oauth/callback' )}&scope=${encodeURIComponent(OAUTH_SCOPE)}`, redirect_uris: ['http://127.0.0.1:5173/oauth/callback'], scope: OAUTH_SCOPE, grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], application_type: 'web', token_endpoint_auth_method: 'none', dpop_bound_access_tokens: true }; return new NodeOAuthClient({ clientMetadata, keyset, stateStore: new StateStore(db), sessionStore: new SessionStore(db) // Note: D1 doesn't support advisory locks, so we skip requestLock // This means concurrent token refreshes could race, but it's acceptable for MVP }); } /** * Cookie secret for iron-session. */ export function getCookieSecret(): string { const COOKIE_SECRET = env.COOKIE_SECRET; if (!COOKIE_SECRET) { if (env.PUBLIC_URL) { throw new Error('COOKIE_SECRET is required in production'); } // Default for development return 'development-secret-at-least-32-characters-long'; } return COOKIE_SECRET; } /** * Check if we're in production mode (has PUBLIC_URL). */ export function isProduction(): boolean { return !!env.PUBLIC_URL; }