@hatk/hatk 0.0.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/backfill.d.ts +11 -0
  2. package/dist/backfill.d.ts.map +1 -0
  3. package/dist/backfill.js +328 -0
  4. package/dist/car.d.ts +5 -0
  5. package/dist/car.d.ts.map +1 -0
  6. package/dist/car.js +52 -0
  7. package/dist/cbor.d.ts +7 -0
  8. package/dist/cbor.d.ts.map +1 -0
  9. package/dist/cbor.js +89 -0
  10. package/dist/cid.d.ts +4 -0
  11. package/dist/cid.d.ts.map +1 -0
  12. package/dist/cid.js +39 -0
  13. package/dist/cli.d.ts +3 -0
  14. package/dist/cli.d.ts.map +1 -0
  15. package/dist/cli.js +1663 -0
  16. package/dist/config.d.ts +47 -0
  17. package/dist/config.d.ts.map +1 -0
  18. package/dist/config.js +43 -0
  19. package/dist/db.d.ts +134 -0
  20. package/dist/db.d.ts.map +1 -0
  21. package/dist/db.js +1361 -0
  22. package/dist/feeds.d.ts +95 -0
  23. package/dist/feeds.d.ts.map +1 -0
  24. package/dist/feeds.js +144 -0
  25. package/dist/fts.d.ts +20 -0
  26. package/dist/fts.d.ts.map +1 -0
  27. package/dist/fts.js +762 -0
  28. package/dist/hydrate.d.ts +23 -0
  29. package/dist/hydrate.d.ts.map +1 -0
  30. package/dist/hydrate.js +75 -0
  31. package/dist/indexer.d.ts +14 -0
  32. package/dist/indexer.d.ts.map +1 -0
  33. package/dist/indexer.js +316 -0
  34. package/dist/labels.d.ts +29 -0
  35. package/dist/labels.d.ts.map +1 -0
  36. package/dist/labels.js +111 -0
  37. package/dist/lex-types.d.ts +401 -0
  38. package/dist/lex-types.d.ts.map +1 -0
  39. package/dist/lex-types.js +4 -0
  40. package/dist/lexicon-resolve.d.ts +14 -0
  41. package/dist/lexicon-resolve.d.ts.map +1 -0
  42. package/dist/lexicon-resolve.js +280 -0
  43. package/dist/logger.d.ts +4 -0
  44. package/dist/logger.d.ts.map +1 -0
  45. package/dist/logger.js +23 -0
  46. package/dist/main.d.ts +3 -0
  47. package/dist/main.d.ts.map +1 -0
  48. package/dist/main.js +148 -0
  49. package/dist/mst.d.ts +6 -0
  50. package/dist/mst.d.ts.map +1 -0
  51. package/dist/mst.js +30 -0
  52. package/dist/oauth/client.d.ts +16 -0
  53. package/dist/oauth/client.d.ts.map +1 -0
  54. package/dist/oauth/client.js +54 -0
  55. package/dist/oauth/crypto.d.ts +28 -0
  56. package/dist/oauth/crypto.d.ts.map +1 -0
  57. package/dist/oauth/crypto.js +101 -0
  58. package/dist/oauth/db.d.ts +47 -0
  59. package/dist/oauth/db.d.ts.map +1 -0
  60. package/dist/oauth/db.js +139 -0
  61. package/dist/oauth/discovery.d.ts +22 -0
  62. package/dist/oauth/discovery.d.ts.map +1 -0
  63. package/dist/oauth/discovery.js +50 -0
  64. package/dist/oauth/dpop.d.ts +11 -0
  65. package/dist/oauth/dpop.d.ts.map +1 -0
  66. package/dist/oauth/dpop.js +56 -0
  67. package/dist/oauth/hooks.d.ts +10 -0
  68. package/dist/oauth/hooks.d.ts.map +1 -0
  69. package/dist/oauth/hooks.js +40 -0
  70. package/dist/oauth/server.d.ts +86 -0
  71. package/dist/oauth/server.d.ts.map +1 -0
  72. package/dist/oauth/server.js +572 -0
  73. package/dist/opengraph.d.ts +34 -0
  74. package/dist/opengraph.d.ts.map +1 -0
  75. package/dist/opengraph.js +198 -0
  76. package/dist/schema.d.ts +51 -0
  77. package/dist/schema.d.ts.map +1 -0
  78. package/dist/schema.js +358 -0
  79. package/dist/seed.d.ts +29 -0
  80. package/dist/seed.d.ts.map +1 -0
  81. package/dist/seed.js +86 -0
  82. package/dist/server.d.ts +6 -0
  83. package/dist/server.d.ts.map +1 -0
  84. package/dist/server.js +1024 -0
  85. package/dist/setup.d.ts +8 -0
  86. package/dist/setup.d.ts.map +1 -0
  87. package/dist/setup.js +48 -0
  88. package/dist/test-browser.d.ts +14 -0
  89. package/dist/test-browser.d.ts.map +1 -0
  90. package/dist/test-browser.js +26 -0
  91. package/dist/test.d.ts +47 -0
  92. package/dist/test.d.ts.map +1 -0
  93. package/dist/test.js +256 -0
  94. package/dist/views.d.ts +40 -0
  95. package/dist/views.d.ts.map +1 -0
  96. package/dist/views.js +178 -0
  97. package/dist/vite-plugin.d.ts +5 -0
  98. package/dist/vite-plugin.d.ts.map +1 -0
  99. package/dist/vite-plugin.js +86 -0
  100. package/dist/xrpc-client.d.ts +18 -0
  101. package/dist/xrpc-client.d.ts.map +1 -0
  102. package/dist/xrpc-client.js +54 -0
  103. package/dist/xrpc.d.ts +53 -0
  104. package/dist/xrpc.d.ts.map +1 -0
  105. package/dist/xrpc.js +139 -0
  106. package/fonts/Inter-Regular.woff +0 -0
  107. package/package.json +41 -0
  108. package/public/admin-auth.js +320 -0
  109. package/public/admin.html +2166 -0
@@ -0,0 +1,572 @@
1
+ // packages/hatk/src/oauth/server.ts
2
+ import { generateKeyPair, importPrivateKey, computeJwkThumbprint, signJwt, parseJwt, verifyEs256, importPublicKey, randomToken, sha256, base64UrlEncode, } from "./crypto.js";
3
+ import { parseDpopProof, createDpopProof } from "./dpop.js";
4
+ import { resolveClient, validateRedirectUri, isLoopbackClient } from "./client.js";
5
+ import { discoverAuthServer, resolveHandle } from "./discovery.js";
6
+ import { getServerKey, storeServerKey, storeOAuthRequest, getOAuthRequest, deleteOAuthRequest, storeAuthCode, consumeAuthCode, storeSession, checkAndStoreDpopJti, cleanupExpiredOAuth, storeRefreshToken, getRefreshToken, revokeRefreshToken, } from "./db.js";
7
+ import { emit } from "../logger.js";
8
+ import { querySQL } from "../db.js";
9
+ import { fireOnLoginHook } from "./hooks.js";
10
+ const SERVER_KEY_KID = 'appview-oauth-key';
11
+ async function resolveHandleForDid(did) {
12
+ const rows = (await querySQL('SELECT handle FROM _repos WHERE did = $1', [did]));
13
+ return rows[0]?.handle || undefined;
14
+ }
15
+ /** Convert localhost to 127.0.0.1 for RFC 8252 compliance (PDS requirement). */
16
+ function toLoopbackIp(url) {
17
+ return url.replace(/\/\/localhost([:/])/g, '//127.0.0.1$1').replace(/\/\/localhost$/, '//127.0.0.1');
18
+ }
19
+ /** PDS-facing redirect_uri: loopback must use 127.0.0.1 per RFC 8252. */
20
+ function pdsRedirectUri(issuer) {
21
+ if (isLoopbackClient(issuer))
22
+ return `${toLoopbackIp(issuer)}/oauth/callback`;
23
+ return `${issuer}/oauth/callback`;
24
+ }
25
+ /** PDS-facing client_id: loopback encodes redirect_uri+scope per AT Proto spec, production uses metadata URL. */
26
+ function pdsClientId(issuer, config) {
27
+ if (isLoopbackClient(issuer)) {
28
+ const redirectUri = pdsRedirectUri(issuer);
29
+ // Use scope from matching client config (try bare issuer, then metadata URL)
30
+ const client = config?.clients.find((c) => c.client_id === issuer) ||
31
+ config?.clients.find((c) => c.client_id === `${issuer}/oauth-client-metadata.json`);
32
+ const scope = client?.scope || 'atproto transition:generic';
33
+ return `http://localhost/?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`;
34
+ }
35
+ return `${issuer}/oauth-client-metadata.json`;
36
+ }
37
+ let serverPrivateJwk;
38
+ let serverPublicJwk;
39
+ let serverPrivateKey;
40
+ let serverJkt;
41
+ let _plcUrl;
42
+ let _relayUrl;
43
+ export async function initOAuth(_config, plcUrl, relayUrl) {
44
+ _plcUrl = plcUrl;
45
+ _relayUrl = relayUrl;
46
+ // Load or generate server key pair
47
+ const existing = await getServerKey(SERVER_KEY_KID);
48
+ if (existing) {
49
+ serverPrivateJwk = JSON.parse(existing.privateKey);
50
+ serverPublicJwk = JSON.parse(existing.publicKey);
51
+ }
52
+ else {
53
+ const kp = await generateKeyPair();
54
+ serverPrivateJwk = kp.privateJwk;
55
+ serverPublicJwk = kp.publicJwk;
56
+ await storeServerKey(SERVER_KEY_KID, JSON.stringify(serverPrivateJwk), JSON.stringify(serverPublicJwk));
57
+ }
58
+ serverPrivateKey = await importPrivateKey(serverPrivateJwk);
59
+ serverJkt = await computeJwkThumbprint(serverPublicJwk);
60
+ // Periodic cleanup of expired OAuth data
61
+ setInterval(() => cleanupExpiredOAuth().catch(() => { }), 60_000);
62
+ }
63
+ // --- Metadata Endpoints ---
64
+ export function getAuthServerMetadata(issuer, config) {
65
+ return {
66
+ issuer,
67
+ authorization_endpoint: `${issuer}/oauth/authorize`,
68
+ token_endpoint: `${issuer}/oauth/token`,
69
+ revocation_endpoint: `${issuer}/oauth/revoke`,
70
+ pushed_authorization_request_endpoint: `${issuer}/oauth/par`,
71
+ jwks_uri: `${issuer}/oauth/jwks`,
72
+ scopes_supported: config.scopes,
73
+ subject_types_supported: ['public'],
74
+ response_types_supported: ['code'],
75
+ response_modes_supported: ['query', 'fragment'],
76
+ grant_types_supported: ['authorization_code', 'refresh_token'],
77
+ code_challenge_methods_supported: ['S256'],
78
+ token_endpoint_auth_methods_supported: ['none'],
79
+ dpop_signing_alg_values_supported: ['ES256'],
80
+ require_pushed_authorization_requests: false,
81
+ authorization_response_iss_parameter_supported: true,
82
+ client_id_metadata_document_supported: true,
83
+ protected_resources: [issuer],
84
+ };
85
+ }
86
+ export function getProtectedResourceMetadata(issuer, config) {
87
+ return {
88
+ resource: issuer,
89
+ authorization_servers: [issuer],
90
+ bearer_methods_supported: ['header'],
91
+ scopes_supported: config.scopes,
92
+ };
93
+ }
94
+ export function getJwks() {
95
+ return {
96
+ keys: [
97
+ {
98
+ ...serverPublicJwk,
99
+ kid: SERVER_KEY_KID,
100
+ use: 'sig',
101
+ alg: 'ES256',
102
+ },
103
+ ],
104
+ };
105
+ }
106
+ export function getClientMetadata(issuer, config) {
107
+ // Find the metadata client entry to get its scope
108
+ const metadataClientId = `${issuer}/oauth-client-metadata.json`;
109
+ const clientConfig = config.clients.find((c) => c.client_id === metadataClientId);
110
+ return {
111
+ client_id: metadataClientId,
112
+ client_name: clientConfig?.client_name || 'hatk',
113
+ redirect_uris: [`${issuer}/oauth/callback`],
114
+ grant_types: ['authorization_code', 'refresh_token'],
115
+ response_types: ['code'],
116
+ token_endpoint_auth_method: 'none',
117
+ dpop_bound_access_tokens: true,
118
+ scope: clientConfig?.scope || 'atproto transition:generic',
119
+ };
120
+ }
121
+ // --- PAR Endpoint ---
122
+ export async function handlePar(config, body, dpopHeader, requestUrl) {
123
+ // Validate client DPoP proof
124
+ const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
125
+ const fresh = await checkAndStoreDpopJti(dpop.jti, dpop.iat + 300);
126
+ if (!fresh)
127
+ throw new Error('DPoP jti replay detected');
128
+ // Validate client
129
+ const clientId = body.client_id;
130
+ if (!clientId)
131
+ throw new Error('client_id is required');
132
+ const client = resolveClient(clientId, config.clients);
133
+ if (!client)
134
+ throw new Error(`Unknown client: ${clientId}`);
135
+ // Validate redirect_uri
136
+ const redirectUri = body.redirect_uri;
137
+ if (!redirectUri)
138
+ throw new Error('redirect_uri is required');
139
+ if (!validateRedirectUri(client, redirectUri))
140
+ throw new Error('Invalid redirect_uri');
141
+ // Validate PKCE
142
+ if (!body.code_challenge)
143
+ throw new Error('code_challenge is required');
144
+ if (body.code_challenge_method && body.code_challenge_method !== 'S256')
145
+ throw new Error('Only S256 supported');
146
+ // Resolve DID from login_hint
147
+ let did = body.login_hint;
148
+ if (did && !did.startsWith('did:')) {
149
+ did = await resolveHandle(did, _relayUrl);
150
+ }
151
+ // Discover user's PDS auth server
152
+ let pdsRequestUri;
153
+ let pdsAuthServer;
154
+ let pdsCodeVerifier;
155
+ let pdsState;
156
+ if (did) {
157
+ const discovery = await discoverAuthServer(did, _plcUrl);
158
+ pdsAuthServer = discovery.authServerEndpoint;
159
+ // Create PKCE for our PAR to the PDS
160
+ pdsCodeVerifier = randomToken();
161
+ const pdsCodeChallenge = base64UrlEncode(await sha256(pdsCodeVerifier));
162
+ pdsState = randomToken(); // unique state to correlate callback
163
+ // PAR to the PDS
164
+ const parEndpoint = discovery.authServerMetadata.pushed_authorization_request_endpoint || `${pdsAuthServer}/oauth/par`;
165
+ const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint);
166
+ const pdsParBody = new URLSearchParams({
167
+ client_id: pdsClientId(config.issuer, config),
168
+ redirect_uri: pdsRedirectUri(config.issuer),
169
+ response_type: 'code',
170
+ code_challenge: pdsCodeChallenge,
171
+ code_challenge_method: 'S256',
172
+ scope: body.scope || 'atproto transition:generic',
173
+ login_hint: body.login_hint || did,
174
+ state: pdsState,
175
+ });
176
+ const pdsParRes = await fetch(parEndpoint, {
177
+ method: 'POST',
178
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
179
+ body: pdsParBody.toString(),
180
+ });
181
+ if (!pdsParRes.ok) {
182
+ // Handle DPoP nonce retry
183
+ const errBody = await pdsParRes.json().catch(() => ({}));
184
+ if (errBody.error === 'use_dpop_nonce') {
185
+ const nonce = pdsParRes.headers.get('DPoP-Nonce');
186
+ if (nonce) {
187
+ const retryProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint, undefined, nonce);
188
+ const retryRes = await fetch(parEndpoint, {
189
+ method: 'POST',
190
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: retryProof },
191
+ body: pdsParBody.toString(),
192
+ });
193
+ if (!retryRes.ok) {
194
+ const retryErr = await retryRes.json().catch(() => ({}));
195
+ emit('oauth', 'pds_par_error', {
196
+ status: retryRes.status,
197
+ error: retryErr.error,
198
+ error_description: retryErr.error_description,
199
+ retry: true,
200
+ });
201
+ throw new Error(`PDS PAR failed: ${retryRes.status} ${retryErr.error_description || retryErr.error || ''}`);
202
+ }
203
+ const retryData = await retryRes.json();
204
+ pdsRequestUri = retryData.request_uri;
205
+ }
206
+ }
207
+ else {
208
+ emit('oauth', 'pds_par_error', {
209
+ status: pdsParRes.status,
210
+ error: errBody.error,
211
+ error_description: errBody.error_description,
212
+ endpoint: parEndpoint,
213
+ client_id: pdsParBody.get('client_id'),
214
+ redirect_uri: pdsParBody.get('redirect_uri'),
215
+ });
216
+ throw new Error(`PDS PAR failed: ${pdsParRes.status} ${errBody.error_description || errBody.error || ''}`);
217
+ }
218
+ }
219
+ else {
220
+ const pdsParData = await pdsParRes.json();
221
+ pdsRequestUri = pdsParData.request_uri;
222
+ }
223
+ }
224
+ // Store our authorization request
225
+ const requestUri = `urn:ietf:params:oauth:request_uri:${randomToken()}`;
226
+ const expiresAt = Math.floor(Date.now() / 1000) + 600;
227
+ await storeOAuthRequest(requestUri, {
228
+ clientId,
229
+ redirectUri,
230
+ scope: body.scope,
231
+ state: body.state,
232
+ codeChallenge: body.code_challenge,
233
+ codeChallengeMethod: body.code_challenge_method || 'S256',
234
+ dpopJkt: dpop.jkt,
235
+ pdsRequestUri,
236
+ pdsAuthServer,
237
+ pdsCodeVerifier,
238
+ pdsState,
239
+ did,
240
+ loginHint: body.login_hint,
241
+ expiresAt,
242
+ });
243
+ return { request_uri: requestUri, expires_in: 600 };
244
+ }
245
+ // --- Authorize Endpoint ---
246
+ export function buildAuthorizeRedirect(config, request) {
247
+ if (!request.pds_auth_server || !request.pds_request_uri) {
248
+ throw new Error('Authorization request missing PDS data');
249
+ }
250
+ const params = new URLSearchParams({
251
+ request_uri: request.pds_request_uri,
252
+ client_id: pdsClientId(config.issuer, config),
253
+ });
254
+ return `${request.pds_auth_server}/oauth/authorize?${params}`;
255
+ }
256
+ // --- OAuth Callback (PDS redirects here) ---
257
+ export async function handleCallback(config, code, state, iss) {
258
+ // Find the matching OAuth request by pds_state (unique per PAR)
259
+ const { querySQL } = await import("../db.js");
260
+ let request = null;
261
+ if (state) {
262
+ const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_state = $1 AND expires_at > $2`, [
263
+ state,
264
+ Math.floor(Date.now() / 1000),
265
+ ]);
266
+ request = rows.length > 0 ? rows[0] : null;
267
+ }
268
+ // Fallback: match by iss (legacy requests without pds_state)
269
+ if (!request && iss) {
270
+ const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_auth_server = $1 AND expires_at > $2 ORDER BY expires_at DESC`, [iss, Math.floor(Date.now() / 1000)]);
271
+ request = rows.length > 0 ? rows[0] : null;
272
+ }
273
+ if (!request)
274
+ throw new Error('No matching authorization request found');
275
+ // Exchange code at PDS token endpoint
276
+ const tokenEndpoint = `${request.pds_auth_server}/oauth/token`;
277
+ const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
278
+ const tokenBody = new URLSearchParams({
279
+ grant_type: 'authorization_code',
280
+ code,
281
+ redirect_uri: pdsRedirectUri(config.issuer),
282
+ client_id: pdsClientId(config.issuer, config),
283
+ code_verifier: request.pds_code_verifier,
284
+ });
285
+ let tokenRes = await fetch(tokenEndpoint, {
286
+ method: 'POST',
287
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
288
+ body: tokenBody.toString(),
289
+ });
290
+ // Handle DPoP nonce retry
291
+ if (!tokenRes.ok) {
292
+ const errBody = await tokenRes.json().catch(() => ({}));
293
+ if (errBody.error === 'use_dpop_nonce') {
294
+ const nonce = tokenRes.headers.get('DPoP-Nonce');
295
+ if (nonce) {
296
+ const retryProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint, undefined, nonce);
297
+ tokenRes = await fetch(tokenEndpoint, {
298
+ method: 'POST',
299
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: retryProof },
300
+ body: tokenBody.toString(),
301
+ });
302
+ if (!tokenRes.ok) {
303
+ const retryErr = await tokenRes.json().catch(() => ({}));
304
+ emit('oauth', 'pds_token_exchange_error', {
305
+ status: tokenRes.status,
306
+ error: retryErr.error,
307
+ error_description: retryErr.error_description,
308
+ retry: true,
309
+ });
310
+ throw new Error(`PDS token exchange failed: ${tokenRes.status} ${retryErr.error_description || retryErr.error || ''}`);
311
+ }
312
+ }
313
+ else {
314
+ throw new Error(`PDS token exchange failed: DPoP nonce required but not provided`);
315
+ }
316
+ }
317
+ else {
318
+ emit('oauth', 'pds_token_exchange_error', {
319
+ status: tokenRes.status,
320
+ error: errBody.error,
321
+ error_description: errBody.error_description,
322
+ client_id: tokenBody.get('client_id'),
323
+ redirect_uri: tokenBody.get('redirect_uri'),
324
+ });
325
+ throw new Error(`PDS token exchange failed: ${tokenRes.status} ${errBody.error_description || errBody.error || ''}`);
326
+ }
327
+ }
328
+ const tokenData = await tokenRes.json();
329
+ const did = tokenData.sub;
330
+ if (!did)
331
+ throw new Error('PDS token response missing sub (DID)');
332
+ // Store PDS session server-side
333
+ await storeSession(did, {
334
+ pdsEndpoint: request.pds_auth_server.replace('/oauth', ''),
335
+ accessToken: tokenData.access_token,
336
+ refreshToken: tokenData.refresh_token,
337
+ dpopJkt: serverJkt,
338
+ tokenExpiresAt: tokenData.expires_in ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
339
+ });
340
+ await fireOnLoginHook(did);
341
+ // Generate authorization code for the client
342
+ const clientCode = randomToken();
343
+ await storeAuthCode(clientCode, request.request_uri);
344
+ // Update the request with the DID (in case it wasn't set during PAR)
345
+ if (!request.did && did) {
346
+ const { runSQL } = await import("../db.js");
347
+ await runSQL('UPDATE _oauth_requests SET did = $1 WHERE request_uri = $2', did, request.request_uri);
348
+ }
349
+ // Build redirect back to client
350
+ const params = new URLSearchParams({ code: clientCode, iss: config.issuer });
351
+ if (request.state)
352
+ params.set('state', request.state);
353
+ const clientRedirectUri = `${request.redirect_uri}?${params}`;
354
+ return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state };
355
+ }
356
+ // --- Token Endpoint ---
357
+ export async function handleToken(config, body, dpopHeader, requestUrl) {
358
+ const grantType = body.grant_type;
359
+ if (!grantType)
360
+ throw new Error('grant_type is required');
361
+ if (grantType === 'authorization_code') {
362
+ return handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl);
363
+ }
364
+ else if (grantType === 'refresh_token') {
365
+ return handleRefreshTokenGrant(config, body, dpopHeader, requestUrl);
366
+ }
367
+ throw new Error(`Unsupported grant_type: ${grantType}`);
368
+ }
369
+ async function handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl) {
370
+ const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
371
+ const fresh = await checkAndStoreDpopJti(dpop.jti, dpop.iat + 300);
372
+ if (!fresh)
373
+ throw new Error('DPoP jti replay detected');
374
+ const { code, client_id, redirect_uri, code_verifier } = body;
375
+ if (!code || !client_id || !redirect_uri || !code_verifier) {
376
+ throw new Error('Missing required parameters');
377
+ }
378
+ // Consume one-time code
379
+ const requestUri = await consumeAuthCode(code);
380
+ if (!requestUri)
381
+ throw new Error('Invalid or expired authorization code');
382
+ const request = await getOAuthRequest(requestUri);
383
+ if (!request)
384
+ throw new Error('Authorization request not found');
385
+ // Validate
386
+ if (request.client_id !== client_id)
387
+ throw new Error('client_id mismatch');
388
+ if (request.redirect_uri !== redirect_uri)
389
+ throw new Error('redirect_uri mismatch');
390
+ // Verify PKCE
391
+ const challengeHash = base64UrlEncode(await sha256(code_verifier));
392
+ if (challengeHash !== request.code_challenge)
393
+ throw new Error('PKCE verification failed');
394
+ // Verify DPoP key matches PAR
395
+ if (request.dpop_jkt !== dpop.jkt)
396
+ throw new Error('DPoP key mismatch');
397
+ // Find the DID from the PDS session (stored during callback)
398
+ const did = request.did;
399
+ if (!did)
400
+ throw new Error('No DID associated with this request');
401
+ // Issue appview access token
402
+ const tokenId = randomToken();
403
+ const now = Math.floor(Date.now() / 1000);
404
+ const expiresIn = 3600;
405
+ const accessToken = await signJwt({ typ: 'at+jwt', alg: 'ES256', kid: SERVER_KEY_KID }, {
406
+ iss: config.issuer,
407
+ sub: did,
408
+ aud: config.issuer,
409
+ client_id,
410
+ scope: request.scope || 'atproto',
411
+ jti: tokenId,
412
+ iat: now,
413
+ exp: now + expiresIn,
414
+ cnf: { jkt: dpop.jkt },
415
+ }, serverPrivateKey);
416
+ // Issue refresh token with rotation support
417
+ const refreshToken = randomToken();
418
+ await storeRefreshToken(refreshToken, {
419
+ clientId: client_id,
420
+ did,
421
+ dpopJkt: dpop.jkt,
422
+ scope: request.scope || 'atproto',
423
+ });
424
+ // Cleanup
425
+ await deleteOAuthRequest(requestUri);
426
+ const handle = await resolveHandleForDid(did);
427
+ return {
428
+ access_token: accessToken,
429
+ token_type: 'DPoP',
430
+ expires_in: expiresIn,
431
+ refresh_token: refreshToken,
432
+ sub: did,
433
+ handle,
434
+ };
435
+ }
436
+ async function handleRefreshTokenGrant(config, body, dpopHeader, requestUrl) {
437
+ const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
438
+ const fresh = await checkAndStoreDpopJti(dpop.jti, dpop.iat + 300);
439
+ if (!fresh)
440
+ throw new Error('DPoP jti replay detected');
441
+ const { refresh_token, client_id } = body;
442
+ if (!refresh_token || !client_id)
443
+ throw new Error('Missing required parameters');
444
+ // Look up and validate refresh token
445
+ const stored = await getRefreshToken(refresh_token);
446
+ if (!stored)
447
+ throw new Error('Invalid refresh token');
448
+ if (stored.revoked)
449
+ throw new Error('Refresh token revoked');
450
+ if (stored.expires_at && stored.expires_at < Math.floor(Date.now() / 1000))
451
+ throw new Error('Refresh token expired');
452
+ if (stored.client_id !== client_id)
453
+ throw new Error('client_id mismatch');
454
+ // Revoke old refresh token (rotation)
455
+ await revokeRefreshToken(refresh_token);
456
+ const did = stored.did;
457
+ const scope = stored.scope || 'atproto';
458
+ // Issue new access token
459
+ const tokenId = randomToken();
460
+ const now = Math.floor(Date.now() / 1000);
461
+ const expiresIn = 3600;
462
+ const accessToken = await signJwt({ typ: 'at+jwt', alg: 'ES256', kid: SERVER_KEY_KID }, {
463
+ iss: config.issuer,
464
+ sub: did,
465
+ aud: config.issuer,
466
+ client_id,
467
+ scope,
468
+ jti: tokenId,
469
+ iat: now,
470
+ exp: now + expiresIn,
471
+ cnf: { jkt: dpop.jkt },
472
+ }, serverPrivateKey);
473
+ // Issue new refresh token (rotation)
474
+ const newRefreshToken = randomToken();
475
+ await storeRefreshToken(newRefreshToken, {
476
+ clientId: client_id,
477
+ did,
478
+ dpopJkt: dpop.jkt,
479
+ scope,
480
+ });
481
+ const handle = await resolveHandleForDid(did);
482
+ return {
483
+ access_token: accessToken,
484
+ token_type: 'DPoP',
485
+ expires_in: expiresIn,
486
+ refresh_token: newRefreshToken,
487
+ sub: did,
488
+ handle,
489
+ };
490
+ }
491
+ // --- PDS Session Refresh ---
492
+ export async function refreshPdsSession(config, session) {
493
+ if (!session.refresh_token)
494
+ return null;
495
+ const tokenEndpoint = `${session.pds_endpoint}/oauth/token`;
496
+ const clientId = pdsClientId(config.issuer, config);
497
+ const dpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
498
+ const body = new URLSearchParams({
499
+ grant_type: 'refresh_token',
500
+ refresh_token: session.refresh_token,
501
+ client_id: clientId,
502
+ });
503
+ let tokenRes = await fetch(tokenEndpoint, {
504
+ method: 'POST',
505
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: dpopProof },
506
+ body: body.toString(),
507
+ });
508
+ // Handle DPoP nonce retry
509
+ if (!tokenRes.ok) {
510
+ const errBody = await tokenRes.json().catch(() => ({}));
511
+ if (errBody.error === 'use_dpop_nonce') {
512
+ const nonce = tokenRes.headers.get('DPoP-Nonce');
513
+ if (nonce) {
514
+ const retryProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint, undefined, nonce);
515
+ tokenRes = await fetch(tokenEndpoint, {
516
+ method: 'POST',
517
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: retryProof },
518
+ body: body.toString(),
519
+ });
520
+ }
521
+ }
522
+ }
523
+ if (!tokenRes.ok) {
524
+ emit('oauth', 'pds_session_refresh_error', {
525
+ status: tokenRes.status,
526
+ did: session.did,
527
+ pds_endpoint: session.pds_endpoint,
528
+ });
529
+ return null;
530
+ }
531
+ const tokenData = await tokenRes.json();
532
+ // Update stored session
533
+ await storeSession(session.did, {
534
+ pdsEndpoint: session.pds_endpoint,
535
+ accessToken: tokenData.access_token,
536
+ refreshToken: tokenData.refresh_token || session.refresh_token,
537
+ dpopJkt: session.dpop_jkt,
538
+ tokenExpiresAt: tokenData.expires_in ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
539
+ });
540
+ return {
541
+ accessToken: tokenData.access_token,
542
+ refreshToken: tokenData.refresh_token,
543
+ expiresAt: tokenData.expires_in ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
544
+ };
545
+ }
546
+ // --- Token Validation (for API calls) ---
547
+ export async function authenticate(authHeader, dpopHeader, method, url) {
548
+ if (!authHeader)
549
+ return null;
550
+ const dpopMatch = authHeader.match(/^DPoP\s+(.+)$/i);
551
+ if (!dpopMatch)
552
+ return null;
553
+ if (!dpopHeader)
554
+ return null;
555
+ const token = dpopMatch[1];
556
+ const { payload, signatureInput, signature } = parseJwt(token);
557
+ // Check expiration
558
+ const now = Math.floor(Date.now() / 1000);
559
+ if (payload.exp && payload.exp < now)
560
+ return null;
561
+ // Verify DPoP proof
562
+ const dpop = await parseDpopProof(dpopHeader, method, url, undefined, token);
563
+ // Verify token's cnf.jkt matches DPoP key
564
+ if (payload.cnf?.jkt && payload.cnf.jkt !== dpop.jkt)
565
+ return null;
566
+ // Verify token signature with our public key
567
+ const publicKey = await importPublicKey(serverPublicJwk);
568
+ const valid = await verifyEs256(publicKey, signature, signatureInput);
569
+ if (!valid)
570
+ return null;
571
+ return { did: payload.sub };
572
+ }
@@ -0,0 +1,34 @@
1
+ import type { XrpcContext } from './xrpc.ts';
2
+ /** Virtual DOM node for satori rendering */
3
+ export interface SatoriNode {
4
+ type: string;
5
+ props: {
6
+ style?: Record<string, any>;
7
+ children?: (SatoriNode | string)[] | string;
8
+ src?: string;
9
+ width?: number;
10
+ height?: number;
11
+ [key: string]: any;
12
+ };
13
+ }
14
+ /** Context passed to opengraph generate() functions */
15
+ export interface OpengraphContext extends XrpcContext {
16
+ fetchImage: (url: string) => Promise<string | null>;
17
+ }
18
+ /** Return type for opengraph generate() functions */
19
+ export interface OpengraphResult {
20
+ element: SatoriNode;
21
+ options?: {
22
+ width?: number;
23
+ height?: number;
24
+ fonts?: any[];
25
+ };
26
+ meta?: {
27
+ title?: string;
28
+ description?: string;
29
+ };
30
+ }
31
+ export declare function initOpengraph(ogDir: string): Promise<void>;
32
+ export declare function handleOpengraphRequest(pathname: string): Promise<Buffer | null>;
33
+ export declare function buildOgMeta(pathname: string, origin: string): string | null;
34
+ //# sourceMappingURL=opengraph.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opengraph.d.ts","sourceRoot":"","sources":["../src/opengraph.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AAE5C,4CAA4C;AAC5C,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,EAAE;QACL,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;QAC3B,QAAQ,CAAC,EAAE,CAAC,UAAU,GAAG,MAAM,CAAC,EAAE,GAAG,MAAM,CAAA;QAC3C,GAAG,CAAC,EAAE,MAAM,CAAA;QACZ,KAAK,CAAC,EAAE,MAAM,CAAA;QACd,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;KACnB,CAAA;CACF;AAED,uDAAuD;AACvD,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;CACpD;AAED,qDAAqD;AACrD,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,UAAU,CAAA;IACnB,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,GAAG,EAAE,CAAA;KAAE,CAAA;IAC5D,IAAI,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;CAChD;AAkCD,wBAAsB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAoGhE;AAED,wBAAsB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA8BrF;AAED,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAyC3E"}