@hatk/hatk 0.0.1-alpha.8 → 0.0.1-alpha.80

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 (175) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +110 -0
  4. package/dist/backfill.d.ts +21 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +102 -37
  7. package/dist/car.d.ts +42 -10
  8. package/dist/car.d.ts.map +1 -1
  9. package/dist/car.js +154 -14
  10. package/dist/cli.js +243 -1043
  11. package/dist/config.d.ts +74 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +43 -9
  14. package/dist/database/adapter-factory.d.ts +6 -0
  15. package/dist/database/adapter-factory.d.ts.map +1 -0
  16. package/dist/database/adapter-factory.js +20 -0
  17. package/dist/database/adapters/duckdb-search.d.ts +12 -0
  18. package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
  19. package/dist/database/adapters/duckdb-search.js +27 -0
  20. package/dist/database/adapters/duckdb.d.ts +25 -0
  21. package/dist/database/adapters/duckdb.d.ts.map +1 -0
  22. package/dist/database/adapters/duckdb.js +161 -0
  23. package/dist/database/adapters/sqlite-search.d.ts +23 -0
  24. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  25. package/dist/database/adapters/sqlite-search.js +74 -0
  26. package/dist/database/adapters/sqlite.d.ts +18 -0
  27. package/dist/database/adapters/sqlite.d.ts.map +1 -0
  28. package/dist/database/adapters/sqlite.js +88 -0
  29. package/dist/{db.d.ts → database/db.d.ts} +57 -6
  30. package/dist/database/db.d.ts.map +1 -0
  31. package/dist/{db.js → database/db.js} +755 -552
  32. package/dist/database/dialect.d.ts +45 -0
  33. package/dist/database/dialect.d.ts.map +1 -0
  34. package/dist/database/dialect.js +72 -0
  35. package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
  36. package/dist/database/fts.d.ts.map +1 -0
  37. package/dist/{fts.js → database/fts.js} +116 -32
  38. package/dist/database/index.d.ts +7 -0
  39. package/dist/database/index.d.ts.map +1 -0
  40. package/dist/database/index.js +6 -0
  41. package/dist/database/ports.d.ts +50 -0
  42. package/dist/database/ports.d.ts.map +1 -0
  43. package/dist/database/ports.js +1 -0
  44. package/dist/{schema.d.ts → database/schema.d.ts} +14 -3
  45. package/dist/database/schema.d.ts.map +1 -0
  46. package/dist/{schema.js → database/schema.js} +81 -41
  47. package/dist/dev-entry.d.ts +8 -0
  48. package/dist/dev-entry.d.ts.map +1 -0
  49. package/dist/dev-entry.js +119 -0
  50. package/dist/feeds.d.ts +12 -8
  51. package/dist/feeds.d.ts.map +1 -1
  52. package/dist/feeds.js +51 -6
  53. package/dist/hooks.d.ts +85 -0
  54. package/dist/hooks.d.ts.map +1 -0
  55. package/dist/hooks.js +163 -0
  56. package/dist/hydrate.d.ts +7 -6
  57. package/dist/hydrate.d.ts.map +1 -1
  58. package/dist/hydrate.js +4 -16
  59. package/dist/indexer.d.ts +109 -3
  60. package/dist/indexer.d.ts.map +1 -1
  61. package/dist/indexer.js +338 -66
  62. package/dist/jetstream.d.ts +118 -0
  63. package/dist/jetstream.d.ts.map +1 -0
  64. package/dist/jetstream.js +225 -0
  65. package/dist/labels.d.ts +36 -0
  66. package/dist/labels.d.ts.map +1 -1
  67. package/dist/labels.js +71 -6
  68. package/dist/lexicon-resolve.d.ts.map +1 -1
  69. package/dist/lexicon-resolve.js +27 -112
  70. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  71. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  72. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  73. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  74. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  75. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  76. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  77. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  78. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  79. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  80. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  81. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  82. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  83. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  84. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  85. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  86. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  87. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  88. package/dist/logger.d.ts +29 -0
  89. package/dist/logger.d.ts.map +1 -1
  90. package/dist/logger.js +29 -0
  91. package/dist/main.js +161 -72
  92. package/dist/mst.d.ts +18 -1
  93. package/dist/mst.d.ts.map +1 -1
  94. package/dist/mst.js +19 -8
  95. package/dist/oauth/db.d.ts +3 -1
  96. package/dist/oauth/db.d.ts.map +1 -1
  97. package/dist/oauth/db.js +56 -19
  98. package/dist/oauth/discovery.d.ts.map +1 -1
  99. package/dist/oauth/discovery.js +13 -1
  100. package/dist/oauth/server.d.ts +92 -1
  101. package/dist/oauth/server.d.ts.map +1 -1
  102. package/dist/oauth/server.js +352 -37
  103. package/dist/oauth/service-describe.d.ts +31 -0
  104. package/dist/oauth/service-describe.d.ts.map +1 -0
  105. package/dist/oauth/service-describe.js +61 -0
  106. package/dist/oauth/session.d.ts +32 -0
  107. package/dist/oauth/session.d.ts.map +1 -0
  108. package/dist/oauth/session.js +131 -0
  109. package/dist/opengraph.d.ts +10 -0
  110. package/dist/opengraph.d.ts.map +1 -1
  111. package/dist/opengraph.js +80 -40
  112. package/dist/pds-proxy.d.ts +82 -0
  113. package/dist/pds-proxy.d.ts.map +1 -0
  114. package/dist/pds-proxy.js +356 -0
  115. package/dist/private-collections.d.ts +8 -0
  116. package/dist/private-collections.d.ts.map +1 -0
  117. package/dist/private-collections.js +22 -0
  118. package/dist/push.d.ts +54 -0
  119. package/dist/push.d.ts.map +1 -0
  120. package/dist/push.js +367 -0
  121. package/dist/renderer.d.ts +27 -0
  122. package/dist/renderer.d.ts.map +1 -0
  123. package/dist/renderer.js +46 -0
  124. package/dist/resolve-hatk.d.ts +6 -0
  125. package/dist/resolve-hatk.d.ts.map +1 -0
  126. package/dist/resolve-hatk.js +20 -0
  127. package/dist/response.d.ts +16 -0
  128. package/dist/response.d.ts.map +1 -0
  129. package/dist/response.js +69 -0
  130. package/dist/scanner.d.ts +21 -0
  131. package/dist/scanner.d.ts.map +1 -0
  132. package/dist/scanner.js +88 -0
  133. package/dist/seed.d.ts +19 -0
  134. package/dist/seed.d.ts.map +1 -1
  135. package/dist/seed.js +43 -4
  136. package/dist/server-init.d.ts +8 -0
  137. package/dist/server-init.d.ts.map +1 -0
  138. package/dist/server-init.js +62 -0
  139. package/dist/server.d.ts +26 -3
  140. package/dist/server.d.ts.map +1 -1
  141. package/dist/server.js +832 -638
  142. package/dist/setup.d.ts +28 -1
  143. package/dist/setup.d.ts.map +1 -1
  144. package/dist/setup.js +50 -3
  145. package/dist/templates/feed.tpl +14 -0
  146. package/dist/templates/hook.tpl +5 -0
  147. package/dist/templates/label.tpl +15 -0
  148. package/dist/templates/og.tpl +17 -0
  149. package/dist/templates/seed.tpl +11 -0
  150. package/dist/templates/setup.tpl +5 -0
  151. package/dist/templates/test-feed.tpl +19 -0
  152. package/dist/templates/test-xrpc.tpl +19 -0
  153. package/dist/templates/xrpc.tpl +41 -0
  154. package/dist/test.d.ts +1 -1
  155. package/dist/test.d.ts.map +1 -1
  156. package/dist/test.js +41 -32
  157. package/dist/views.js +1 -1
  158. package/dist/vite-plugin.d.ts +1 -1
  159. package/dist/vite-plugin.d.ts.map +1 -1
  160. package/dist/vite-plugin.js +254 -66
  161. package/dist/xrpc.d.ts +82 -11
  162. package/dist/xrpc.d.ts.map +1 -1
  163. package/dist/xrpc.js +196 -39
  164. package/package.json +17 -9
  165. package/public/admin-auth.js +271 -306
  166. package/public/admin.html +177 -61
  167. package/dist/db.d.ts.map +0 -1
  168. package/dist/fts.d.ts.map +0 -1
  169. package/dist/oauth/hooks.d.ts +0 -10
  170. package/dist/oauth/hooks.d.ts.map +0 -1
  171. package/dist/oauth/hooks.js +0 -40
  172. package/dist/schema.d.ts.map +0 -1
  173. package/dist/test-browser.d.ts +0 -14
  174. package/dist/test-browser.d.ts.map +0 -1
  175. package/dist/test-browser.js +0 -26
@@ -1,13 +1,37 @@
1
1
  // packages/hatk/src/oauth/server.ts
2
2
  import { generateKeyPair, importPrivateKey, computeJwkThumbprint, signJwt, parseJwt, verifyEs256, importPublicKey, randomToken, sha256, base64UrlEncode, } from "./crypto.js";
3
3
  import { parseDpopProof, createDpopProof } from "./dpop.js";
4
+ import { initSession } from "./session.js";
4
5
  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";
6
+ import { negotiateScope } from "./service-describe.js";
7
+ import { discoverAuthServer, resolveHandle, fetchProtectedResourceMetadata, fetchAuthServerMetadata, } from "./discovery.js";
8
+ import { getServerKey, storeServerKey, storeOAuthRequest, getOAuthRequest, deleteOAuthRequest, storeAuthCode, consumeAuthCode, storeSession, deleteSession, checkAndStoreDpopJti, cleanupExpiredOAuth, storeRefreshToken, getRefreshToken, revokeRefreshToken, } from "./db.js";
7
9
  import { emit } from "../logger.js";
8
- import { querySQL } from "../db.js";
9
- import { fireOnLoginHook } from "./hooks.js";
10
+ import { querySQL } from "../database/db.js";
11
+ import { fireOnLoginHook } from "../hooks.js";
10
12
  const SERVER_KEY_KID = 'appview-oauth-key';
13
+ const CLIENT_KEY_KID = 'pds-client-auth-key';
14
+ const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
15
+ /**
16
+ * RFC 6749 §5.2 OAuth token-endpoint error.
17
+ *
18
+ * Surfaces as HTTP `status` (default 400) with body
19
+ * `{ error: code, error_description: description }` so OAuth clients can
20
+ * distinguish a permanently-rejected refresh token (`invalid_grant`) from
21
+ * a transient server failure and decide whether to log the user out.
22
+ */
23
+ export class OAuthError extends Error {
24
+ code;
25
+ description;
26
+ status;
27
+ constructor(code, description, status = 400) {
28
+ super(description);
29
+ this.code = code;
30
+ this.description = description;
31
+ this.status = status;
32
+ this.name = 'OAuthError';
33
+ }
34
+ }
11
35
  async function resolveHandleForDid(did) {
12
36
  const rows = (await querySQL('SELECT handle FROM _repos WHERE did = $1', [did]));
13
37
  return rows[0]?.handle || undefined;
@@ -38,6 +62,8 @@ let serverPrivateJwk;
38
62
  let serverPublicJwk;
39
63
  let serverPrivateKey;
40
64
  let serverJkt;
65
+ let clientPublicJwk;
66
+ let clientPrivateKey;
41
67
  let _plcUrl;
42
68
  let _relayUrl;
43
69
  export async function initOAuth(_config, plcUrl, relayUrl) {
@@ -57,6 +83,26 @@ export async function initOAuth(_config, plcUrl, relayUrl) {
57
83
  }
58
84
  serverPrivateKey = await importPrivateKey(serverPrivateJwk);
59
85
  serverJkt = await computeJwkThumbprint(serverPublicJwk);
86
+ // Client-authentication key, kept separate from the access-token signing key
87
+ // above. A PDS binds each session to the kid that authenticated it and
88
+ // refuses a later refresh signed by a different one, so this key can't be
89
+ // rotated without signing every user out — keeping it distinct means the
90
+ // access-token key stays rotatable.
91
+ const existingClientKey = await getServerKey(CLIENT_KEY_KID);
92
+ let clientPrivateJwk;
93
+ if (existingClientKey) {
94
+ clientPrivateJwk = JSON.parse(existingClientKey.privateKey);
95
+ clientPublicJwk = JSON.parse(existingClientKey.publicKey);
96
+ }
97
+ else {
98
+ const kp = await generateKeyPair();
99
+ clientPrivateJwk = kp.privateJwk;
100
+ clientPublicJwk = kp.publicJwk;
101
+ await storeServerKey(CLIENT_KEY_KID, JSON.stringify(clientPrivateJwk), JSON.stringify(clientPublicJwk));
102
+ }
103
+ clientPrivateKey = await importPrivateKey(clientPrivateJwk);
104
+ // Initialize SSR session cookie signing
105
+ initSession(serverPrivateJwk, _config.cookieName);
60
106
  // Periodic cleanup of expired OAuth data
61
107
  setInterval(() => cleanupExpiredOAuth().catch(() => { }), 60_000);
62
108
  }
@@ -103,22 +149,96 @@ export function getJwks() {
103
149
  ],
104
150
  };
105
151
  }
152
+ /**
153
+ * Whether we authenticate to PDSes as a confidential client.
154
+ *
155
+ * A confidential client gets a 2-year session and 3-month refresh window
156
+ * instead of 2 weeks, and is exempt from the consent screen atproto forces on
157
+ * every authorization request from a public client. It requires a fetchable
158
+ * `jwks_uri`, so loopback deployments (local dev) stay public.
159
+ */
160
+ export function isConfidentialClient(issuer) {
161
+ return !isLoopbackClient(issuer);
162
+ }
163
+ /** Public half of the client-authentication key, for the PDS to verify assertions. */
164
+ export function getClientJwks() {
165
+ return {
166
+ keys: [
167
+ {
168
+ ...clientPublicJwk,
169
+ kid: CLIENT_KEY_KID,
170
+ use: 'sig',
171
+ alg: 'ES256',
172
+ },
173
+ ],
174
+ };
175
+ }
176
+ /**
177
+ * A JWT proving we are this client, per RFC 7523. The PDS checks `sub` against
178
+ * the client_id it resolved and `aud` against its own issuer, then verifies the
179
+ * signature against the key it fetched from `jwks_uri`.
180
+ */
181
+ async function createClientAssertion(issuer, config, audience) {
182
+ const clientId = pdsClientId(issuer, config);
183
+ const now = Math.floor(Date.now() / 1000);
184
+ return signJwt({ typ: 'JWT', alg: 'ES256', kid: CLIENT_KEY_KID }, {
185
+ iss: clientId,
186
+ sub: clientId,
187
+ aud: audience,
188
+ jti: randomToken(),
189
+ iat: now,
190
+ exp: now + 60,
191
+ }, clientPrivateKey);
192
+ }
193
+ /**
194
+ * Add client authentication to a PAR or token request body. No-op for loopback
195
+ * deployments, which stay public clients.
196
+ */
197
+ async function withClientAuth(params, issuer, config, audience) {
198
+ if (!isConfidentialClient(issuer))
199
+ return params;
200
+ return {
201
+ ...params,
202
+ client_assertion_type: CLIENT_ASSERTION_TYPE,
203
+ client_assertion: await createClientAssertion(issuer, config, audience),
204
+ };
205
+ }
106
206
  export function getClientMetadata(issuer, config) {
107
207
  // Find the metadata client entry to get its scope
108
208
  const metadataClientId = `${issuer}/oauth-client-metadata.json`;
109
209
  const clientConfig = config.clients.find((c) => c.client_id === metadataClientId);
210
+ const confidential = isConfidentialClient(issuer);
110
211
  return {
111
212
  client_id: metadataClientId,
112
213
  client_name: clientConfig?.client_name || 'hatk',
113
214
  redirect_uris: [`${issuer}/oauth/callback`],
114
215
  grant_types: ['authorization_code', 'refresh_token'],
115
216
  response_types: ['code'],
116
- token_endpoint_auth_method: 'none',
217
+ // atproto requires the signing alg alongside private_key_jwt, and requires
218
+ // its absence for a public client.
219
+ ...(confidential
220
+ ? {
221
+ token_endpoint_auth_method: 'private_key_jwt',
222
+ token_endpoint_auth_signing_alg: 'ES256',
223
+ jwks_uri: `${issuer}/oauth/client-jwks.json`,
224
+ }
225
+ : { token_endpoint_auth_method: 'none' }),
117
226
  dpop_bound_access_tokens: true,
118
227
  scope: clientConfig?.scope || 'atproto transition:generic',
119
228
  };
120
229
  }
121
230
  // --- PAR Endpoint ---
231
+ /**
232
+ * Handle a Pushed Authorization Request (PAR).
233
+ *
234
+ * Supports account creation via `prompt=create`. When set, `login_hint`
235
+ * is treated as a PDS hostname (e.g. "selfhosted.social" or "localhost:2583")
236
+ * rather than a handle or DID. The auth server is discovered from the PDS's
237
+ * protected resource metadata, and `prompt=create` is forwarded to the PDS
238
+ * PAR so it shows the signup page.
239
+ *
240
+ * For normal login, `login_hint` is a handle or DID as usual.
241
+ */
122
242
  export async function handlePar(config, body, dpopHeader, requestUrl) {
123
243
  // Validate client DPoP proof
124
244
  const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
@@ -143,36 +263,73 @@ export async function handlePar(config, body, dpopHeader, requestUrl) {
143
263
  throw new Error('code_challenge is required');
144
264
  if (body.code_challenge_method && body.code_challenge_method !== 'S256')
145
265
  throw new Error('Only S256 supported');
146
- // Resolve DID from login_hint
266
+ // Resolve DID and PDS from login_hint
267
+ const prompt = body.prompt;
147
268
  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
269
  let pdsRequestUri;
153
270
  let pdsAuthServer;
154
271
  let pdsCodeVerifier;
155
272
  let pdsState;
156
- if (did) {
273
+ let pdsEndpoint;
274
+ if (prompt === 'create' && body.login_hint) {
275
+ // Account creation: login_hint is a PDS URL, discover auth server from it directly
276
+ let pdsUrl;
277
+ if (body.login_hint.startsWith('http')) {
278
+ pdsUrl = body.login_hint;
279
+ }
280
+ else if (body.login_hint.match(/^localhost[:/]/)) {
281
+ pdsUrl = `http://${body.login_hint}`;
282
+ }
283
+ else {
284
+ pdsUrl = `https://${body.login_hint}`;
285
+ }
286
+ pdsEndpoint = pdsUrl;
287
+ const protectedResource = await fetchProtectedResourceMetadata(pdsUrl);
288
+ pdsAuthServer = protectedResource.authorization_servers[0];
289
+ if (!pdsAuthServer)
290
+ throw new Error(`No auth server for PDS ${pdsUrl}`);
291
+ did = undefined; // no DID yet for account creation
292
+ }
293
+ else if (did && !did.startsWith('did:')) {
294
+ try {
295
+ did = await resolveHandle(did, _relayUrl);
296
+ }
297
+ catch {
298
+ throw new Error('Handle not found');
299
+ }
300
+ }
301
+ // Discover user's PDS auth server (for login flow with a resolved DID)
302
+ if (did && !pdsAuthServer) {
157
303
  const discovery = await discoverAuthServer(did, _plcUrl);
158
304
  pdsAuthServer = discovery.authServerEndpoint;
305
+ pdsEndpoint = discovery.pdsEndpoint;
306
+ }
307
+ if (pdsAuthServer) {
308
+ const authServerMetadata = await fetchAuthServerMetadata(pdsAuthServer);
159
309
  // Create PKCE for our PAR to the PDS
160
310
  pdsCodeVerifier = randomToken();
161
311
  const pdsCodeChallenge = base64UrlEncode(await sha256(pdsCodeVerifier));
162
312
  pdsState = randomToken(); // unique state to correlate callback
163
313
  // PAR to the PDS
164
- const parEndpoint = discovery.authServerMetadata.pushed_authorization_request_endpoint || `${pdsAuthServer}/oauth/par`;
314
+ const parEndpoint = authServerMetadata.pushed_authorization_request_endpoint || `${pdsAuthServer}/oauth/par`;
165
315
  const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint);
166
- const pdsParBody = new URLSearchParams({
316
+ const pdsScope = await negotiateScope(config, body.scope || 'atproto transition:generic', pdsEndpoint, isLoopbackClient(config.issuer));
317
+ const pdsParParams = {
167
318
  client_id: pdsClientId(config.issuer, config),
168
319
  redirect_uri: pdsRedirectUri(config.issuer),
169
320
  response_type: 'code',
170
321
  code_challenge: pdsCodeChallenge,
171
322
  code_challenge_method: 'S256',
172
- scope: body.scope || 'atproto transition:generic',
173
- login_hint: body.login_hint || did,
323
+ scope: pdsScope,
174
324
  state: pdsState,
175
- });
325
+ };
326
+ if (prompt === 'create') {
327
+ pdsParParams.prompt = 'create';
328
+ }
329
+ if (did) {
330
+ pdsParParams.login_hint = body.login_hint || did;
331
+ }
332
+ const pdsParBody = new URLSearchParams(await withClientAuth(pdsParParams, config.issuer, config, authServerMetadata.issuer || pdsAuthServer));
176
333
  const pdsParRes = await fetch(parEndpoint, {
177
334
  method: 'POST',
178
335
  headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
@@ -234,6 +391,7 @@ export async function handlePar(config, body, dpopHeader, requestUrl) {
234
391
  dpopJkt: dpop.jkt,
235
392
  pdsRequestUri,
236
393
  pdsAuthServer,
394
+ pdsEndpoint,
237
395
  pdsCodeVerifier,
238
396
  pdsState,
239
397
  did,
@@ -253,10 +411,130 @@ export function buildAuthorizeRedirect(config, request) {
253
411
  });
254
412
  return `${request.pds_auth_server}/oauth/authorize?${params}`;
255
413
  }
414
+ // --- Server-initiated login (no DPoP required from browser) ---
415
+ /**
416
+ * Initiate a server-side OAuth login or account creation flow.
417
+ *
418
+ * For account creation, pass `{ prompt: 'create', pds: 'selfhosted.social' }`.
419
+ * The `pds` is a bare hostname; the auth server is discovered from its
420
+ * protected resource metadata.
421
+ */
422
+ export async function serverLogin(config, handle, options) {
423
+ let did;
424
+ let pdsAuthServer;
425
+ let pdsEndpoint;
426
+ if (options?.prompt === 'create' && options?.pds) {
427
+ // Account creation: discover auth server from PDS hostname
428
+ const pdsUrl = options.pds.startsWith('http')
429
+ ? options.pds
430
+ : options.pds.match(/^localhost[:/]/)
431
+ ? `http://${options.pds}`
432
+ : `https://${options.pds}`;
433
+ pdsEndpoint = pdsUrl;
434
+ const protectedResource = await fetchProtectedResourceMetadata(pdsUrl);
435
+ pdsAuthServer = protectedResource.authorization_servers[0];
436
+ if (!pdsAuthServer)
437
+ throw new Error(`No auth server for PDS ${pdsUrl}`);
438
+ }
439
+ else {
440
+ // Normal login: resolve handle to DID
441
+ did = handle;
442
+ if (!did.startsWith('did:')) {
443
+ did = await resolveHandle(handle, _relayUrl);
444
+ }
445
+ const discovery = await discoverAuthServer(did, _plcUrl);
446
+ pdsAuthServer = discovery.authServerEndpoint;
447
+ pdsEndpoint = discovery.pdsEndpoint;
448
+ }
449
+ const authServerMetadata = await fetchAuthServerMetadata(pdsAuthServer);
450
+ // Create PKCE for PAR to PDS
451
+ const pdsCodeVerifier = randomToken();
452
+ const pdsCodeChallenge = base64UrlEncode(await sha256(pdsCodeVerifier));
453
+ const pdsState = randomToken();
454
+ // PAR to the PDS
455
+ const parEndpoint = authServerMetadata.pushed_authorization_request_endpoint || `${pdsAuthServer}/oauth/par`;
456
+ const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint);
457
+ const scope = await negotiateScope(config, config.scopes?.join(' ') || 'atproto transition:generic', pdsEndpoint, isLoopbackClient(config.issuer));
458
+ const pdsParParams = {
459
+ client_id: pdsClientId(config.issuer, config),
460
+ redirect_uri: pdsRedirectUri(config.issuer),
461
+ response_type: 'code',
462
+ code_challenge: pdsCodeChallenge,
463
+ code_challenge_method: 'S256',
464
+ scope,
465
+ state: pdsState,
466
+ };
467
+ if (options?.prompt === 'create') {
468
+ pdsParParams.prompt = 'create';
469
+ }
470
+ if (did) {
471
+ pdsParParams.login_hint = handle;
472
+ }
473
+ const pdsParBody = new URLSearchParams(await withClientAuth(pdsParParams, config.issuer, config, authServerMetadata.issuer || pdsAuthServer));
474
+ let pdsRequestUri;
475
+ const pdsParRes = await fetch(parEndpoint, {
476
+ method: 'POST',
477
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
478
+ body: pdsParBody.toString(),
479
+ });
480
+ if (!pdsParRes.ok) {
481
+ const errBody = await pdsParRes.json().catch(() => ({}));
482
+ if (errBody.error === 'use_dpop_nonce') {
483
+ const nonce = pdsParRes.headers.get('DPoP-Nonce');
484
+ if (nonce) {
485
+ const retryProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint, undefined, nonce);
486
+ const retryRes = await fetch(parEndpoint, {
487
+ method: 'POST',
488
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: retryProof },
489
+ body: pdsParBody.toString(),
490
+ });
491
+ if (!retryRes.ok) {
492
+ const retryErr = await retryRes.json().catch(() => ({}));
493
+ throw new Error(`PDS PAR failed: ${retryRes.status} ${retryErr.error_description || retryErr.error || ''}`);
494
+ }
495
+ const retryData = await retryRes.json();
496
+ pdsRequestUri = retryData.request_uri;
497
+ }
498
+ }
499
+ else {
500
+ throw new Error(`PDS PAR failed: ${pdsParRes.status} ${errBody.error_description || errBody.error || ''}`);
501
+ }
502
+ }
503
+ else {
504
+ const pdsParData = await pdsParRes.json();
505
+ pdsRequestUri = pdsParData.request_uri;
506
+ }
507
+ // Store the request so the callback can find it
508
+ const requestUri = `urn:ietf:params:oauth:request_uri:${randomToken()}`;
509
+ const expiresAt = Math.floor(Date.now() / 1000) + 600;
510
+ await storeOAuthRequest(requestUri, {
511
+ clientId: pdsClientId(config.issuer, config),
512
+ redirectUri: '/',
513
+ scope,
514
+ state: pdsState,
515
+ codeChallenge: '',
516
+ codeChallengeMethod: 'S256',
517
+ dpopJkt: serverJkt,
518
+ pdsRequestUri,
519
+ pdsAuthServer,
520
+ pdsEndpoint,
521
+ pdsCodeVerifier,
522
+ pdsState,
523
+ did,
524
+ loginHint: handle,
525
+ expiresAt,
526
+ });
527
+ // Build redirect URL to PDS
528
+ const params = new URLSearchParams({
529
+ request_uri: pdsRequestUri,
530
+ client_id: pdsClientId(config.issuer, config),
531
+ });
532
+ return `${pdsAuthServer}/oauth/authorize?${params}`;
533
+ }
256
534
  // --- OAuth Callback (PDS redirects here) ---
257
535
  export async function handleCallback(config, code, state, iss) {
258
536
  // Find the matching OAuth request by pds_state (unique per PAR)
259
- const { querySQL } = await import("../db.js");
537
+ const { querySQL } = await import("../database/db.js");
260
538
  let request = null;
261
539
  if (state) {
262
540
  const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_state = $1 AND expires_at > $2`, [
@@ -275,13 +553,13 @@ export async function handleCallback(config, code, state, iss) {
275
553
  // Exchange code at PDS token endpoint
276
554
  const tokenEndpoint = `${request.pds_auth_server}/oauth/token`;
277
555
  const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
278
- const tokenBody = new URLSearchParams({
556
+ const tokenBody = new URLSearchParams(await withClientAuth({
279
557
  grant_type: 'authorization_code',
280
558
  code,
281
559
  redirect_uri: pdsRedirectUri(config.issuer),
282
560
  client_id: pdsClientId(config.issuer, config),
283
561
  code_verifier: request.pds_code_verifier,
284
- });
562
+ }, config.issuer, config, request.pds_auth_server));
285
563
  let tokenRes = await fetch(tokenEndpoint, {
286
564
  method: 'POST',
287
565
  headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
@@ -329,42 +607,75 @@ export async function handleCallback(config, code, state, iss) {
329
607
  const did = tokenData.sub;
330
608
  if (!did)
331
609
  throw new Error('PDS token response missing sub (DID)');
332
- // Store PDS session server-side
610
+ // Store PDS session server-side — pds_endpoint is the actual data PDS
611
+ // (e.g. leccinum.us-west.host.bsky.network), pds_auth_server is the OAuth server (bsky.social)
333
612
  await storeSession(did, {
334
- pdsEndpoint: request.pds_auth_server.replace('/oauth', ''),
613
+ pdsEndpoint: request.pds_endpoint,
614
+ pdsAuthServer: request.pds_auth_server,
335
615
  accessToken: tokenData.access_token,
336
616
  refreshToken: tokenData.refresh_token,
337
617
  dpopJkt: serverJkt,
338
618
  tokenExpiresAt: tokenData.expires_in ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
339
619
  });
340
- await fireOnLoginHook(did);
620
+ await fireOnLoginHook(did, config);
341
621
  // Generate authorization code for the client
342
622
  const clientCode = randomToken();
343
623
  await storeAuthCode(clientCode, request.request_uri);
344
624
  // Update the request with the DID (in case it wasn't set during PAR)
345
625
  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);
626
+ const { runSQL } = await import("../database/db.js");
627
+ await runSQL('UPDATE _oauth_requests SET did = $1 WHERE request_uri = $2', [did, request.request_uri]);
348
628
  }
349
629
  // Build redirect back to client
350
630
  const params = new URLSearchParams({ code: clientCode, iss: config.issuer });
351
631
  if (request.state)
352
632
  params.set('state', request.state);
353
633
  const clientRedirectUri = `${request.redirect_uri}?${params}`;
354
- return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state };
634
+ return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state, did };
635
+ }
636
+ /**
637
+ * Handle an error redirect from the PDS (user denied, request expired, etc).
638
+ *
639
+ * The PDS reports failures by redirecting to our callback with `error` and no
640
+ * `code`. Discard the pending request and return a URL that hands the error
641
+ * back to the app, so the browser lands somewhere renderable instead of on a
642
+ * raw error response.
643
+ */
644
+ export async function handleCallbackError(config, error, errorDescription, state) {
645
+ let request = null;
646
+ if (state) {
647
+ const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_state = $1`, [state]);
648
+ request = rows.length > 0 ? rows[0] : null;
649
+ }
650
+ if (request)
651
+ await deleteOAuthRequest(request.request_uri);
652
+ emit('oauth', 'pds_callback_error', { error, error_description: errorDescription, matched: !!request });
653
+ const params = new URLSearchParams({ error });
654
+ if (errorDescription)
655
+ params.set('error_description', errorDescription);
656
+ // Server-initiated logins store redirect_uri as '/' — send the browser home
657
+ // with the error attached. Same for an unmatched state (expired or replayed).
658
+ if (!request || request.redirect_uri === '/')
659
+ return `/?${params}`;
660
+ // Client flow: echo the client's state and our issuer so the SPA's callback
661
+ // handler recognises the redirect as its own rather than re-entering here.
662
+ if (request.state)
663
+ params.set('state', request.state);
664
+ params.set('iss', config.issuer);
665
+ return `${request.redirect_uri}?${params}`;
355
666
  }
356
667
  // --- Token Endpoint ---
357
668
  export async function handleToken(config, body, dpopHeader, requestUrl) {
358
669
  const grantType = body.grant_type;
359
670
  if (!grantType)
360
- throw new Error('grant_type is required');
671
+ throw new OAuthError('invalid_request', 'grant_type is required');
361
672
  if (grantType === 'authorization_code') {
362
673
  return handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl);
363
674
  }
364
675
  else if (grantType === 'refresh_token') {
365
676
  return handleRefreshTokenGrant(config, body, dpopHeader, requestUrl);
366
677
  }
367
- throw new Error(`Unsupported grant_type: ${grantType}`);
678
+ throw new OAuthError('unsupported_grant_type', `Unsupported grant_type: ${grantType}`);
368
679
  }
369
680
  async function handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl) {
370
681
  const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
@@ -437,20 +748,21 @@ async function handleRefreshTokenGrant(config, body, dpopHeader, requestUrl) {
437
748
  const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
438
749
  const fresh = await checkAndStoreDpopJti(dpop.jti, dpop.iat + 300);
439
750
  if (!fresh)
440
- throw new Error('DPoP jti replay detected');
751
+ throw new OAuthError('invalid_request', 'DPoP jti replay detected');
441
752
  const { refresh_token, client_id } = body;
442
753
  if (!refresh_token || !client_id)
443
- throw new Error('Missing required parameters');
444
- // Look up and validate refresh token
754
+ throw new OAuthError('invalid_request', 'Missing required parameters');
755
+ // Look up and validate refresh token. Per RFC 6749 §5.2, all of these are
756
+ // `invalid_grant` — the client must treat them as terminal and re-authenticate.
445
757
  const stored = await getRefreshToken(refresh_token);
446
758
  if (!stored)
447
- throw new Error('Invalid refresh token');
759
+ throw new OAuthError('invalid_grant', 'Invalid refresh token');
448
760
  if (stored.revoked)
449
- throw new Error('Refresh token revoked');
761
+ throw new OAuthError('invalid_grant', 'Refresh token revoked');
450
762
  if (stored.expires_at && stored.expires_at < Math.floor(Date.now() / 1000))
451
- throw new Error('Refresh token expired');
763
+ throw new OAuthError('invalid_grant', 'Refresh token expired');
452
764
  if (stored.client_id !== client_id)
453
- throw new Error('client_id mismatch');
765
+ throw new OAuthError('invalid_grant', 'client_id mismatch');
454
766
  // Revoke old refresh token (rotation)
455
767
  await revokeRefreshToken(refresh_token);
456
768
  const did = stored.did;
@@ -492,14 +804,15 @@ async function handleRefreshTokenGrant(config, body, dpopHeader, requestUrl) {
492
804
  export async function refreshPdsSession(config, session) {
493
805
  if (!session.refresh_token)
494
806
  return null;
495
- const tokenEndpoint = `${session.pds_endpoint}/oauth/token`;
807
+ // Use auth server for token endpoint (falls back to pds_endpoint for sessions created before this fix)
808
+ const tokenEndpoint = `${session.pds_auth_server || session.pds_endpoint}/oauth/token`;
496
809
  const clientId = pdsClientId(config.issuer, config);
497
810
  const dpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
498
- const body = new URLSearchParams({
811
+ const body = new URLSearchParams(await withClientAuth({
499
812
  grant_type: 'refresh_token',
500
813
  refresh_token: session.refresh_token,
501
814
  client_id: clientId,
502
- });
815
+ }, config.issuer, config, session.pds_auth_server || session.pds_endpoint));
503
816
  let tokenRes = await fetch(tokenEndpoint, {
504
817
  method: 'POST',
505
818
  headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: dpopProof },
@@ -526,12 +839,14 @@ export async function refreshPdsSession(config, session) {
526
839
  did: session.did,
527
840
  pds_endpoint: session.pds_endpoint,
528
841
  });
842
+ await deleteSession(session.did);
529
843
  return null;
530
844
  }
531
845
  const tokenData = await tokenRes.json();
532
846
  // Update stored session
533
847
  await storeSession(session.did, {
534
848
  pdsEndpoint: session.pds_endpoint,
849
+ pdsAuthServer: session.pds_auth_server,
535
850
  accessToken: tokenData.access_token,
536
851
  refreshToken: tokenData.refresh_token || session.refresh_token,
537
852
  dpopJkt: session.dpop_jkt,
@@ -0,0 +1,31 @@
1
+ /**
2
+ * What a PDS says it serves, and the scopes worth asking it for.
3
+ *
4
+ * `community.lexicon.service.describe` answers with a service's roles and every
5
+ * XRPC method it routes. It is the only way to ask: atproto publishes no method
6
+ * list, so the alternative is to call a method and read the failure, where a
7
+ * server without an optional feature and one that is broken look alike.
8
+ *
9
+ * Used before pushing an authorization request, so a PDS is asked for a
10
+ * feature's scopes only when it implements the feature.
11
+ */
12
+ import type { OAuthConfig } from '../config.ts';
13
+ /**
14
+ * Every XRPC method a PDS serves. An empty set for a server that answers no
15
+ * describe — which is every PDS that predates the method, so absence has to
16
+ * read as "no optional features" rather than as an error.
17
+ */
18
+ export declare function describeMethods(pdsEndpoint: string): Promise<Set<string>>;
19
+ /**
20
+ * The scope string to request from this PDS: `base`, plus any conditional set
21
+ * whose `whenMethod` the server serves.
22
+ *
23
+ * Loopback clients are left alone. Their client_id encodes the scope they may
24
+ * request, and the token exchange rebuilds that client_id from config — vary
25
+ * the request and the two disagree. A local setup that needs the extra scopes
26
+ * lists them in its client's own `scope` instead.
27
+ */
28
+ export declare function negotiateScope(config: OAuthConfig, base: string, pdsEndpoint: string | undefined, isLoopback: boolean): Promise<string>;
29
+ /** Drop cached describe answers. For tests. */
30
+ export declare function clearDescribeCache(): void;
31
+ //# sourceMappingURL=service-describe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-describe.d.ts","sourceRoot":"","sources":["../../src/oauth/service-describe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAU/C;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAwB/E;AAED;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,WAAW,EACnB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,GAAG,SAAS,EAC/B,UAAU,EAAE,OAAO,GAClB,OAAO,CAAC,MAAM,CAAC,CAUjB;AAED,+CAA+C;AAC/C,wBAAgB,kBAAkB,IAAI,IAAI,CAEzC"}
@@ -0,0 +1,61 @@
1
+ import { emit } from "../logger.js";
2
+ const DESCRIBE_NSID = 'community.lexicon.service.describe';
3
+ const PROBE_TIMEOUT_MS = 5000;
4
+ const CACHE_TTL_MS = 60 * 60 * 1000;
5
+ /** Answers are a property of the server, so one entry covers every account on it. */
6
+ const cache = new Map();
7
+ /**
8
+ * Every XRPC method a PDS serves. An empty set for a server that answers no
9
+ * describe — which is every PDS that predates the method, so absence has to
10
+ * read as "no optional features" rather than as an error.
11
+ */
12
+ export async function describeMethods(pdsEndpoint) {
13
+ const cached = cache.get(pdsEndpoint);
14
+ if (cached && cached.expires > Date.now())
15
+ return cached.methods;
16
+ let methods = new Set();
17
+ try {
18
+ const res = await fetch(`${pdsEndpoint.replace(/\/$/, '')}/xrpc/${DESCRIBE_NSID}`, {
19
+ headers: { accept: 'application/json' },
20
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
21
+ });
22
+ if (res.ok) {
23
+ const body = (await res.json().catch(() => null));
24
+ if (Array.isArray(body?.methods)) {
25
+ for (const m of body.methods) {
26
+ if (typeof m?.value === 'string')
27
+ methods.add(m.value);
28
+ }
29
+ }
30
+ }
31
+ }
32
+ catch {
33
+ methods = new Set();
34
+ }
35
+ cache.set(pdsEndpoint, { methods, expires: Date.now() + CACHE_TTL_MS });
36
+ return methods;
37
+ }
38
+ /**
39
+ * The scope string to request from this PDS: `base`, plus any conditional set
40
+ * whose `whenMethod` the server serves.
41
+ *
42
+ * Loopback clients are left alone. Their client_id encodes the scope they may
43
+ * request, and the token exchange rebuilds that client_id from config — vary
44
+ * the request and the two disagree. A local setup that needs the extra scopes
45
+ * lists them in its client's own `scope` instead.
46
+ */
47
+ export async function negotiateScope(config, base, pdsEndpoint, isLoopback) {
48
+ const conditional = config.conditionalScopes ?? [];
49
+ if (conditional.length === 0 || !pdsEndpoint || isLoopback)
50
+ return base;
51
+ const methods = await describeMethods(pdsEndpoint);
52
+ const granted = conditional.filter((c) => methods.has(c.whenMethod)).flatMap((c) => c.scopes);
53
+ if (granted.length === 0)
54
+ return base;
55
+ emit('oauth', 'conditional_scopes', { pds: pdsEndpoint, scopes: granted });
56
+ return [base, ...granted].join(' ');
57
+ }
58
+ /** Drop cached describe answers. For tests. */
59
+ export function clearDescribeCache() {
60
+ cache.clear();
61
+ }