@fluixi/oauth2 1.0.0-alpha.1 → 1.0.0-alpha.10

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 (45) hide show
  1. package/dist/{browser-ONMHX3Z3.mjs → browser-36FHB6KG.mjs} +2 -2
  2. package/dist/browser.cjs +4 -2
  3. package/dist/browser.mjs +4 -2
  4. package/dist/callback.cjs +6 -5
  5. package/dist/callback.mjs +1 -0
  6. package/dist/{chunk-4NSOGPLM.mjs → chunk-F52RUVDM.mjs} +1 -1
  7. package/dist/{chunk-25WE6UWI.mjs → chunk-OGS6MQ3D.mjs} +1 -1
  8. package/dist/{dpop-LVPWWADA.mjs → dpop-GOWCCCFR.mjs} +26 -6
  9. package/dist/{dpop-NS7NKOWM.mjs → dpop-YPGCGL7S.mjs} +22 -2
  10. package/dist/dpop-store.cjs +4 -2
  11. package/dist/dpop-store.mjs +4 -2
  12. package/dist/dpop.cjs +22 -2
  13. package/dist/dpop.d.ts +18 -1
  14. package/dist/dpop.d.ts.map +1 -1
  15. package/dist/dpop.js +23 -2
  16. package/dist/dpop.mjs +22 -2
  17. package/dist/index.cjs +28 -3
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -0
  21. package/dist/index.mjs +31 -6
  22. package/dist/module.cjs +44 -0
  23. package/dist/module.d.ts +13 -0
  24. package/dist/module.d.ts.map +1 -0
  25. package/dist/module.js +22 -0
  26. package/dist/module.mjs +23 -0
  27. package/dist/server.cjs +138 -25
  28. package/dist/server.d.ts +20 -0
  29. package/dist/server.d.ts.map +1 -1
  30. package/dist/server.js +79 -17
  31. package/dist/server.mjs +140 -27
  32. package/dist/service-client.cjs +1 -1
  33. package/dist/service-client.mjs +3 -3
  34. package/dist/service.cjs +12 -2
  35. package/dist/service.d.ts +2 -18
  36. package/dist/service.d.ts.map +1 -1
  37. package/dist/service.js +11 -0
  38. package/dist/service.mjs +12 -2
  39. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  40. package/dist/types.d.ts +4 -2
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/types.js +0 -12
  43. package/dist/verify.cjs +24 -3
  44. package/dist/verify.mjs +6 -6
  45. package/package.json +15 -15
package/dist/module.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The app-module half of this package, listed in `src/app.config.ts`.
3
+ *
4
+ * Registers the app's service under `AuthServiceToken` and its client under
5
+ * `AuthClientToken` on both sides. Package code reaches the service through those tokens,
6
+ * `OAuthCallback` among it, because a package cannot import the app. The server half, the
7
+ * routes and session middleware of `tokens: 'server'`, is `authServerModule` in
8
+ * `@fluixi/oauth2/server`, for `app.config.server.ts`.
9
+ */
10
+ import { defineModule } from '@fluixi/start/app';
11
+ import { AuthServiceToken, AuthClientToken } from './tokens.js';
12
+ export function authModule(auth) {
13
+ return defineModule({
14
+ name: 'oauth2',
15
+ providers: [
16
+ // One instance for every request on the server. It holds no per-visitor state there:
17
+ // the signed-in user comes from request locals, which `sessionLocals` fills per request.
18
+ { provide: AuthServiceToken, useValue: auth },
19
+ { provide: AuthClientToken, useValue: auth.client },
20
+ ],
21
+ });
22
+ }
@@ -0,0 +1,23 @@
1
+ // src/module.ts
2
+ import { defineModule } from "@fluixi/start/app";
3
+
4
+ // src/tokens.ts
5
+ import { injectionToken } from "@fluixi/start/di";
6
+ var AuthServiceToken = injectionToken("AuthService");
7
+ var AuthClientToken = injectionToken("AuthClient");
8
+
9
+ // src/module.ts
10
+ function authModule(auth) {
11
+ return defineModule({
12
+ name: "oauth2",
13
+ providers: [
14
+ // One instance for every request on the server. It holds no per-visitor state there:
15
+ // the signed-in user comes from request locals, which `sessionLocals` fills per request.
16
+ { provide: AuthServiceToken, useValue: auth },
17
+ { provide: AuthClientToken, useValue: auth.client }
18
+ ]
19
+ });
20
+ }
21
+ export {
22
+ authModule
23
+ };
package/dist/server.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! @fluixi/oauth2 v1.0.0-alpha.1 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
1
+ /*! @fluixi/oauth2 v1.0.0-alpha.10 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
2
  "use strict";
3
3
  var __create = Object.create;
4
4
  var __defProp = Object.defineProperty;
@@ -31,6 +31,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  // src/server.ts
32
32
  var server_exports = {};
33
33
  __export(server_exports, {
34
+ authServerModule: () => authServerModule,
34
35
  oauthRoutes: () => oauthRoutes,
35
36
  sessionLocals: () => sessionLocals
36
37
  });
@@ -206,7 +207,106 @@ function expiryFrom(expiresIn, now = Date.now()) {
206
207
  return Number.isFinite(seconds) && seconds > 0 ? now + seconds * 1e3 : void 0;
207
208
  }
208
209
 
210
+ // src/dpop.ts
211
+ async function loadJose2() {
212
+ try {
213
+ return await import("jose");
214
+ } catch {
215
+ throw new OAuthError(
216
+ "DPoP needs `jose`. Install it: npm i jose. It is an optional peer, so a client not using DPoP does not carry it.",
217
+ "jose_missing"
218
+ );
219
+ }
220
+ }
221
+ async function createDpopKey(alg = "ES256", options = {}) {
222
+ const jose = await loadJose2();
223
+ const { privateKey, publicKey } = await jose.generateKeyPair(alg, {
224
+ extractable: options.extractable ?? false
225
+ });
226
+ const publicJwk = { ...await jose.exportJWK(publicKey) };
227
+ return {
228
+ privateKey,
229
+ publicJwk,
230
+ thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
231
+ alg
232
+ };
233
+ }
234
+ async function exportDpopKey(key) {
235
+ const jose = await loadJose2();
236
+ return { jwk: { ...await jose.exportJWK(key.privateKey) }, alg: key.alg };
237
+ }
238
+ var PRIVATE_MEMBERS = /* @__PURE__ */ new Set(["d", "p", "q", "dp", "dq", "qi", "oth"]);
239
+ async function importDpopKey(stored) {
240
+ const jose = await loadJose2();
241
+ const privateKey = await jose.importJWK(stored.jwk, stored.alg);
242
+ const publicJwk = Object.fromEntries(Object.entries(stored.jwk).filter(([k]) => !PRIVATE_MEMBERS.has(k)));
243
+ return {
244
+ privateKey,
245
+ publicJwk,
246
+ thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
247
+ alg: stored.alg
248
+ };
249
+ }
250
+ async function proofFor(key, options) {
251
+ const jose = await loadJose2();
252
+ const htu = (() => {
253
+ try {
254
+ const url = new URL(options.url);
255
+ url.search = "";
256
+ url.hash = "";
257
+ return url.toString();
258
+ } catch {
259
+ return options.url;
260
+ }
261
+ })();
262
+ const claims = {
263
+ htm: options.method.toUpperCase(),
264
+ htu,
265
+ jti: crypto.randomUUID()
266
+ };
267
+ if (options.accessToken) {
268
+ const digest = await crypto.subtle.digest(
269
+ "SHA-256",
270
+ new TextEncoder().encode(options.accessToken)
271
+ );
272
+ let binary = "";
273
+ for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
274
+ claims.ath = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
275
+ }
276
+ if (options.nonce) claims.nonce = options.nonce;
277
+ return new jose.SignJWT(claims).setProtectedHeader({ typ: "dpop+jwt", alg: key.alg, jwk: key.publicJwk }).setIssuedAt().sign(key.privateKey);
278
+ }
279
+ function nonceFrom(response) {
280
+ return response.headers.get("dpop-nonce");
281
+ }
282
+
209
283
  // src/server.ts
284
+ var import_app = require("@fluixi/start/app");
285
+ var dpopNonces = /* @__PURE__ */ new Map();
286
+ async function prove(headers, key, method, url, accessToken) {
287
+ headers.dpop = await proofFor(key, { method, url, accessToken, nonce: dpopNonces.get(new URL(url).origin) });
288
+ }
289
+ function learnNonce(url, response) {
290
+ const offered = nonceFrom(response);
291
+ if (!offered) return false;
292
+ const origin = new URL(url).origin;
293
+ const fresh = offered !== dpopNonces.get(origin);
294
+ dpopNonces.set(origin, offered);
295
+ return fresh && !response.ok;
296
+ }
297
+ async function withToken(url, session) {
298
+ const key = session.dpop && session.tokenType?.toLowerCase() === "dpop" ? await importDpopKey(session.dpop) : null;
299
+ const send = async () => {
300
+ const headers = {
301
+ authorization: `${key ? "DPoP" : "Bearer"} ${session.accessToken}`
302
+ };
303
+ if (key) await prove(headers, key, "GET", url, session.accessToken);
304
+ return fetch(url, { headers });
305
+ };
306
+ let response = await send();
307
+ if (key && learnNonce(url, response)) response = await send();
308
+ return response;
309
+ }
210
310
  function oauthRoutes(config) {
211
311
  const base = (config.basePath ?? "/auth").replace(/\/$/, "");
212
312
  const scopes = config.scopes ?? ["openid", "profile", "email"];
@@ -238,14 +338,19 @@ function oauthRoutes(config) {
238
338
  keyId: config.keyId,
239
339
  assertionAlgorithm: config.assertionAlgorithm
240
340
  };
241
- async function tokenRequest(body) {
341
+ async function tokenRequest(body, key = null) {
242
342
  const { token } = await resolve();
243
- const headers = {
244
- "content-type": "application/x-www-form-urlencoded"
343
+ const attempt = async () => {
344
+ const headers = {
345
+ "content-type": "application/x-www-form-urlencoded"
346
+ };
347
+ const form = new URLSearchParams(body);
348
+ await applyClientAuth(clientAuth, token, headers, form);
349
+ if (key) await prove(headers, key, "POST", token);
350
+ return fetch(token, { method: "POST", headers, body: form.toString() });
245
351
  };
246
- const form = new URLSearchParams(body);
247
- await applyClientAuth(clientAuth, token, headers, form);
248
- const response = await fetch(token, { method: "POST", headers, body: form.toString() });
352
+ let response = await attempt();
353
+ if (key && learnNonce(token, response)) response = await attempt();
249
354
  const payload = await response.json().catch(() => ({}));
250
355
  if (!response.ok) {
251
356
  throw new OAuthError(
@@ -312,17 +417,23 @@ function oauthRoutes(config) {
312
417
  if (!pending || !state || !timingSafeEqual(pending.state, state)) {
313
418
  return json({ error: "state_mismatch" }, 400);
314
419
  }
315
- const payload = await tokenRequest({
316
- grant_type: "authorization_code",
317
- code,
318
- redirect_uri: config.redirectUri,
319
- code_verifier: pending.verifier
320
- });
420
+ const key = config.dpop ? await createDpopKey(config.dpopAlgorithm, { extractable: true }) : null;
421
+ const payload = await tokenRequest(
422
+ {
423
+ grant_type: "authorization_code",
424
+ code,
425
+ redirect_uri: config.redirectUri,
426
+ code_verifier: pending.verifier
427
+ },
428
+ key
429
+ );
321
430
  const signedIn = await cookie.write({
322
431
  accessToken: String(payload.access_token ?? ""),
323
432
  refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : void 0,
324
433
  idToken: typeof payload.id_token === "string" ? payload.id_token : void 0,
325
- expiresAt: expiryFrom(payload.expires_in)
434
+ expiresAt: expiryFrom(payload.expires_in),
435
+ tokenType: typeof payload.token_type === "string" ? payload.token_type : void 0,
436
+ dpop: key ? await exportDpopKey(key) : void 0
326
437
  });
327
438
  return new Response(null, {
328
439
  status: 302,
@@ -342,17 +453,20 @@ function oauthRoutes(config) {
342
453
  if (route === "/refresh") {
343
454
  const current = await session();
344
455
  if (!current?.refreshToken) return json({ error: "no_session" }, 401);
345
- const payload = await tokenRequest({
346
- grant_type: "refresh_token",
347
- refresh_token: current.refreshToken
348
- });
456
+ const key = current.dpop ? await importDpopKey(current.dpop) : null;
457
+ const payload = await tokenRequest(
458
+ { grant_type: "refresh_token", refresh_token: current.refreshToken },
459
+ key
460
+ );
349
461
  const refreshed = await cookie.write({
350
462
  accessToken: String(payload.access_token ?? ""),
351
463
  // A provider that rotates refresh tokens sends a new one; one that does not
352
464
  // expects the old one to keep working.
353
465
  refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : current.refreshToken,
354
466
  idToken: current.idToken,
355
- expiresAt: expiryFrom(payload.expires_in)
467
+ expiresAt: expiryFrom(payload.expires_in),
468
+ tokenType: typeof payload.token_type === "string" ? payload.token_type : current.tokenType,
469
+ dpop: current.dpop
356
470
  });
357
471
  return json({ ok: true }, 200, { "set-cookie": refreshed });
358
472
  }
@@ -361,9 +475,7 @@ function oauthRoutes(config) {
361
475
  if (!current?.accessToken) return json({ error: "no_session" }, 401);
362
476
  const { userinfo } = await resolve();
363
477
  if (!userinfo) return json({ error: "no_userinfo" }, 501);
364
- const response = await fetch(userinfo, {
365
- headers: { authorization: `Bearer ${current.accessToken}` }
366
- });
478
+ const response = await withToken(userinfo, current);
367
479
  if (!response.ok) return json({ error: "userinfo_failed" }, response.status);
368
480
  const claims = await response.json();
369
481
  return json(config.mapUser ? config.mapUser(claims, current.accessToken) : claims);
@@ -453,9 +565,7 @@ function sessionLocals(config) {
453
565
  try {
454
566
  const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
455
567
  if (endpoints?.userinfo) {
456
- const response = await fetch(endpoints.userinfo, {
457
- headers: { authorization: `Bearer ${session.accessToken}` }
458
- });
568
+ const response = await withToken(endpoints.userinfo, session);
459
569
  if (response.ok) {
460
570
  const claims = await response.json();
461
571
  user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
@@ -468,3 +578,6 @@ function sessionLocals(config) {
468
578
  return next();
469
579
  };
470
580
  }
581
+ function authServerModule(config) {
582
+ return (0, import_app.defineModule)({ name: "oauth2-server", middleware: [oauthRoutes(config), sessionLocals(config)] });
583
+ }
package/dist/server.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type ClientAuthMethodInput } from './client-auth.js';
2
2
  import { type Endpoints } from './types.js';
3
+ import { type AppModule } from '@fluixi/start/app';
3
4
  export interface OAuthRoutesConfig {
4
5
  issuer?: string;
5
6
  endpoints?: Endpoints;
@@ -52,6 +53,17 @@ export interface OAuthRoutesConfig {
52
53
  userCacheMs?: number;
53
54
  /** Map the provider's userinfo onto the app's own shape. */
54
55
  mapUser?: (claims: Record<string, unknown>, accessToken: string) => unknown;
56
+ /**
57
+ * Bind tokens to a key with DPoP (RFC 9449), which a provider may require: an Okta org
58
+ * that mandates it refuses a server-mode client without it.
59
+ *
60
+ * The key is made per session at the code exchange and kept in the encrypted session
61
+ * cookie, since a refresh token is bound to the key that obtained it and a refresh with
62
+ * another key is refused. Needs `jose`.
63
+ */
64
+ dpop?: boolean;
65
+ /** Default `ES256`. Must be in the provider's `dpop_signing_alg_values_supported`. */
66
+ dpopAlgorithm?: string;
55
67
  }
56
68
  /**
57
69
  * The middleware.
@@ -86,4 +98,12 @@ export declare function oauthRoutes(config: OAuthRoutesConfig): (request: Reques
86
98
  * having its session resolved first.
87
99
  */
88
100
  export declare function sessionLocals(config: OAuthRoutesConfig): (request: Request, next: () => Promise<Response>) => Promise<Response>;
101
+ /**
102
+ * The server half of `authModule`, listed in `src/app.config.server.ts`: the routes and the
103
+ * session middleware that `tokens: 'server'` needs, in the order they have to run.
104
+ *
105
+ * `oauthRoutes` first, so a request to the login route is answered rather than having its
106
+ * session resolved on the way past.
107
+ */
108
+ export declare function authServerModule(config: OAuthRoutesConfig): AppModule;
89
109
  //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAgCA,OAAO,EAGL,KAAK,qBAAqB,EAC3B,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EAAc,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC;AAExD,MAAM,WAAW,iBAAiB;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,yFAAyF;IACzF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,4EAA4E;IAC5E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;OAOG;IACH,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;IAChC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,qBAAqB,CAAC;IACxC,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,6DAA6D;IAC7D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uBAAuB;IACvB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;;;;;;OASG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4DAA4D;IAC5D,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC;CAC7E;AAYD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,iBAAiB,IAwDjD,SAAS,OAAO,EAChB,MAAM,MAAM,OAAO,CAAC,QAAQ,CAAC,KAC5B,OAAO,CAAC,QAAQ,CAAC,CAwMrB;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,iBAAiB,IA8BnD,SAAS,OAAO,EAChB,MAAM,MAAM,OAAO,CAAC,QAAQ,CAAC,KAC5B,OAAO,CAAC,QAAQ,CAAC,CA0CrB"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAgCA,OAAO,EAGL,KAAK,qBAAqB,EAC3B,MAAM,kBAAkB,CAAC;AAG1B,OAAO,EAAc,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC;AAExD,OAAO,EAAgB,KAAK,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAEjE,MAAM,WAAW,iBAAiB;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,8EAA8E;IAC9E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,yFAAyF;IACzF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6EAA6E;IAC7E,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,4EAA4E;IAC5E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;OAOG;IACH,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;IAChC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,qBAAqB,CAAC;IACxC,uDAAuD;IACvD,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,6DAA6D;IAC7D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uBAAuB;IACvB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;;;;;;OASG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4DAA4D;IAC5D,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC;IAC5E;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,sFAAsF;IACtF,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AA8DD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,iBAAiB,IA+DjD,SAAS,OAAO,EAChB,MAAM,MAAM,OAAO,CAAC,QAAQ,CAAC,KAC5B,OAAO,CAAC,QAAQ,CAAC,CAmNrB;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,iBAAiB,IA8BnD,SAAS,OAAO,EAChB,MAAM,MAAM,OAAO,CAAC,QAAQ,CAAC,KAC5B,OAAO,CAAC,QAAQ,CAAC,CAwCrB;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,iBAAiB,GAAG,SAAS,CAErE"}
package/dist/server.js CHANGED
@@ -34,6 +34,46 @@ import { applyClientAuth, } from './client-auth.js';
34
34
  import { discover } from './discovery.js';
35
35
  import { createPkcePair, createStateValue, expiryFrom, timingSafeEqual } from './pkce.js';
36
36
  import { OAuthError } from './types.js';
37
+ import { createDpopKey, exportDpopKey, importDpopKey, nonceFrom, proofFor } from './dpop.js';
38
+ import { defineModule } from '@fluixi/start/app';
39
+ /**
40
+ * Nonces providers asked for, per origin, shared by the routes and `sessionLocals`. A nonce
41
+ * is the server's to set (RFC 9449 §8, §9), so the latest one it sent is the one to use.
42
+ */
43
+ const dpopNonces = new Map();
44
+ /** Put a proof for this request on `headers`, with the nonce this origin last asked for. */
45
+ async function prove(headers, key, method, url, accessToken) {
46
+ headers.dpop = await proofFor(key, { method, url, accessToken, nonce: dpopNonces.get(new URL(url).origin) });
47
+ }
48
+ /** Record a nonce the server sent. True when it refused the request with one not seen before. */
49
+ function learnNonce(url, response) {
50
+ const offered = nonceFrom(response);
51
+ if (!offered)
52
+ return false;
53
+ const origin = new URL(url).origin;
54
+ const fresh = offered !== dpopNonces.get(origin);
55
+ dpopNonces.set(origin, offered);
56
+ return fresh && !response.ok;
57
+ }
58
+ /**
59
+ * A request carrying the session's access token: the `DPoP` scheme and a proof bound to the
60
+ * token when the provider bound it, `Bearer` otherwise.
61
+ */
62
+ async function withToken(url, session) {
63
+ const key = session.dpop && session.tokenType?.toLowerCase() === 'dpop' ? await importDpopKey(session.dpop) : null;
64
+ const send = async () => {
65
+ const headers = {
66
+ authorization: `${key ? 'DPoP' : 'Bearer'} ${session.accessToken}`,
67
+ };
68
+ if (key)
69
+ await prove(headers, key, 'GET', url, session.accessToken);
70
+ return fetch(url, { headers });
71
+ };
72
+ let response = await send();
73
+ if (key && learnNonce(url, response))
74
+ response = await send();
75
+ return response;
76
+ }
37
77
  /**
38
78
  * The middleware.
39
79
  *
@@ -72,14 +112,23 @@ export function oauthRoutes(config) {
72
112
  keyId: config.keyId,
73
113
  assertionAlgorithm: config.assertionAlgorithm,
74
114
  };
75
- async function tokenRequest(body) {
115
+ async function tokenRequest(body, key = null) {
76
116
  const { token } = await resolve();
77
- const headers = {
78
- 'content-type': 'application/x-www-form-urlencoded',
117
+ // Built afresh for a retry: a `private_key_jwt` assertion carries a `jti`, and sending
118
+ // the same one twice can be refused as a replay.
119
+ const attempt = async () => {
120
+ const headers = {
121
+ 'content-type': 'application/x-www-form-urlencoded',
122
+ };
123
+ const form = new URLSearchParams(body);
124
+ await applyClientAuth(clientAuth, token, headers, form);
125
+ if (key)
126
+ await prove(headers, key, 'POST', token);
127
+ return fetch(token, { method: 'POST', headers, body: form.toString() });
79
128
  };
80
- const form = new URLSearchParams(body);
81
- await applyClientAuth(clientAuth, token, headers, form);
82
- const response = await fetch(token, { method: 'POST', headers, body: form.toString() });
129
+ let response = await attempt();
130
+ if (key && learnNonce(token, response))
131
+ response = await attempt();
83
132
  const payload = (await response.json().catch(() => ({})));
84
133
  if (!response.ok) {
85
134
  throw new OAuthError(String(payload.error ?? 'token_request_failed'), String(payload.error ?? 'token_request_failed'));
@@ -146,17 +195,23 @@ export function oauthRoutes(config) {
146
195
  if (!pending || !state || !timingSafeEqual(pending.state, state)) {
147
196
  return json({ error: 'state_mismatch' }, 400);
148
197
  }
198
+ // The token is bound here, at the exchange, so this is where the session's key is made.
199
+ const key = config.dpop
200
+ ? await createDpopKey(config.dpopAlgorithm, { extractable: true })
201
+ : null;
149
202
  const payload = await tokenRequest({
150
203
  grant_type: 'authorization_code',
151
204
  code,
152
205
  redirect_uri: config.redirectUri,
153
206
  code_verifier: pending.verifier,
154
- });
207
+ }, key);
155
208
  const signedIn = await cookie.write({
156
209
  accessToken: String(payload.access_token ?? ''),
157
210
  refreshToken: typeof payload.refresh_token === 'string' ? payload.refresh_token : undefined,
158
211
  idToken: typeof payload.id_token === 'string' ? payload.id_token : undefined,
159
212
  expiresAt: expiryFrom(payload.expires_in),
213
+ tokenType: typeof payload.token_type === 'string' ? payload.token_type : undefined,
214
+ dpop: key ? await exportDpopKey(key) : undefined,
160
215
  });
161
216
  return new Response(null, {
162
217
  status: 302,
@@ -177,10 +232,9 @@ export function oauthRoutes(config) {
177
232
  const current = await session();
178
233
  if (!current?.refreshToken)
179
234
  return json({ error: 'no_session' }, 401);
180
- const payload = await tokenRequest({
181
- grant_type: 'refresh_token',
182
- refresh_token: current.refreshToken,
183
- });
235
+ // The key that obtained the refresh token, or the provider refuses the refresh.
236
+ const key = current.dpop ? await importDpopKey(current.dpop) : null;
237
+ const payload = await tokenRequest({ grant_type: 'refresh_token', refresh_token: current.refreshToken }, key);
184
238
  const refreshed = await cookie.write({
185
239
  accessToken: String(payload.access_token ?? ''),
186
240
  // A provider that rotates refresh tokens sends a new one; one that does not
@@ -190,6 +244,8 @@ export function oauthRoutes(config) {
190
244
  : current.refreshToken,
191
245
  idToken: current.idToken,
192
246
  expiresAt: expiryFrom(payload.expires_in),
247
+ tokenType: typeof payload.token_type === 'string' ? payload.token_type : current.tokenType,
248
+ dpop: current.dpop,
193
249
  });
194
250
  return json({ ok: true }, 200, { 'set-cookie': refreshed });
195
251
  }
@@ -200,9 +256,7 @@ export function oauthRoutes(config) {
200
256
  const { userinfo } = await resolve();
201
257
  if (!userinfo)
202
258
  return json({ error: 'no_userinfo' }, 501);
203
- const response = await fetch(userinfo, {
204
- headers: { authorization: `Bearer ${current.accessToken}` },
205
- });
259
+ const response = await withToken(userinfo, current);
206
260
  if (!response.ok)
207
261
  return json({ error: 'userinfo_failed' }, response.status);
208
262
  const claims = (await response.json());
@@ -338,9 +392,7 @@ export function sessionLocals(config) {
338
392
  try {
339
393
  const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
340
394
  if (endpoints?.userinfo) {
341
- const response = await fetch(endpoints.userinfo, {
342
- headers: { authorization: `Bearer ${session.accessToken}` },
343
- });
395
+ const response = await withToken(endpoints.userinfo, session);
344
396
  if (response.ok) {
345
397
  const claims = (await response.json());
346
398
  user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
@@ -358,3 +410,13 @@ export function sessionLocals(config) {
358
410
  return next();
359
411
  };
360
412
  }
413
+ /**
414
+ * The server half of `authModule`, listed in `src/app.config.server.ts`: the routes and the
415
+ * session middleware that `tokens: 'server'` needs, in the order they have to run.
416
+ *
417
+ * `oauthRoutes` first, so a request to the login route is answered rather than having its
418
+ * session resolved on the way past.
419
+ */
420
+ export function authServerModule(config) {
421
+ return defineModule({ name: 'oauth2-server', middleware: [oauthRoutes(config), sessionLocals(config)] });
422
+ }