@hatk/hatk 0.0.1-alpha.1 → 0.0.1-alpha.100

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 (226) 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 +90 -1
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +254 -55
  7. package/dist/car.d.ts +59 -1
  8. package/dist/car.d.ts.map +1 -1
  9. package/dist/car.js +179 -7
  10. package/dist/cbor.d.ts +37 -0
  11. package/dist/cbor.d.ts.map +1 -1
  12. package/dist/cbor.js +36 -3
  13. package/dist/cid.d.ts +37 -0
  14. package/dist/cid.d.ts.map +1 -1
  15. package/dist/cid.js +38 -3
  16. package/dist/cli.js +247 -986
  17. package/dist/config.d.ts +145 -1
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +62 -9
  20. package/dist/database/adapter-factory.d.ts +6 -0
  21. package/dist/database/adapter-factory.d.ts.map +1 -0
  22. package/dist/database/adapter-factory.js +20 -0
  23. package/dist/database/adapters/duckdb-search.d.ts +12 -0
  24. package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
  25. package/dist/database/adapters/duckdb-search.js +27 -0
  26. package/dist/database/adapters/duckdb.d.ts +25 -0
  27. package/dist/database/adapters/duckdb.d.ts.map +1 -0
  28. package/dist/database/adapters/duckdb.js +161 -0
  29. package/dist/database/adapters/sqlite-search.d.ts +16 -0
  30. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  31. package/dist/database/adapters/sqlite-search.js +76 -0
  32. package/dist/database/adapters/sqlite.d.ts +18 -0
  33. package/dist/database/adapters/sqlite.d.ts.map +1 -0
  34. package/dist/database/adapters/sqlite.js +88 -0
  35. package/dist/{db.d.ts → database/db.d.ts} +94 -6
  36. package/dist/database/db.d.ts.map +1 -0
  37. package/dist/database/db.js +1833 -0
  38. package/dist/database/dialect.d.ts +45 -0
  39. package/dist/database/dialect.d.ts.map +1 -0
  40. package/dist/database/dialect.js +72 -0
  41. package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
  42. package/dist/database/fts.d.ts.map +1 -0
  43. package/dist/{fts.js → database/fts.js} +122 -34
  44. package/dist/database/index.d.ts +7 -0
  45. package/dist/database/index.d.ts.map +1 -0
  46. package/dist/database/index.js +6 -0
  47. package/dist/database/ports.d.ts +50 -0
  48. package/dist/database/ports.d.ts.map +1 -0
  49. package/dist/database/ports.js +1 -0
  50. package/dist/database/schema.d.ts +91 -0
  51. package/dist/database/schema.d.ts.map +1 -0
  52. package/dist/{schema.js → database/schema.js} +153 -54
  53. package/dist/dev-entry.d.ts +8 -0
  54. package/dist/dev-entry.d.ts.map +1 -0
  55. package/dist/dev-entry.js +126 -0
  56. package/dist/feeds.d.ts +26 -8
  57. package/dist/feeds.d.ts.map +1 -1
  58. package/dist/feeds.js +72 -16
  59. package/dist/hooks.d.ts +102 -0
  60. package/dist/hooks.d.ts.map +1 -0
  61. package/dist/hooks.js +186 -0
  62. package/dist/hydrate.d.ts +55 -6
  63. package/dist/hydrate.d.ts.map +1 -1
  64. package/dist/hydrate.js +21 -20
  65. package/dist/indexer.d.ts +195 -3
  66. package/dist/indexer.d.ts.map +1 -1
  67. package/dist/indexer.js +634 -94
  68. package/dist/jetstream.d.ts +118 -0
  69. package/dist/jetstream.d.ts.map +1 -0
  70. package/dist/jetstream.js +238 -0
  71. package/dist/labels.d.ts +36 -0
  72. package/dist/labels.d.ts.map +1 -1
  73. package/dist/labels.js +71 -6
  74. package/dist/lex-types.d.ts +2 -0
  75. package/dist/lex-types.d.ts.map +1 -1
  76. package/dist/lexicon-resolve.d.ts.map +1 -1
  77. package/dist/lexicon-resolve.js +27 -112
  78. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  79. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  80. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  81. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  82. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  83. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  84. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  85. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  86. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  87. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  88. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  89. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  90. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  91. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  92. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  93. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  94. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  95. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  96. package/dist/logger.d.ts +29 -0
  97. package/dist/logger.d.ts.map +1 -1
  98. package/dist/logger.js +29 -0
  99. package/dist/main.js +197 -75
  100. package/dist/mst.d.ts +18 -1
  101. package/dist/mst.d.ts.map +1 -1
  102. package/dist/mst.js +19 -8
  103. package/dist/oauth/db.d.ts +27 -1
  104. package/dist/oauth/db.d.ts.map +1 -1
  105. package/dist/oauth/db.js +80 -19
  106. package/dist/oauth/discovery.d.ts +10 -0
  107. package/dist/oauth/discovery.d.ts.map +1 -1
  108. package/dist/oauth/discovery.js +54 -2
  109. package/dist/oauth/server.d.ts +131 -1
  110. package/dist/oauth/server.d.ts.map +1 -1
  111. package/dist/oauth/server.js +504 -39
  112. package/dist/oauth/service-describe.d.ts +42 -0
  113. package/dist/oauth/service-describe.d.ts.map +1 -0
  114. package/dist/oauth/service-describe.js +108 -0
  115. package/dist/oauth/session.d.ts +32 -0
  116. package/dist/oauth/session.d.ts.map +1 -0
  117. package/dist/oauth/session.js +131 -0
  118. package/dist/opengraph.d.ts +10 -0
  119. package/dist/opengraph.d.ts.map +1 -1
  120. package/dist/opengraph.js +94 -42
  121. package/dist/pds-proxy.d.ts +92 -0
  122. package/dist/pds-proxy.d.ts.map +1 -0
  123. package/dist/pds-proxy.js +396 -0
  124. package/dist/private-collections.d.ts +13 -0
  125. package/dist/private-collections.d.ts.map +1 -0
  126. package/dist/private-collections.js +27 -0
  127. package/dist/push.d.ts +54 -0
  128. package/dist/push.d.ts.map +1 -0
  129. package/dist/push.js +367 -0
  130. package/dist/renderer.d.ts +27 -0
  131. package/dist/renderer.d.ts.map +1 -0
  132. package/dist/renderer.js +46 -0
  133. package/dist/resolve-hatk.d.ts +6 -0
  134. package/dist/resolve-hatk.d.ts.map +1 -0
  135. package/dist/resolve-hatk.js +20 -0
  136. package/dist/response.d.ts +16 -0
  137. package/dist/response.d.ts.map +1 -0
  138. package/dist/response.js +69 -0
  139. package/dist/scanner.d.ts +21 -0
  140. package/dist/scanner.d.ts.map +1 -0
  141. package/dist/scanner.js +88 -0
  142. package/dist/seed.d.ts +19 -0
  143. package/dist/seed.d.ts.map +1 -1
  144. package/dist/seed.js +43 -4
  145. package/dist/server-init.d.ts +8 -0
  146. package/dist/server-init.d.ts.map +1 -0
  147. package/dist/server-init.js +62 -0
  148. package/dist/server.d.ts +34 -3
  149. package/dist/server.d.ts.map +1 -1
  150. package/dist/server.js +1053 -626
  151. package/dist/setup.d.ts +28 -1
  152. package/dist/setup.d.ts.map +1 -1
  153. package/dist/setup.js +50 -3
  154. package/dist/spaces/blob.d.ts +112 -0
  155. package/dist/spaces/blob.d.ts.map +1 -0
  156. package/dist/spaces/blob.js +460 -0
  157. package/dist/spaces/credential.d.ts +62 -0
  158. package/dist/spaces/credential.d.ts.map +1 -0
  159. package/dist/spaces/credential.js +184 -0
  160. package/dist/spaces/engine.d.ts +97 -0
  161. package/dist/spaces/engine.d.ts.map +1 -0
  162. package/dist/spaces/engine.js +559 -0
  163. package/dist/spaces/guard.d.ts +41 -0
  164. package/dist/spaces/guard.d.ts.map +1 -0
  165. package/dist/spaces/guard.js +79 -0
  166. package/dist/spaces/identity.d.ts +37 -0
  167. package/dist/spaces/identity.d.ts.map +1 -0
  168. package/dist/spaces/identity.js +88 -0
  169. package/dist/spaces/index.d.ts +55 -0
  170. package/dist/spaces/index.d.ts.map +1 -0
  171. package/dist/spaces/index.js +133 -0
  172. package/dist/spaces/notify.d.ts +51 -0
  173. package/dist/spaces/notify.d.ts.map +1 -0
  174. package/dist/spaces/notify.js +121 -0
  175. package/dist/spaces/records.d.ts +37 -0
  176. package/dist/spaces/records.d.ts.map +1 -0
  177. package/dist/spaces/records.js +63 -0
  178. package/dist/spaces/store.d.ts +51 -0
  179. package/dist/spaces/store.d.ts.map +1 -0
  180. package/dist/spaces/store.js +99 -0
  181. package/dist/spaces/uri.d.ts +60 -0
  182. package/dist/spaces/uri.d.ts.map +1 -0
  183. package/dist/spaces/uri.js +97 -0
  184. package/dist/spaces/verify.d.ts +46 -0
  185. package/dist/spaces/verify.d.ts.map +1 -0
  186. package/dist/spaces/verify.js +94 -0
  187. package/dist/spaces/viewer.d.ts +51 -0
  188. package/dist/spaces/viewer.d.ts.map +1 -0
  189. package/dist/spaces/viewer.js +167 -0
  190. package/dist/spaces/visibility.d.ts +57 -0
  191. package/dist/spaces/visibility.d.ts.map +1 -0
  192. package/dist/spaces/visibility.js +81 -0
  193. package/dist/templates/feed.tpl +18 -0
  194. package/dist/templates/hook.tpl +5 -0
  195. package/dist/templates/label.tpl +15 -0
  196. package/dist/templates/og.tpl +17 -0
  197. package/dist/templates/seed.tpl +11 -0
  198. package/dist/templates/setup.tpl +5 -0
  199. package/dist/templates/test-feed.tpl +19 -0
  200. package/dist/templates/test-xrpc.tpl +19 -0
  201. package/dist/templates/xrpc.tpl +53 -0
  202. package/dist/test.d.ts +3 -1
  203. package/dist/test.d.ts.map +1 -1
  204. package/dist/test.js +60 -36
  205. package/dist/views.js +1 -1
  206. package/dist/vite-plugin.d.ts +1 -1
  207. package/dist/vite-plugin.d.ts.map +1 -1
  208. package/dist/vite-plugin.js +254 -65
  209. package/dist/xrpc.d.ts +122 -12
  210. package/dist/xrpc.d.ts.map +1 -1
  211. package/dist/xrpc.js +270 -55
  212. package/package.json +34 -10
  213. package/public/admin-auth.js +271 -306
  214. package/public/admin.html +177 -61
  215. package/public/robots.txt +2 -0
  216. package/dist/db.d.ts.map +0 -1
  217. package/dist/db.js +0 -1361
  218. package/dist/fts.d.ts.map +0 -1
  219. package/dist/oauth/hooks.d.ts +0 -10
  220. package/dist/oauth/hooks.d.ts.map +0 -1
  221. package/dist/oauth/hooks.js +0 -40
  222. package/dist/schema.d.ts +0 -51
  223. package/dist/schema.d.ts.map +0 -1
  224. package/dist/test-browser.d.ts +0 -14
  225. package/dist/test-browser.d.ts.map +0 -1
  226. 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,81 @@ 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 pdsScope;
322
+ let pdsEndpoint;
323
+ if (prompt === 'create' && body.login_hint) {
324
+ // Account creation: login_hint is a PDS URL, discover auth server from it directly
325
+ let pdsUrl;
326
+ if (body.login_hint.startsWith('http')) {
327
+ pdsUrl = body.login_hint;
328
+ }
329
+ else if (body.login_hint.match(/^localhost[:/]/)) {
330
+ pdsUrl = `http://${body.login_hint}`;
331
+ }
332
+ else {
333
+ pdsUrl = `https://${body.login_hint}`;
334
+ }
335
+ pdsEndpoint = pdsUrl;
336
+ const protectedResource = await fetchProtectedResourceMetadata(pdsUrl);
337
+ pdsAuthServer = protectedResource.authorization_servers[0];
338
+ if (!pdsAuthServer)
339
+ throw new Error(`No auth server for PDS ${pdsUrl}`);
340
+ did = undefined; // no DID yet for account creation
341
+ }
342
+ else if (did && !did.startsWith('did:')) {
343
+ try {
344
+ did = await resolveHandle(did, _relayUrl);
345
+ }
346
+ catch {
347
+ throw new Error('Handle not found');
348
+ }
349
+ }
350
+ // Discover user's PDS auth server (for login flow with a resolved DID)
351
+ if (did && !pdsAuthServer) {
157
352
  const discovery = await discoverAuthServer(did, _plcUrl);
158
353
  pdsAuthServer = discovery.authServerEndpoint;
354
+ pdsEndpoint = discovery.pdsEndpoint;
355
+ }
356
+ if (pdsAuthServer) {
357
+ const authServerMetadata = await fetchAuthServerMetadata(pdsAuthServer);
358
+ const endpoints = authServerEndpoints(authServerMetadata, pdsAuthServer);
359
+ // Held for the authorize redirect and the token exchange, which run on
360
+ // later requests with only this row to go on.
361
+ pdsAuthorizationEndpoint = endpoints.authorizationEndpoint;
362
+ pdsTokenEndpoint = endpoints.tokenEndpoint;
159
363
  // Create PKCE for our PAR to the PDS
160
364
  pdsCodeVerifier = randomToken();
161
365
  const pdsCodeChallenge = base64UrlEncode(await sha256(pdsCodeVerifier));
162
366
  pdsState = randomToken(); // unique state to correlate callback
163
367
  // PAR to the PDS
164
- const parEndpoint = discovery.authServerMetadata.pushed_authorization_request_endpoint || `${pdsAuthServer}/oauth/par`;
368
+ const parEndpoint = endpoints.parEndpoint;
165
369
  const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint);
166
- const pdsParBody = new URLSearchParams({
370
+ pdsScope = await negotiateScope(config, body.scope || 'atproto transition:generic', pdsEndpoint, isLoopbackClient(config.issuer));
371
+ const pdsParParams = {
167
372
  client_id: pdsClientId(config.issuer, config),
168
373
  redirect_uri: pdsRedirectUri(config.issuer),
169
374
  response_type: 'code',
170
375
  code_challenge: pdsCodeChallenge,
171
376
  code_challenge_method: 'S256',
172
- scope: body.scope || 'atproto transition:generic',
173
- login_hint: body.login_hint || did,
377
+ scope: pdsScope,
174
378
  state: pdsState,
175
- });
379
+ };
380
+ if (prompt === 'create') {
381
+ pdsParParams.prompt = 'create';
382
+ }
383
+ if (did) {
384
+ pdsParParams.login_hint = body.login_hint || did;
385
+ }
386
+ const pdsParBody = new URLSearchParams(await withClientAuth(pdsParParams, config.issuer, config, authServerMetadata.issuer || pdsAuthServer));
176
387
  const pdsParRes = await fetch(parEndpoint, {
177
388
  method: 'POST',
178
389
  headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
@@ -234,10 +445,14 @@ export async function handlePar(config, body, dpopHeader, requestUrl) {
234
445
  dpopJkt: dpop.jkt,
235
446
  pdsRequestUri,
236
447
  pdsAuthServer,
448
+ pdsAuthorizationEndpoint,
449
+ pdsTokenEndpoint,
450
+ pdsEndpoint,
237
451
  pdsCodeVerifier,
238
452
  pdsState,
239
453
  did,
240
454
  loginHint: body.login_hint,
455
+ pdsScope,
241
456
  expiresAt,
242
457
  });
243
458
  return { request_uri: requestUri, expires_in: 600 };
@@ -251,12 +466,136 @@ export function buildAuthorizeRedirect(config, request) {
251
466
  request_uri: request.pds_request_uri,
252
467
  client_id: pdsClientId(config.issuer, config),
253
468
  });
254
- return `${request.pds_auth_server}/oauth/authorize?${params}`;
469
+ return withQuery(storedEndpoint(request.pds_authorization_endpoint, request.pds_auth_server, '/oauth/authorize'), params);
470
+ }
471
+ // --- Server-initiated login (no DPoP required from browser) ---
472
+ /**
473
+ * Initiate a server-side OAuth login or account creation flow.
474
+ *
475
+ * For account creation, pass `{ prompt: 'create', pds: 'selfhosted.social' }`.
476
+ * The `pds` is a bare hostname; the auth server is discovered from its
477
+ * protected resource metadata.
478
+ */
479
+ export async function serverLogin(config, handle, options) {
480
+ let did;
481
+ let pdsAuthServer;
482
+ let pdsEndpoint;
483
+ if (options?.prompt === 'create' && options?.pds) {
484
+ // Account creation: discover auth server from PDS hostname
485
+ const pdsUrl = options.pds.startsWith('http')
486
+ ? options.pds
487
+ : options.pds.match(/^localhost[:/]/)
488
+ ? `http://${options.pds}`
489
+ : `https://${options.pds}`;
490
+ pdsEndpoint = pdsUrl;
491
+ const protectedResource = await fetchProtectedResourceMetadata(pdsUrl);
492
+ pdsAuthServer = protectedResource.authorization_servers[0];
493
+ if (!pdsAuthServer)
494
+ throw new Error(`No auth server for PDS ${pdsUrl}`);
495
+ }
496
+ else {
497
+ // Normal login: resolve handle to DID
498
+ did = handle;
499
+ if (!did.startsWith('did:')) {
500
+ did = await resolveHandle(handle, _relayUrl);
501
+ }
502
+ const discovery = await discoverAuthServer(did, _plcUrl);
503
+ pdsAuthServer = discovery.authServerEndpoint;
504
+ pdsEndpoint = discovery.pdsEndpoint;
505
+ }
506
+ const authServerMetadata = await fetchAuthServerMetadata(pdsAuthServer);
507
+ const endpoints = authServerEndpoints(authServerMetadata, pdsAuthServer);
508
+ // Create PKCE for PAR to PDS
509
+ const pdsCodeVerifier = randomToken();
510
+ const pdsCodeChallenge = base64UrlEncode(await sha256(pdsCodeVerifier));
511
+ const pdsState = randomToken();
512
+ // PAR to the PDS
513
+ const parEndpoint = endpoints.parEndpoint;
514
+ const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint);
515
+ const scope = await negotiateScope(config, config.scopes?.join(' ') || 'atproto transition:generic', pdsEndpoint, isLoopbackClient(config.issuer));
516
+ const pdsParParams = {
517
+ client_id: pdsClientId(config.issuer, config),
518
+ redirect_uri: pdsRedirectUri(config.issuer),
519
+ response_type: 'code',
520
+ code_challenge: pdsCodeChallenge,
521
+ code_challenge_method: 'S256',
522
+ scope,
523
+ state: pdsState,
524
+ };
525
+ if (options?.prompt === 'create') {
526
+ pdsParParams.prompt = 'create';
527
+ }
528
+ if (did) {
529
+ pdsParParams.login_hint = handle;
530
+ }
531
+ const pdsParBody = new URLSearchParams(await withClientAuth(pdsParParams, config.issuer, config, authServerMetadata.issuer || pdsAuthServer));
532
+ let pdsRequestUri;
533
+ const pdsParRes = await fetch(parEndpoint, {
534
+ method: 'POST',
535
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
536
+ body: pdsParBody.toString(),
537
+ });
538
+ if (!pdsParRes.ok) {
539
+ const errBody = await pdsParRes.json().catch(() => ({}));
540
+ if (errBody.error === 'use_dpop_nonce') {
541
+ const nonce = pdsParRes.headers.get('DPoP-Nonce');
542
+ if (nonce) {
543
+ const retryProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', parEndpoint, undefined, nonce);
544
+ const retryRes = await fetch(parEndpoint, {
545
+ method: 'POST',
546
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: retryProof },
547
+ body: pdsParBody.toString(),
548
+ });
549
+ if (!retryRes.ok) {
550
+ const retryErr = await retryRes.json().catch(() => ({}));
551
+ throw new Error(`PDS PAR failed: ${retryRes.status} ${retryErr.error_description || retryErr.error || ''}`);
552
+ }
553
+ const retryData = await retryRes.json();
554
+ pdsRequestUri = retryData.request_uri;
555
+ }
556
+ }
557
+ else {
558
+ throw new Error(`PDS PAR failed: ${pdsParRes.status} ${errBody.error_description || errBody.error || ''}`);
559
+ }
560
+ }
561
+ else {
562
+ const pdsParData = await pdsParRes.json();
563
+ pdsRequestUri = pdsParData.request_uri;
564
+ }
565
+ // Store the request so the callback can find it
566
+ const requestUri = `urn:ietf:params:oauth:request_uri:${randomToken()}`;
567
+ const expiresAt = Math.floor(Date.now() / 1000) + 600;
568
+ await storeOAuthRequest(requestUri, {
569
+ clientId: pdsClientId(config.issuer, config),
570
+ redirectUri: '/',
571
+ scope,
572
+ state: pdsState,
573
+ codeChallenge: '',
574
+ codeChallengeMethod: 'S256',
575
+ dpopJkt: serverJkt,
576
+ pdsRequestUri,
577
+ pdsAuthServer,
578
+ pdsAuthorizationEndpoint: endpoints.authorizationEndpoint,
579
+ pdsTokenEndpoint: endpoints.tokenEndpoint,
580
+ pdsEndpoint,
581
+ pdsCodeVerifier,
582
+ pdsState,
583
+ did,
584
+ loginHint: handle,
585
+ pdsScope: scope,
586
+ expiresAt,
587
+ });
588
+ // Build redirect URL to PDS
589
+ const params = new URLSearchParams({
590
+ request_uri: pdsRequestUri,
591
+ client_id: pdsClientId(config.issuer, config),
592
+ });
593
+ return withQuery(endpoints.authorizationEndpoint, params);
255
594
  }
256
595
  // --- OAuth Callback (PDS redirects here) ---
257
596
  export async function handleCallback(config, code, state, iss) {
258
597
  // Find the matching OAuth request by pds_state (unique per PAR)
259
- const { querySQL } = await import("../db.js");
598
+ const { querySQL } = await import("../database/db.js");
260
599
  let request = null;
261
600
  if (state) {
262
601
  const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_state = $1 AND expires_at > $2`, [
@@ -273,15 +612,15 @@ export async function handleCallback(config, code, state, iss) {
273
612
  if (!request)
274
613
  throw new Error('No matching authorization request found');
275
614
  // Exchange code at PDS token endpoint
276
- const tokenEndpoint = `${request.pds_auth_server}/oauth/token`;
615
+ const tokenEndpoint = storedEndpoint(request.pds_token_endpoint, request.pds_auth_server, '/oauth/token');
277
616
  const serverDpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
278
- const tokenBody = new URLSearchParams({
617
+ const tokenBody = new URLSearchParams(await withClientAuth({
279
618
  grant_type: 'authorization_code',
280
619
  code,
281
620
  redirect_uri: pdsRedirectUri(config.issuer),
282
621
  client_id: pdsClientId(config.issuer, config),
283
622
  code_verifier: request.pds_code_verifier,
284
- });
623
+ }, config.issuer, config, request.pds_auth_server));
285
624
  let tokenRes = await fetch(tokenEndpoint, {
286
625
  method: 'POST',
287
626
  headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: serverDpopProof },
@@ -329,42 +668,78 @@ export async function handleCallback(config, code, state, iss) {
329
668
  const did = tokenData.sub;
330
669
  if (!did)
331
670
  throw new Error('PDS token response missing sub (DID)');
332
- // Store PDS session server-side
671
+ // Store PDS session server-side — pds_endpoint is the actual data PDS
672
+ // (e.g. leccinum.us-west.host.bsky.network), pds_auth_server is the OAuth server (bsky.social)
333
673
  await storeSession(did, {
334
- pdsEndpoint: request.pds_auth_server.replace('/oauth', ''),
674
+ pdsEndpoint: request.pds_endpoint,
675
+ pdsAuthServer: request.pds_auth_server,
676
+ pdsTokenEndpoint: tokenEndpoint,
335
677
  accessToken: tokenData.access_token,
336
678
  refreshToken: tokenData.refresh_token,
337
679
  dpopJkt: serverJkt,
338
680
  tokenExpiresAt: tokenData.expires_in ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
681
+ requestedScope: request.pds_scope ?? undefined,
682
+ grantedScope: typeof tokenData.scope === 'string' ? tokenData.scope : undefined,
339
683
  });
340
- await fireOnLoginHook(did);
684
+ await fireOnLoginHook(did, config);
341
685
  // Generate authorization code for the client
342
686
  const clientCode = randomToken();
343
687
  await storeAuthCode(clientCode, request.request_uri);
344
688
  // Update the request with the DID (in case it wasn't set during PAR)
345
689
  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);
690
+ const { runSQL } = await import("../database/db.js");
691
+ await runSQL('UPDATE _oauth_requests SET did = $1 WHERE request_uri = $2', [did, request.request_uri]);
348
692
  }
349
693
  // Build redirect back to client
350
694
  const params = new URLSearchParams({ code: clientCode, iss: config.issuer });
351
695
  if (request.state)
352
696
  params.set('state', request.state);
353
697
  const clientRedirectUri = `${request.redirect_uri}?${params}`;
354
- return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state };
698
+ return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state, did };
699
+ }
700
+ /**
701
+ * Handle an error redirect from the PDS (user denied, request expired, etc).
702
+ *
703
+ * The PDS reports failures by redirecting to our callback with `error` and no
704
+ * `code`. Discard the pending request and return a URL that hands the error
705
+ * back to the app, so the browser lands somewhere renderable instead of on a
706
+ * raw error response.
707
+ */
708
+ export async function handleCallbackError(config, error, errorDescription, state) {
709
+ let request = null;
710
+ if (state) {
711
+ const rows = await querySQL(`SELECT * FROM _oauth_requests WHERE pds_state = $1`, [state]);
712
+ request = rows.length > 0 ? rows[0] : null;
713
+ }
714
+ if (request)
715
+ await deleteOAuthRequest(request.request_uri);
716
+ emit('oauth', 'pds_callback_error', { error, error_description: errorDescription, matched: !!request });
717
+ const params = new URLSearchParams({ error });
718
+ if (errorDescription)
719
+ params.set('error_description', errorDescription);
720
+ // Server-initiated logins store redirect_uri as '/' — send the browser home
721
+ // with the error attached. Same for an unmatched state (expired or replayed).
722
+ if (!request || request.redirect_uri === '/')
723
+ return `/?${params}`;
724
+ // Client flow: echo the client's state and our issuer so the SPA's callback
725
+ // handler recognises the redirect as its own rather than re-entering here.
726
+ if (request.state)
727
+ params.set('state', request.state);
728
+ params.set('iss', config.issuer);
729
+ return `${request.redirect_uri}?${params}`;
355
730
  }
356
731
  // --- Token Endpoint ---
357
732
  export async function handleToken(config, body, dpopHeader, requestUrl) {
358
733
  const grantType = body.grant_type;
359
734
  if (!grantType)
360
- throw new Error('grant_type is required');
735
+ throw new OAuthError('invalid_request', 'grant_type is required');
361
736
  if (grantType === 'authorization_code') {
362
737
  return handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl);
363
738
  }
364
739
  else if (grantType === 'refresh_token') {
365
740
  return handleRefreshTokenGrant(config, body, dpopHeader, requestUrl);
366
741
  }
367
- throw new Error(`Unsupported grant_type: ${grantType}`);
742
+ throw new OAuthError('unsupported_grant_type', `Unsupported grant_type: ${grantType}`);
368
743
  }
369
744
  async function handleAuthorizationCodeGrant(config, body, dpopHeader, requestUrl) {
370
745
  const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
@@ -437,20 +812,21 @@ async function handleRefreshTokenGrant(config, body, dpopHeader, requestUrl) {
437
812
  const dpop = await parseDpopProof(dpopHeader, 'POST', requestUrl);
438
813
  const fresh = await checkAndStoreDpopJti(dpop.jti, dpop.iat + 300);
439
814
  if (!fresh)
440
- throw new Error('DPoP jti replay detected');
815
+ throw new OAuthError('invalid_request', 'DPoP jti replay detected');
441
816
  const { refresh_token, client_id } = body;
442
817
  if (!refresh_token || !client_id)
443
- throw new Error('Missing required parameters');
444
- // Look up and validate refresh token
818
+ throw new OAuthError('invalid_request', 'Missing required parameters');
819
+ // Look up and validate refresh token. Per RFC 6749 §5.2, all of these are
820
+ // `invalid_grant` — the client must treat them as terminal and re-authenticate.
445
821
  const stored = await getRefreshToken(refresh_token);
446
822
  if (!stored)
447
- throw new Error('Invalid refresh token');
823
+ throw new OAuthError('invalid_grant', 'Invalid refresh token');
448
824
  if (stored.revoked)
449
- throw new Error('Refresh token revoked');
825
+ throw new OAuthError('invalid_grant', 'Refresh token revoked');
450
826
  if (stored.expires_at && stored.expires_at < Math.floor(Date.now() / 1000))
451
- throw new Error('Refresh token expired');
827
+ throw new OAuthError('invalid_grant', 'Refresh token expired');
452
828
  if (stored.client_id !== client_id)
453
- throw new Error('client_id mismatch');
829
+ throw new OAuthError('invalid_grant', 'client_id mismatch');
454
830
  // Revoke old refresh token (rotation)
455
831
  await revokeRefreshToken(refresh_token);
456
832
  const did = stored.did;
@@ -488,18 +864,101 @@ async function handleRefreshTokenGrant(config, body, dpopHeader, requestUrl) {
488
864
  handle,
489
865
  };
490
866
  }
867
+ // --- Sessions obtained outside the redirect flow ---
868
+ /** A refusal from the endpoint {@link obtainSession} called, or an answer it cannot use. */
869
+ export class ObtainSessionError extends Error {
870
+ error;
871
+ status;
872
+ constructor(
873
+ /** The endpoint's error code, e.g. an XRPC error name. */
874
+ error, message, status) {
875
+ super(message);
876
+ this.error = error;
877
+ this.status = status;
878
+ }
879
+ }
880
+ /**
881
+ * Obtain a session for another account straight from its authorization server,
882
+ * outside the redirect flow: a group host creating an account for this app and
883
+ * handing it a session, say.
884
+ *
885
+ * `body` is POSTed as JSON to `url` as this app's OAuth client, the way the
886
+ * token endpoint is called: `client_id` and, for a confidential client, a client
887
+ * assertion addressed to the endpoint's origin, are added to it, and the request
888
+ * carries a DPoP proof from the app's key, answering a nonce challenge once.
889
+ * `headers` are sent too, for whatever else the endpoint wants to know (service
890
+ * auth from the viewer, typically).
891
+ *
892
+ * The response must hold an OAuth token response, at `session` or as the whole
893
+ * body. That session is stored for its `sub` exactly as a login's is, bound to
894
+ * the same DPoP key, so refresh and the PDS helpers work for that DID from here
895
+ * on. It is only accepted if the account's own authorization server is the one
896
+ * that answered: an endpoint cannot hand over a session for an account it does
897
+ * not serve.
898
+ */
899
+ export async function obtainSession(config, url, body, opts = {}) {
900
+ const origin = new URL(url).origin;
901
+ const payload = {
902
+ ...body,
903
+ ...(await withClientAuth({ client_id: pdsClientId(config.issuer, config) }, config.issuer, config, origin)),
904
+ };
905
+ const send = async (nonce) => fetch(url, {
906
+ method: 'POST',
907
+ headers: {
908
+ ...opts.headers,
909
+ 'Content-Type': 'application/json',
910
+ DPoP: await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', url, undefined, nonce),
911
+ },
912
+ body: JSON.stringify(payload),
913
+ });
914
+ let res = await send();
915
+ let out = (await res.json().catch(() => ({})));
916
+ const nonce = res.headers.get('DPoP-Nonce');
917
+ if (!res.ok && out.error === 'use_dpop_nonce' && nonce) {
918
+ res = await send(nonce);
919
+ out = (await res.json().catch(() => ({})));
920
+ }
921
+ if (!res.ok) {
922
+ throw new ObtainSessionError(typeof out.error === 'string' ? out.error : 'UpstreamFailure', String(out.message ?? out.error_description ?? `${url} failed (${res.status})`), res.status);
923
+ }
924
+ const token = (typeof out.session === 'object' && out.session ? out.session : out);
925
+ const did = token.sub;
926
+ if (typeof did !== 'string' || typeof token.access_token !== 'string') {
927
+ throw new ObtainSessionError('NoSession', `${url} returned no session`, 502);
928
+ }
929
+ const { pdsEndpoint, authServerEndpoint, authServerMetadata } = await discoverAuthServer(did, _plcUrl);
930
+ if (new URL(authServerEndpoint).origin !== origin) {
931
+ throw new ObtainSessionError('WrongAuthServer', `${origin} is not the authorization server for ${did}`, 502);
932
+ }
933
+ await storeSession(did, {
934
+ pdsEndpoint,
935
+ pdsAuthServer: authServerEndpoint,
936
+ pdsTokenEndpoint: authServerMetadata.token_endpoint,
937
+ accessToken: token.access_token,
938
+ refreshToken: typeof token.refresh_token === 'string' ? token.refresh_token : undefined,
939
+ dpopJkt: serverJkt,
940
+ tokenExpiresAt: typeof token.expires_in === 'number' ? Math.floor(Date.now() / 1000) + token.expires_in : undefined,
941
+ requestedScope: typeof body.scope === 'string' ? body.scope : undefined,
942
+ grantedScope: typeof token.scope === 'string' ? token.scope : undefined,
943
+ });
944
+ emit('oauth', 'session_obtained', { did, from: origin });
945
+ return { did, response: out };
946
+ }
491
947
  // --- PDS Session Refresh ---
492
948
  export async function refreshPdsSession(config, session) {
493
949
  if (!session.refresh_token)
494
950
  return null;
495
- const tokenEndpoint = `${session.pds_endpoint}/oauth/token`;
951
+ // Prefer the endpoint the auth server advertised when this session was
952
+ // authorized. Sessions predating that column fall back to the auth server
953
+ // (or, older still, the PDS) with the path this used to hardcode.
954
+ const tokenEndpoint = storedEndpoint(session.pds_token_endpoint, session.pds_auth_server || session.pds_endpoint, '/oauth/token');
496
955
  const clientId = pdsClientId(config.issuer, config);
497
956
  const dpopProof = await createDpopProof(serverPrivateJwk, serverPublicJwk, 'POST', tokenEndpoint);
498
- const body = new URLSearchParams({
957
+ const body = new URLSearchParams(await withClientAuth({
499
958
  grant_type: 'refresh_token',
500
959
  refresh_token: session.refresh_token,
501
960
  client_id: clientId,
502
- });
961
+ }, config.issuer, config, session.pds_auth_server || session.pds_endpoint));
503
962
  let tokenRes = await fetch(tokenEndpoint, {
504
963
  method: 'POST',
505
964
  headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: dpopProof },
@@ -526,16 +985,22 @@ export async function refreshPdsSession(config, session) {
526
985
  did: session.did,
527
986
  pds_endpoint: session.pds_endpoint,
528
987
  });
988
+ await deleteSession(session.did);
529
989
  return null;
530
990
  }
531
991
  const tokenData = await tokenRes.json();
532
992
  // Update stored session
533
993
  await storeSession(session.did, {
534
994
  pdsEndpoint: session.pds_endpoint,
995
+ pdsAuthServer: session.pds_auth_server,
996
+ pdsTokenEndpoint: session.pds_token_endpoint,
535
997
  accessToken: tokenData.access_token,
536
998
  refreshToken: tokenData.refresh_token || session.refresh_token,
537
999
  dpopJkt: session.dpop_jkt,
538
1000
  tokenExpiresAt: tokenData.expires_in ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
1001
+ // A refresh renews the grant, it does not renegotiate it.
1002
+ requestedScope: session.requested_scope ?? undefined,
1003
+ grantedScope: typeof tokenData.scope === 'string' ? tokenData.scope : (session.granted_scope ?? undefined),
539
1004
  });
540
1005
  return {
541
1006
  accessToken: tokenData.access_token,