an atproto based link aggregator
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}