@hatk/hatk 0.0.1-alpha.9 → 0.0.1-alpha.92

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 (218) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +113 -0
  4. package/dist/backfill.d.ts +29 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +142 -51
  7. package/dist/car.d.ts +20 -0
  8. package/dist/car.d.ts.map +1 -1
  9. package/dist/car.js +108 -0
  10. package/dist/cli.js +247 -1043
  11. package/dist/config.d.ts +145 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +61 -8
  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 +16 -0
  24. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  25. package/dist/database/adapters/sqlite-search.js +76 -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} +68 -6
  30. package/dist/database/db.d.ts.map +1 -0
  31. package/dist/database/db.js +1775 -0
  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} +122 -34
  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} +16 -3
  45. package/dist/database/schema.d.ts.map +1 -0
  46. package/dist/{schema.js → database/schema.js} +126 -44
  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 +127 -0
  50. package/dist/feeds.d.ts +26 -8
  51. package/dist/feeds.d.ts.map +1 -1
  52. package/dist/feeds.js +72 -16
  53. package/dist/hooks.d.ts +102 -0
  54. package/dist/hooks.d.ts.map +1 -0
  55. package/dist/hooks.js +186 -0
  56. package/dist/hydrate.d.ts +49 -6
  57. package/dist/hydrate.d.ts.map +1 -1
  58. package/dist/hydrate.js +21 -20
  59. package/dist/indexer.d.ts +167 -3
  60. package/dist/indexer.d.ts.map +1 -1
  61. package/dist/indexer.js +556 -94
  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/lex-types.d.ts +2 -0
  69. package/dist/lex-types.d.ts.map +1 -1
  70. package/dist/lexicon-resolve.d.ts.map +1 -1
  71. package/dist/lexicon-resolve.js +27 -112
  72. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  73. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  74. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  75. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  76. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  77. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  78. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  79. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  80. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  81. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  82. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  83. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  84. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  85. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  86. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  87. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  88. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  89. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  90. package/dist/logger.d.ts +29 -0
  91. package/dist/logger.d.ts.map +1 -1
  92. package/dist/logger.js +29 -0
  93. package/dist/main.js +198 -75
  94. package/dist/mst.d.ts +15 -0
  95. package/dist/mst.d.ts.map +1 -1
  96. package/dist/mst.js +13 -0
  97. package/dist/oauth/db.d.ts +16 -1
  98. package/dist/oauth/db.d.ts.map +1 -1
  99. package/dist/oauth/db.js +74 -19
  100. package/dist/oauth/discovery.d.ts +10 -0
  101. package/dist/oauth/discovery.d.ts.map +1 -1
  102. package/dist/oauth/discovery.js +54 -2
  103. package/dist/oauth/server.d.ts +93 -1
  104. package/dist/oauth/server.d.ts.map +1 -1
  105. package/dist/oauth/server.js +416 -39
  106. package/dist/oauth/service-describe.d.ts +42 -0
  107. package/dist/oauth/service-describe.d.ts.map +1 -0
  108. package/dist/oauth/service-describe.js +108 -0
  109. package/dist/oauth/session.d.ts +32 -0
  110. package/dist/oauth/session.d.ts.map +1 -0
  111. package/dist/oauth/session.js +131 -0
  112. package/dist/opengraph.d.ts +10 -0
  113. package/dist/opengraph.d.ts.map +1 -1
  114. package/dist/opengraph.js +94 -42
  115. package/dist/pds-proxy.d.ts +82 -0
  116. package/dist/pds-proxy.d.ts.map +1 -0
  117. package/dist/pds-proxy.js +356 -0
  118. package/dist/private-collections.d.ts +13 -0
  119. package/dist/private-collections.d.ts.map +1 -0
  120. package/dist/private-collections.js +27 -0
  121. package/dist/push.d.ts +54 -0
  122. package/dist/push.d.ts.map +1 -0
  123. package/dist/push.js +367 -0
  124. package/dist/renderer.d.ts +27 -0
  125. package/dist/renderer.d.ts.map +1 -0
  126. package/dist/renderer.js +46 -0
  127. package/dist/resolve-hatk.d.ts +6 -0
  128. package/dist/resolve-hatk.d.ts.map +1 -0
  129. package/dist/resolve-hatk.js +20 -0
  130. package/dist/response.d.ts +16 -0
  131. package/dist/response.d.ts.map +1 -0
  132. package/dist/response.js +69 -0
  133. package/dist/scanner.d.ts +21 -0
  134. package/dist/scanner.d.ts.map +1 -0
  135. package/dist/scanner.js +88 -0
  136. package/dist/seed.d.ts +19 -0
  137. package/dist/seed.d.ts.map +1 -1
  138. package/dist/seed.js +43 -4
  139. package/dist/server-init.d.ts +8 -0
  140. package/dist/server-init.d.ts.map +1 -0
  141. package/dist/server-init.js +62 -0
  142. package/dist/server.d.ts +34 -3
  143. package/dist/server.d.ts.map +1 -1
  144. package/dist/server.js +1032 -649
  145. package/dist/setup.d.ts +28 -1
  146. package/dist/setup.d.ts.map +1 -1
  147. package/dist/setup.js +50 -3
  148. package/dist/spaces/blob.d.ts +51 -0
  149. package/dist/spaces/blob.d.ts.map +1 -0
  150. package/dist/spaces/blob.js +111 -0
  151. package/dist/spaces/credential.d.ts +62 -0
  152. package/dist/spaces/credential.d.ts.map +1 -0
  153. package/dist/spaces/credential.js +184 -0
  154. package/dist/spaces/engine.d.ts +97 -0
  155. package/dist/spaces/engine.d.ts.map +1 -0
  156. package/dist/spaces/engine.js +559 -0
  157. package/dist/spaces/guard.d.ts +41 -0
  158. package/dist/spaces/guard.d.ts.map +1 -0
  159. package/dist/spaces/guard.js +79 -0
  160. package/dist/spaces/identity.d.ts +37 -0
  161. package/dist/spaces/identity.d.ts.map +1 -0
  162. package/dist/spaces/identity.js +88 -0
  163. package/dist/spaces/index.d.ts +55 -0
  164. package/dist/spaces/index.d.ts.map +1 -0
  165. package/dist/spaces/index.js +133 -0
  166. package/dist/spaces/notify.d.ts +51 -0
  167. package/dist/spaces/notify.d.ts.map +1 -0
  168. package/dist/spaces/notify.js +121 -0
  169. package/dist/spaces/records.d.ts +37 -0
  170. package/dist/spaces/records.d.ts.map +1 -0
  171. package/dist/spaces/records.js +63 -0
  172. package/dist/spaces/store.d.ts +51 -0
  173. package/dist/spaces/store.d.ts.map +1 -0
  174. package/dist/spaces/store.js +99 -0
  175. package/dist/spaces/uri.d.ts +60 -0
  176. package/dist/spaces/uri.d.ts.map +1 -0
  177. package/dist/spaces/uri.js +97 -0
  178. package/dist/spaces/verify.d.ts +39 -0
  179. package/dist/spaces/verify.d.ts.map +1 -0
  180. package/dist/spaces/verify.js +87 -0
  181. package/dist/spaces/viewer.d.ts +51 -0
  182. package/dist/spaces/viewer.d.ts.map +1 -0
  183. package/dist/spaces/viewer.js +167 -0
  184. package/dist/spaces/visibility.d.ts +57 -0
  185. package/dist/spaces/visibility.d.ts.map +1 -0
  186. package/dist/spaces/visibility.js +81 -0
  187. package/dist/templates/feed.tpl +18 -0
  188. package/dist/templates/hook.tpl +5 -0
  189. package/dist/templates/label.tpl +15 -0
  190. package/dist/templates/og.tpl +17 -0
  191. package/dist/templates/seed.tpl +11 -0
  192. package/dist/templates/setup.tpl +5 -0
  193. package/dist/templates/test-feed.tpl +19 -0
  194. package/dist/templates/test-xrpc.tpl +19 -0
  195. package/dist/templates/xrpc.tpl +53 -0
  196. package/dist/test.d.ts +3 -1
  197. package/dist/test.d.ts.map +1 -1
  198. package/dist/test.js +60 -36
  199. package/dist/views.js +1 -1
  200. package/dist/vite-plugin.d.ts +1 -1
  201. package/dist/vite-plugin.d.ts.map +1 -1
  202. package/dist/vite-plugin.js +254 -66
  203. package/dist/xrpc.d.ts +101 -12
  204. package/dist/xrpc.d.ts.map +1 -1
  205. package/dist/xrpc.js +255 -55
  206. package/package.json +21 -9
  207. package/public/admin-auth.js +271 -306
  208. package/public/admin.html +177 -61
  209. package/dist/db.d.ts.map +0 -1
  210. package/dist/db.js +0 -1361
  211. package/dist/fts.d.ts.map +0 -1
  212. package/dist/oauth/hooks.d.ts +0 -10
  213. package/dist/oauth/hooks.d.ts.map +0 -1
  214. package/dist/oauth/hooks.js +0 -40
  215. package/dist/schema.d.ts.map +0 -1
  216. package/dist/test-browser.d.ts +0 -14
  217. package/dist/test-browser.d.ts.map +0 -1
  218. 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;
@@ -34,10 +58,58 @@ function pdsClientId(issuer, config) {
34
58
  }
35
59
  return `${issuer}/oauth-client-metadata.json`;
36
60
  }
61
+ /**
62
+ * The endpoints a remote auth server advertises, taken from its metadata rather
63
+ * than rebuilt from its issuer.
64
+ *
65
+ * RFC 8414 requires `authorization_endpoint` and `token_endpoint` and the
66
+ * atproto OAuth profile requires PAR on top of that, so an auth server is free
67
+ * to serve any of them from a path of its choosing. Concatenating `/oauth/...`
68
+ * onto the issuer only ever worked because bsky.social and the reference PDS
69
+ * happen to use those paths.
70
+ *
71
+ * PAR keeps its legacy fallback: it was already read from metadata, and the
72
+ * fallback is what deployments without it have been relying on.
73
+ */
74
+ function authServerEndpoints(metadata, authServer) {
75
+ if (!metadata.authorization_endpoint) {
76
+ throw new Error(`Auth server ${authServer} advertises no authorization_endpoint`);
77
+ }
78
+ if (!metadata.token_endpoint) {
79
+ throw new Error(`Auth server ${authServer} advertises no token_endpoint`);
80
+ }
81
+ return {
82
+ authorizationEndpoint: metadata.authorization_endpoint,
83
+ tokenEndpoint: metadata.token_endpoint,
84
+ parEndpoint: metadata.pushed_authorization_request_endpoint || `${authServer}/oauth/par`,
85
+ };
86
+ }
87
+ /**
88
+ * Endpoint stored on a request or session row, falling back to the path this
89
+ * used to hardcode. The fallback is for rows written before those columns
90
+ * existed, not for fresh discovery — a login started after the upgrade always
91
+ * has the advertised endpoint.
92
+ */
93
+ function storedEndpoint(stored, authServer, legacyPath) {
94
+ return stored || `${authServer}${legacyPath}`;
95
+ }
96
+ /**
97
+ * Append query parameters to an endpoint URL. RFC 6749 §3.1 lets an
98
+ * authorization endpoint carry its own query component, which a bare
99
+ * `${endpoint}?${params}` would corrupt.
100
+ */
101
+ function withQuery(endpoint, params) {
102
+ const url = new URL(endpoint);
103
+ for (const [key, value] of params)
104
+ url.searchParams.set(key, value);
105
+ return url.toString();
106
+ }
37
107
  let serverPrivateJwk;
38
108
  let serverPublicJwk;
39
109
  let serverPrivateKey;
40
110
  let serverJkt;
111
+ let clientPublicJwk;
112
+ let clientPrivateKey;
41
113
  let _plcUrl;
42
114
  let _relayUrl;
43
115
  export async function initOAuth(_config, plcUrl, relayUrl) {
@@ -57,6 +129,26 @@ export async function initOAuth(_config, plcUrl, relayUrl) {
57
129
  }
58
130
  serverPrivateKey = await importPrivateKey(serverPrivateJwk);
59
131
  serverJkt = await computeJwkThumbprint(serverPublicJwk);
132
+ // Client-authentication key, kept separate from the access-token signing key
133
+ // above. A PDS binds each session to the kid that authenticated it and
134
+ // refuses a later refresh signed by a different one, so this key can't be
135
+ // rotated without signing every user out — keeping it distinct means the
136
+ // access-token key stays rotatable.
137
+ const existingClientKey = await getServerKey(CLIENT_KEY_KID);
138
+ let clientPrivateJwk;
139
+ if (existingClientKey) {
140
+ clientPrivateJwk = JSON.parse(existingClientKey.privateKey);
141
+ clientPublicJwk = JSON.parse(existingClientKey.publicKey);
142
+ }
143
+ else {
144
+ const kp = await generateKeyPair();
145
+ clientPrivateJwk = kp.privateJwk;
146
+ clientPublicJwk = kp.publicJwk;
147
+ await storeServerKey(CLIENT_KEY_KID, JSON.stringify(clientPrivateJwk), JSON.stringify(clientPublicJwk));
148
+ }
149
+ clientPrivateKey = await importPrivateKey(clientPrivateJwk);
150
+ // Initialize SSR session cookie signing
151
+ initSession(serverPrivateJwk, _config.cookieName);
60
152
  // Periodic cleanup of expired OAuth data
61
153
  setInterval(() => cleanupExpiredOAuth().catch(() => { }), 60_000);
62
154
  }
@@ -103,22 +195,96 @@ export function getJwks() {
103
195
  ],
104
196
  };
105
197
  }
198
+ /**
199
+ * Whether we authenticate to PDSes as a confidential client.
200
+ *
201
+ * A confidential client gets a 2-year session and 3-month refresh window
202
+ * instead of 2 weeks, and is exempt from the consent screen atproto forces on
203
+ * every authorization request from a public client. It requires a fetchable
204
+ * `jwks_uri`, so loopback deployments (local dev) stay public.
205
+ */
206
+ export function isConfidentialClient(issuer) {
207
+ return !isLoopbackClient(issuer);
208
+ }
209
+ /** Public half of the client-authentication key, for the PDS to verify assertions. */
210
+ export function getClientJwks() {
211
+ return {
212
+ keys: [
213
+ {
214
+ ...clientPublicJwk,
215
+ kid: CLIENT_KEY_KID,
216
+ use: 'sig',
217
+ alg: 'ES256',
218
+ },
219
+ ],
220
+ };
221
+ }
222
+ /**
223
+ * A JWT proving we are this client, per RFC 7523. The PDS checks `sub` against
224
+ * the client_id it resolved and `aud` against its own issuer, then verifies the
225
+ * signature against the key it fetched from `jwks_uri`.
226
+ */
227
+ async function createClientAssertion(issuer, config, audience) {
228
+ const clientId = pdsClientId(issuer, config);
229
+ const now = Math.floor(Date.now() / 1000);
230
+ return signJwt({ typ: 'JWT', alg: 'ES256', kid: CLIENT_KEY_KID }, {
231
+ iss: clientId,
232
+ sub: clientId,
233
+ aud: audience,
234
+ jti: randomToken(),
235
+ iat: now,
236
+ exp: now + 60,
237
+ }, clientPrivateKey);
238
+ }
239
+ /**
240
+ * Add client authentication to a PAR or token request body. No-op for loopback
241
+ * deployments, which stay public clients.
242
+ */
243
+ async function withClientAuth(params, issuer, config, audience) {
244
+ if (!isConfidentialClient(issuer))
245
+ return params;
246
+ return {
247
+ ...params,
248
+ client_assertion_type: CLIENT_ASSERTION_TYPE,
249
+ client_assertion: await createClientAssertion(issuer, config, audience),
250
+ };
251
+ }
106
252
  export function getClientMetadata(issuer, config) {
107
253
  // Find the metadata client entry to get its scope
108
254
  const metadataClientId = `${issuer}/oauth-client-metadata.json`;
109
255
  const clientConfig = config.clients.find((c) => c.client_id === metadataClientId);
256
+ const confidential = isConfidentialClient(issuer);
110
257
  return {
111
258
  client_id: metadataClientId,
112
259
  client_name: clientConfig?.client_name || 'hatk',
113
260
  redirect_uris: [`${issuer}/oauth/callback`],
114
261
  grant_types: ['authorization_code', 'refresh_token'],
115
262
  response_types: ['code'],
116
- token_endpoint_auth_method: 'none',
263
+ // atproto requires the signing alg alongside private_key_jwt, and requires
264
+ // its absence for a public client.
265
+ ...(confidential
266
+ ? {
267
+ token_endpoint_auth_method: 'private_key_jwt',
268
+ token_endpoint_auth_signing_alg: 'ES256',
269
+ jwks_uri: `${issuer}/oauth/client-jwks.json`,
270
+ }
271
+ : { token_endpoint_auth_method: 'none' }),
117
272
  dpop_bound_access_tokens: true,
118
273
  scope: clientConfig?.scope || 'atproto transition:generic',
119
274
  };
120
275
  }
121
276
  // --- PAR Endpoint ---
277
+ /**
278
+ * Handle a Pushed Authorization Request (PAR).
279
+ *
280
+ * Supports account creation via `prompt=create`. When set, `login_hint`
281
+ * is treated as a PDS hostname (e.g. "selfhosted.social" or "localhost:2583")
282
+ * rather than a handle or DID. The auth server is discovered from the PDS's
283
+ * protected resource metadata, and `prompt=create` is forwarded to the PDS
284
+ * PAR so it shows the signup page.
285
+ *
286
+ * For normal login, `login_hint` is a handle or DID as usual.
287
+ */
122
288
  export async function handlePar(config, body, dpopHeader, requestUrl) {
123
289
  // Validate client DPoP proof
124
290
  const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
@@ -143,36 +309,80 @@ export async function handlePar(config, body, dpopHeader, requestUrl) {
143
309
  throw new Error('code_challenge is required');
144
310
  if (body.code_challenge_method && body.code_challenge_method !== 'S256')
145
311
  throw new Error('Only S256 supported');
146
- // Resolve DID from login_hint
312
+ // Resolve DID and PDS from login_hint
313
+ const prompt = body.prompt;
147
314
  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
315
  let pdsRequestUri;
153
316
  let pdsAuthServer;
317
+ let pdsAuthorizationEndpoint;
318
+ let pdsTokenEndpoint;
154
319
  let pdsCodeVerifier;
155
320
  let pdsState;
156
- if (did) {
321
+ let pdsEndpoint;
322
+ if (prompt === 'create' && body.login_hint) {
323
+ // Account creation: login_hint is a PDS URL, discover auth server from it directly
324
+ let pdsUrl;
325
+ if (body.login_hint.startsWith('http')) {
326
+ pdsUrl = body.login_hint;
327
+ }
328
+ else if (body.login_hint.match(/^localhost[:/]/)) {
329
+ pdsUrl = `http://${body.login_hint}`;
330
+ }
331
+ else {
332
+ pdsUrl = `https://${body.login_hint}`;
333
+ }
334
+ pdsEndpoint = pdsUrl;
335
+ const protectedResource = await fetchProtectedResourceMetadata(pdsUrl);
336
+ pdsAuthServer = protectedResource.authorization_servers[0];
337
+ if (!pdsAuthServer)
338
+ throw new Error(`No auth server for PDS ${pdsUrl}`);
339
+ did = undefined; // no DID yet for account creation
340
+ }
341
+ else if (did && !did.startsWith('did:')) {
342
+ try {
343
+ did = await resolveHandle(did, _relayUrl);
344
+ }
345
+ catch {
346
+ throw new Error('Handle not found');
347
+ }
348
+ }
349
+ // Discover user's PDS auth server (for login flow with a resolved DID)
350
+ if (did && !pdsAuthServer) {
157
351
  const discovery = await discoverAuthServer(did, _plcUrl);
158
352
  pdsAuthServer = discovery.authServerEndpoint;
353
+ pdsEndpoint = discovery.pdsEndpoint;
354
+ }
355
+ if (pdsAuthServer) {
356
+ const authServerMetadata = await fetchAuthServerMetadata(pdsAuthServer);
357
+ const endpoints = authServerEndpoints(authServerMetadata, pdsAuthServer);
358
+ // Held for the authorize redirect and the token exchange, which run on
359
+ // later requests with only this row to go on.
360
+ pdsAuthorizationEndpoint = endpoints.authorizationEndpoint;
361
+ pdsTokenEndpoint = endpoints.tokenEndpoint;
159
362
  // Create PKCE for our PAR to the PDS
160
363
  pdsCodeVerifier = randomToken();
161
364
  const pdsCodeChallenge = base64UrlEncode(await sha256(pdsCodeVerifier));
162
365
  pdsState = randomToken(); // unique state to correlate callback
163
366
  // PAR to the PDS
164
- const parEndpoint = discovery.authServerMetadata.pushed_authorization_request_endpoint || `${pdsAuthServer}/oauth/par`;
367
+ const parEndpoint = endpoints.parEndpoint;
165
368
  const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint);
166
- const pdsParBody = new URLSearchParams({
369
+ const pdsScope = await negotiateScope(config, body.scope || 'atproto transition:generic', pdsEndpoint, isLoopbackClient(config.issuer));
370
+ const pdsParParams = {
167
371
  client_id: pdsClientId(config.issuer, config),
168
372
  redirect_uri: pdsRedirectUri(config.issuer),
169
373
  response_type: 'code',
170
374
  code_challenge: pdsCodeChallenge,
171
375
  code_challenge_method: 'S256',
172
- scope: body.scope || 'atproto transition:generic',
173
- login_hint: body.login_hint || did,
376
+ scope: pdsScope,
174
377
  state: pdsState,
175
- });
378
+ };
379
+ if (prompt === 'create') {
380
+ pdsParParams.prompt = 'create';
381
+ }
382
+ if (did) {
383
+ pdsParParams.login_hint = body.login_hint || did;
384
+ }
385
+ const pdsParBody = new URLSearchParams(await withClientAuth(pdsParParams, config.issuer, config, authServerMetadata.issuer || pdsAuthServer));
176
386
  const pdsParRes = await fetch(parEndpoint, {
177
387
  method: 'POST',
178
388
  headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
@@ -234,6 +444,9 @@ export async function handlePar(config, body, dpopHeader, requestUrl) {
234
444
  dpopJkt: dpop.jkt,
235
445
  pdsRequestUri,
236
446
  pdsAuthServer,
447
+ pdsAuthorizationEndpoint,
448
+ pdsTokenEndpoint,
449
+ pdsEndpoint,
237
450
  pdsCodeVerifier,
238
451
  pdsState,
239
452
  did,
@@ -251,12 +464,135 @@ export function buildAuthorizeRedirect(config, request) {
251
464
  request_uri: request.pds_request_uri,
252
465
  client_id: pdsClientId(config.issuer, config),
253
466
  });
254
- return `${request.pds_auth_server}/oauth/authorize?${params}`;
467
+ return withQuery(storedEndpoint(request.pds_authorization_endpoint, request.pds_auth_server, '/oauth/authorize'), params);
468
+ }
469
+ // --- Server-initiated login (no DPoP required from browser) ---
470
+ /**
471
+ * Initiate a server-side OAuth login or account creation flow.
472
+ *
473
+ * For account creation, pass `{ prompt: 'create', pds: 'selfhosted.social' }`.
474
+ * The `pds` is a bare hostname; the auth server is discovered from its
475
+ * protected resource metadata.
476
+ */
477
+ export async function serverLogin(config, handle, options) {
478
+ let did;
479
+ let pdsAuthServer;
480
+ let pdsEndpoint;
481
+ if (options?.prompt === 'create' && options?.pds) {
482
+ // Account creation: discover auth server from PDS hostname
483
+ const pdsUrl = options.pds.startsWith('http')
484
+ ? options.pds
485
+ : options.pds.match(/^localhost[:/]/)
486
+ ? `http://${options.pds}`
487
+ : `https://${options.pds}`;
488
+ pdsEndpoint = pdsUrl;
489
+ const protectedResource = await fetchProtectedResourceMetadata(pdsUrl);
490
+ pdsAuthServer = protectedResource.authorization_servers[0];
491
+ if (!pdsAuthServer)
492
+ throw new Error(`No auth server for PDS ${pdsUrl}`);
493
+ }
494
+ else {
495
+ // Normal login: resolve handle to DID
496
+ did = handle;
497
+ if (!did.startsWith('did:')) {
498
+ did = await resolveHandle(handle, _relayUrl);
499
+ }
500
+ const discovery = await discoverAuthServer(did, _plcUrl);
501
+ pdsAuthServer = discovery.authServerEndpoint;
502
+ pdsEndpoint = discovery.pdsEndpoint;
503
+ }
504
+ const authServerMetadata = await fetchAuthServerMetadata(pdsAuthServer);
505
+ const endpoints = authServerEndpoints(authServerMetadata, pdsAuthServer);
506
+ // Create PKCE for PAR to PDS
507
+ const pdsCodeVerifier = randomToken();
508
+ const pdsCodeChallenge = base64UrlEncode(await sha256(pdsCodeVerifier));
509
+ const pdsState = randomToken();
510
+ // PAR to the PDS
511
+ const parEndpoint = endpoints.parEndpoint;
512
+ const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint);
513
+ const scope = await negotiateScope(config, config.scopes?.join(' ') || 'atproto transition:generic', pdsEndpoint, isLoopbackClient(config.issuer));
514
+ const pdsParParams = {
515
+ client_id: pdsClientId(config.issuer, config),
516
+ redirect_uri: pdsRedirectUri(config.issuer),
517
+ response_type: 'code',
518
+ code_challenge: pdsCodeChallenge,
519
+ code_challenge_method: 'S256',
520
+ scope,
521
+ state: pdsState,
522
+ };
523
+ if (options?.prompt === 'create') {
524
+ pdsParParams.prompt = 'create';
525
+ }
526
+ if (did) {
527
+ pdsParParams.login_hint = handle;
528
+ }
529
+ const pdsParBody = new URLSearchParams(await withClientAuth(pdsParParams, config.issuer, config, authServerMetadata.issuer || pdsAuthServer));
530
+ let pdsRequestUri;
531
+ const pdsParRes = await fetch(parEndpoint, {
532
+ method: 'POST',
533
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
534
+ body: pdsParBody.toString(),
535
+ });
536
+ if (!pdsParRes.ok) {
537
+ const errBody = await pdsParRes.json().catch(() => ({}));
538
+ if (errBody.error === 'use_dpop_nonce') {
539
+ const nonce = pdsParRes.headers.get('DPoP-Nonce');
540
+ if (nonce) {
541
+ const retryProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint, undefined, nonce);
542
+ const retryRes = await fetch(parEndpoint, {
543
+ method: 'POST',
544
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: retryProof },
545
+ body: pdsParBody.toString(),
546
+ });
547
+ if (!retryRes.ok) {
548
+ const retryErr = await retryRes.json().catch(() => ({}));
549
+ throw new Error(`PDS PAR failed: ${retryRes.status} ${retryErr.error_description || retryErr.error || ''}`);
550
+ }
551
+ const retryData = await retryRes.json();
552
+ pdsRequestUri = retryData.request_uri;
553
+ }
554
+ }
555
+ else {
556
+ throw new Error(`PDS PAR failed: ${pdsParRes.status} ${errBody.error_description || errBody.error || ''}`);
557
+ }
558
+ }
559
+ else {
560
+ const pdsParData = await pdsParRes.json();
561
+ pdsRequestUri = pdsParData.request_uri;
562
+ }
563
+ // Store the request so the callback can find it
564
+ const requestUri = `urn:ietf:params:oauth:request_uri:${randomToken()}`;
565
+ const expiresAt = Math.floor(Date.now() / 1000) + 600;
566
+ await storeOAuthRequest(requestUri, {
567
+ clientId: pdsClientId(config.issuer, config),
568
+ redirectUri: '/',
569
+ scope,
570
+ state: pdsState,
571
+ codeChallenge: '',
572
+ codeChallengeMethod: 'S256',
573
+ dpopJkt: serverJkt,
574
+ pdsRequestUri,
575
+ pdsAuthServer,
576
+ pdsAuthorizationEndpoint: endpoints.authorizationEndpoint,
577
+ pdsTokenEndpoint: endpoints.tokenEndpoint,
578
+ pdsEndpoint,
579
+ pdsCodeVerifier,
580
+ pdsState,
581
+ did,
582
+ loginHint: handle,
583
+ expiresAt,
584
+ });
585
+ // Build redirect URL to PDS
586
+ const params = new URLSearchParams({
587
+ request_uri: pdsRequestUri,
588
+ client_id: pdsClientId(config.issuer, config),
589
+ });
590
+ return withQuery(endpoints.authorizationEndpoint, params);
255
591
  }
256
592
  // --- OAuth Callback (PDS redirects here) ---
257
593
  export async function handleCallback(config, code, state, iss) {
258
594
  // Find the matching OAuth request by pds_state (unique per PAR)
259
- const { querySQL } = await import("../db.js");
595
+ const { querySQL } = await import("../database/db.js");
260
596
  let request = null;
261
597
  if (state) {
262
598
  const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_state = $1 AND expires_at > $2`, [
@@ -273,15 +609,15 @@ export async function handleCallback(config, code, state, iss) {
273
609
  if (!request)
274
610
  throw new Error('No matching authorization request found');
275
611
  // Exchange code at PDS token endpoint
276
- const tokenEndpoint = `${request.pds_auth_server}/oauth/token`;
612
+ const tokenEndpoint = storedEndpoint(request.pds_token_endpoint, request.pds_auth_server, '/oauth/token');
277
613
  const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
278
- const tokenBody = new URLSearchParams({
614
+ const tokenBody = new URLSearchParams(await withClientAuth({
279
615
  grant_type: 'authorization_code',
280
616
  code,
281
617
  redirect_uri: pdsRedirectUri(config.issuer),
282
618
  client_id: pdsClientId(config.issuer, config),
283
619
  code_verifier: request.pds_code_verifier,
284
- });
620
+ }, config.issuer, config, request.pds_auth_server));
285
621
  let tokenRes = await fetch(tokenEndpoint, {
286
622
  method: 'POST',
287
623
  headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
@@ -329,42 +665,76 @@ export async function handleCallback(config, code, state, iss) {
329
665
  const did = tokenData.sub;
330
666
  if (!did)
331
667
  throw new Error('PDS token response missing sub (DID)');
332
- // Store PDS session server-side
668
+ // Store PDS session server-side — pds_endpoint is the actual data PDS
669
+ // (e.g. leccinum.us-west.host.bsky.network), pds_auth_server is the OAuth server (bsky.social)
333
670
  await storeSession(did, {
334
- pdsEndpoint: request.pds_auth_server.replace('/oauth', ''),
671
+ pdsEndpoint: request.pds_endpoint,
672
+ pdsAuthServer: request.pds_auth_server,
673
+ pdsTokenEndpoint: tokenEndpoint,
335
674
  accessToken: tokenData.access_token,
336
675
  refreshToken: tokenData.refresh_token,
337
676
  dpopJkt: serverJkt,
338
677
  tokenExpiresAt: tokenData.expires_in ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
339
678
  });
340
- await fireOnLoginHook(did);
679
+ await fireOnLoginHook(did, config);
341
680
  // Generate authorization code for the client
342
681
  const clientCode = randomToken();
343
682
  await storeAuthCode(clientCode, request.request_uri);
344
683
  // Update the request with the DID (in case it wasn't set during PAR)
345
684
  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);
685
+ const { runSQL } = await import("../database/db.js");
686
+ await runSQL('UPDATE _oauth_requests SET did = $1 WHERE request_uri = $2', [did, request.request_uri]);
348
687
  }
349
688
  // Build redirect back to client
350
689
  const params = new URLSearchParams({ code: clientCode, iss: config.issuer });
351
690
  if (request.state)
352
691
  params.set('state', request.state);
353
692
  const clientRedirectUri = `${request.redirect_uri}?${params}`;
354
- return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state };
693
+ return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state, did };
694
+ }
695
+ /**
696
+ * Handle an error redirect from the PDS (user denied, request expired, etc).
697
+ *
698
+ * The PDS reports failures by redirecting to our callback with `error` and no
699
+ * `code`. Discard the pending request and return a URL that hands the error
700
+ * back to the app, so the browser lands somewhere renderable instead of on a
701
+ * raw error response.
702
+ */
703
+ export async function handleCallbackError(config, error, errorDescription, state) {
704
+ let request = null;
705
+ if (state) {
706
+ const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_state = $1`, [state]);
707
+ request = rows.length > 0 ? rows[0] : null;
708
+ }
709
+ if (request)
710
+ await deleteOAuthRequest(request.request_uri);
711
+ emit('oauth', 'pds_callback_error', { error, error_description: errorDescription, matched: !!request });
712
+ const params = new URLSearchParams({ error });
713
+ if (errorDescription)
714
+ params.set('error_description', errorDescription);
715
+ // Server-initiated logins store redirect_uri as '/' — send the browser home
716
+ // with the error attached. Same for an unmatched state (expired or replayed).
717
+ if (!request || request.redirect_uri === '/')
718
+ return `/?${params}`;
719
+ // Client flow: echo the client's state and our issuer so the SPA's callback
720
+ // handler recognises the redirect as its own rather than re-entering here.
721
+ if (request.state)
722
+ params.set('state', request.state);
723
+ params.set('iss', config.issuer);
724
+ return `${request.redirect_uri}?${params}`;
355
725
  }
356
726
  // --- Token Endpoint ---
357
727
  export async function handleToken(config, body, dpopHeader, requestUrl) {
358
728
  const grantType = body.grant_type;
359
729
  if (!grantType)
360
- throw new Error('grant_type is required');
730
+ throw new OAuthError('invalid_request', 'grant_type is required');
361
731
  if (grantType === 'authorization_code') {
362
732
  return handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl);
363
733
  }
364
734
  else if (grantType === 'refresh_token') {
365
735
  return handleRefreshTokenGrant(config, body, dpopHeader, requestUrl);
366
736
  }
367
- throw new Error(`Unsupported grant_type: ${grantType}`);
737
+ throw new OAuthError('unsupported_grant_type', `Unsupported grant_type: ${grantType}`);
368
738
  }
369
739
  async function handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl) {
370
740
  const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
@@ -437,20 +807,21 @@ async function handleRefreshTokenGrant(config, body, dpopHeader, requestUrl) {
437
807
  const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
438
808
  const fresh = await checkAndStoreDpopJti(dpop.jti, dpop.iat + 300);
439
809
  if (!fresh)
440
- throw new Error('DPoP jti replay detected');
810
+ throw new OAuthError('invalid_request', 'DPoP jti replay detected');
441
811
  const { refresh_token, client_id } = body;
442
812
  if (!refresh_token || !client_id)
443
- throw new Error('Missing required parameters');
444
- // Look up and validate refresh token
813
+ throw new OAuthError('invalid_request', 'Missing required parameters');
814
+ // Look up and validate refresh token. Per RFC 6749 §5.2, all of these are
815
+ // `invalid_grant` — the client must treat them as terminal and re-authenticate.
445
816
  const stored = await getRefreshToken(refresh_token);
446
817
  if (!stored)
447
- throw new Error('Invalid refresh token');
818
+ throw new OAuthError('invalid_grant', 'Invalid refresh token');
448
819
  if (stored.revoked)
449
- throw new Error('Refresh token revoked');
820
+ throw new OAuthError('invalid_grant', 'Refresh token revoked');
450
821
  if (stored.expires_at && stored.expires_at < Math.floor(Date.now() / 1000))
451
- throw new Error('Refresh token expired');
822
+ throw new OAuthError('invalid_grant', 'Refresh token expired');
452
823
  if (stored.client_id !== client_id)
453
- throw new Error('client_id mismatch');
824
+ throw new OAuthError('invalid_grant', 'client_id mismatch');
454
825
  // Revoke old refresh token (rotation)
455
826
  await revokeRefreshToken(refresh_token);
456
827
  const did = stored.did;
@@ -492,14 +863,17 @@ async function handleRefreshTokenGrant(config, body, dpopHeader, requestUrl) {
492
863
  export async function refreshPdsSession(config, session) {
493
864
  if (!session.refresh_token)
494
865
  return null;
495
- const tokenEndpoint = `${session.pds_endpoint}/oauth/token`;
866
+ // Prefer the endpoint the auth server advertised when this session was
867
+ // authorized. Sessions predating that column fall back to the auth server
868
+ // (or, older still, the PDS) with the path this used to hardcode.
869
+ const tokenEndpoint = storedEndpoint(session.pds_token_endpoint, session.pds_auth_server || session.pds_endpoint, '/oauth/token');
496
870
  const clientId = pdsClientId(config.issuer, config);
497
871
  const dpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
498
- const body = new URLSearchParams({
872
+ const body = new URLSearchParams(await withClientAuth({
499
873
  grant_type: 'refresh_token',
500
874
  refresh_token: session.refresh_token,
501
875
  client_id: clientId,
502
- });
876
+ }, config.issuer, config, session.pds_auth_server || session.pds_endpoint));
503
877
  let tokenRes = await fetch(tokenEndpoint, {
504
878
  method: 'POST',
505
879
  headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: dpopProof },
@@ -526,12 +900,15 @@ export async function refreshPdsSession(config, session) {
526
900
  did: session.did,
527
901
  pds_endpoint: session.pds_endpoint,
528
902
  });
903
+ await deleteSession(session.did);
529
904
  return null;
530
905
  }
531
906
  const tokenData = await tokenRes.json();
532
907
  // Update stored session
533
908
  await storeSession(session.did, {
534
909
  pdsEndpoint: session.pds_endpoint,
910
+ pdsAuthServer: session.pds_auth_server,
911
+ pdsTokenEndpoint: session.pds_token_endpoint,
535
912
  accessToken: tokenData.access_token,
536
913
  refreshToken: tokenData.refresh_token || session.refresh_token,
537
914
  dpopJkt: session.dpop_jkt,
@@ -0,0 +1,42 @@
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 direct question and the answer to trust when
6
+ * it comes — but it is still a proposal, and implementations exist that serve
7
+ * the features while serving no describe. The reference spaces build of
8
+ * `@atproto/pds` is one: it routes every space method and does not contain the
9
+ * describe NSID at all. Reading its silence as "no optional features" denies it
10
+ * scopes it could honor.
11
+ *
12
+ * So when describe says nothing, the methods a caller actually cares about are
13
+ * probed directly. Calling one and reading the failure cannot tell a missing
14
+ * feature from a broken server *on its own* — but it can against a control: ask
15
+ * for an NSID that certainly does not exist, learn how this server says "no such
16
+ * method", and a different answer for a real method is evidence it has one.
17
+ *
18
+ * Used before pushing an authorization request, so a PDS is asked for a
19
+ * feature's scopes only when it implements the feature.
20
+ */
21
+ import type { OAuthConfig } from '../config.ts';
22
+ /**
23
+ * Every XRPC method a PDS serves, as far as it can be established.
24
+ *
25
+ * `candidates` are the methods the caller needs an answer about. They are only
26
+ * used when describe says nothing — an explicit list is always preferred to
27
+ * inference, and a server that answers describe is taken at its word.
28
+ */
29
+ export declare function describeMethods(pdsEndpoint: string, candidates?: string[]): Promise<Set<string>>;
30
+ /**
31
+ * The scope string to request from this PDS: `base`, plus any conditional set
32
+ * whose `whenMethod` the server serves.
33
+ *
34
+ * Loopback clients are left alone. Their client_id encodes the scope they may
35
+ * request, and the token exchange rebuilds that client_id from config — vary
36
+ * the request and the two disagree. A local setup that needs the extra scopes
37
+ * lists them in its client's own `scope` instead.
38
+ */
39
+ export declare function negotiateScope(config: OAuthConfig, base: string, pdsEndpoint: string | undefined, isLoopback: boolean): Promise<string>;
40
+ /** Drop cached describe answers. For tests. */
41
+ export declare function clearDescribeCache(): void;
42
+ //# sourceMappingURL=service-describe.d.ts.map