@fluixi/oauth2 1.0.0-alpha.2 → 1.0.0-alpha.4

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.
package/dist/server.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! @fluixi/oauth2 v1.0.0-alpha.2 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
1
+ /*! @fluixi/oauth2 v1.0.0-alpha.4 | (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
+ }
package/dist/server.mjs CHANGED
@@ -1,5 +1,5 @@
1
- /*! @fluixi/oauth2 v1.0.0-alpha.2 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
- import "./chunk-ZVQNMEAR.mjs";
1
+ /*! @fluixi/oauth2 v1.0.0-alpha.4 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
+ import "./chunk-5NSLG6E3.mjs";
3
3
 
4
4
  // src/server.ts
5
5
  import { createSessionCookie } from "@fluixi/session/server";
@@ -53,7 +53,7 @@ function resolveMethod(auth) {
53
53
  }
54
54
  async function loadJose() {
55
55
  try {
56
- return await import("./browser-WDGFIB5Z.mjs");
56
+ return await import("./browser-ZDO2EHPH.mjs");
57
57
  } catch {
58
58
  throw new OAuthError(
59
59
  "private_key_jwt needs `jose`. Install it: npm i jose. It is an optional peer, so a client using a secret does not carry it.",
@@ -173,7 +173,106 @@ function expiryFrom(expiresIn, now = Date.now()) {
173
173
  return Number.isFinite(seconds) && seconds > 0 ? now + seconds * 1e3 : void 0;
174
174
  }
175
175
 
176
+ // src/dpop.ts
177
+ async function loadJose2() {
178
+ try {
179
+ return await import("./browser-ZDO2EHPH.mjs");
180
+ } catch {
181
+ throw new OAuthError(
182
+ "DPoP needs `jose`. Install it: npm i jose. It is an optional peer, so a client not using DPoP does not carry it.",
183
+ "jose_missing"
184
+ );
185
+ }
186
+ }
187
+ async function createDpopKey(alg = "ES256", options = {}) {
188
+ const jose = await loadJose2();
189
+ const { privateKey, publicKey } = await jose.generateKeyPair(alg, {
190
+ extractable: options.extractable ?? false
191
+ });
192
+ const publicJwk = { ...await jose.exportJWK(publicKey) };
193
+ return {
194
+ privateKey,
195
+ publicJwk,
196
+ thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
197
+ alg
198
+ };
199
+ }
200
+ async function exportDpopKey(key) {
201
+ const jose = await loadJose2();
202
+ return { jwk: { ...await jose.exportJWK(key.privateKey) }, alg: key.alg };
203
+ }
204
+ var PRIVATE_MEMBERS = /* @__PURE__ */ new Set(["d", "p", "q", "dp", "dq", "qi", "oth"]);
205
+ async function importDpopKey(stored) {
206
+ const jose = await loadJose2();
207
+ const privateKey = await jose.importJWK(stored.jwk, stored.alg);
208
+ const publicJwk = Object.fromEntries(Object.entries(stored.jwk).filter(([k]) => !PRIVATE_MEMBERS.has(k)));
209
+ return {
210
+ privateKey,
211
+ publicJwk,
212
+ thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
213
+ alg: stored.alg
214
+ };
215
+ }
216
+ async function proofFor(key, options) {
217
+ const jose = await loadJose2();
218
+ const htu = (() => {
219
+ try {
220
+ const url = new URL(options.url);
221
+ url.search = "";
222
+ url.hash = "";
223
+ return url.toString();
224
+ } catch {
225
+ return options.url;
226
+ }
227
+ })();
228
+ const claims = {
229
+ htm: options.method.toUpperCase(),
230
+ htu,
231
+ jti: crypto.randomUUID()
232
+ };
233
+ if (options.accessToken) {
234
+ const digest = await crypto.subtle.digest(
235
+ "SHA-256",
236
+ new TextEncoder().encode(options.accessToken)
237
+ );
238
+ let binary = "";
239
+ for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
240
+ claims.ath = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
241
+ }
242
+ if (options.nonce) claims.nonce = options.nonce;
243
+ return new jose.SignJWT(claims).setProtectedHeader({ typ: "dpop+jwt", alg: key.alg, jwk: key.publicJwk }).setIssuedAt().sign(key.privateKey);
244
+ }
245
+ function nonceFrom(response) {
246
+ return response.headers.get("dpop-nonce");
247
+ }
248
+
176
249
  // src/server.ts
250
+ import { defineModule } from "@fluixi/start/app";
251
+ var dpopNonces = /* @__PURE__ */ new Map();
252
+ async function prove(headers, key, method, url, accessToken) {
253
+ headers.dpop = await proofFor(key, { method, url, accessToken, nonce: dpopNonces.get(new URL(url).origin) });
254
+ }
255
+ function learnNonce(url, response) {
256
+ const offered = nonceFrom(response);
257
+ if (!offered) return false;
258
+ const origin = new URL(url).origin;
259
+ const fresh = offered !== dpopNonces.get(origin);
260
+ dpopNonces.set(origin, offered);
261
+ return fresh && !response.ok;
262
+ }
263
+ async function withToken(url, session) {
264
+ const key = session.dpop && session.tokenType?.toLowerCase() === "dpop" ? await importDpopKey(session.dpop) : null;
265
+ const send = async () => {
266
+ const headers = {
267
+ authorization: `${key ? "DPoP" : "Bearer"} ${session.accessToken}`
268
+ };
269
+ if (key) await prove(headers, key, "GET", url, session.accessToken);
270
+ return fetch(url, { headers });
271
+ };
272
+ let response = await send();
273
+ if (key && learnNonce(url, response)) response = await send();
274
+ return response;
275
+ }
177
276
  function oauthRoutes(config) {
178
277
  const base = (config.basePath ?? "/auth").replace(/\/$/, "");
179
278
  const scopes = config.scopes ?? ["openid", "profile", "email"];
@@ -205,14 +304,19 @@ function oauthRoutes(config) {
205
304
  keyId: config.keyId,
206
305
  assertionAlgorithm: config.assertionAlgorithm
207
306
  };
208
- async function tokenRequest(body) {
307
+ async function tokenRequest(body, key = null) {
209
308
  const { token } = await resolve();
210
- const headers = {
211
- "content-type": "application/x-www-form-urlencoded"
309
+ const attempt = async () => {
310
+ const headers = {
311
+ "content-type": "application/x-www-form-urlencoded"
312
+ };
313
+ const form = new URLSearchParams(body);
314
+ await applyClientAuth(clientAuth, token, headers, form);
315
+ if (key) await prove(headers, key, "POST", token);
316
+ return fetch(token, { method: "POST", headers, body: form.toString() });
212
317
  };
213
- const form = new URLSearchParams(body);
214
- await applyClientAuth(clientAuth, token, headers, form);
215
- const response = await fetch(token, { method: "POST", headers, body: form.toString() });
318
+ let response = await attempt();
319
+ if (key && learnNonce(token, response)) response = await attempt();
216
320
  const payload = await response.json().catch(() => ({}));
217
321
  if (!response.ok) {
218
322
  throw new OAuthError(
@@ -279,17 +383,23 @@ function oauthRoutes(config) {
279
383
  if (!pending || !state || !timingSafeEqual(pending.state, state)) {
280
384
  return json({ error: "state_mismatch" }, 400);
281
385
  }
282
- const payload = await tokenRequest({
283
- grant_type: "authorization_code",
284
- code,
285
- redirect_uri: config.redirectUri,
286
- code_verifier: pending.verifier
287
- });
386
+ const key = config.dpop ? await createDpopKey(config.dpopAlgorithm, { extractable: true }) : null;
387
+ const payload = await tokenRequest(
388
+ {
389
+ grant_type: "authorization_code",
390
+ code,
391
+ redirect_uri: config.redirectUri,
392
+ code_verifier: pending.verifier
393
+ },
394
+ key
395
+ );
288
396
  const signedIn = await cookie.write({
289
397
  accessToken: String(payload.access_token ?? ""),
290
398
  refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : void 0,
291
399
  idToken: typeof payload.id_token === "string" ? payload.id_token : void 0,
292
- expiresAt: expiryFrom(payload.expires_in)
400
+ expiresAt: expiryFrom(payload.expires_in),
401
+ tokenType: typeof payload.token_type === "string" ? payload.token_type : void 0,
402
+ dpop: key ? await exportDpopKey(key) : void 0
293
403
  });
294
404
  return new Response(null, {
295
405
  status: 302,
@@ -309,17 +419,20 @@ function oauthRoutes(config) {
309
419
  if (route === "/refresh") {
310
420
  const current = await session();
311
421
  if (!current?.refreshToken) return json({ error: "no_session" }, 401);
312
- const payload = await tokenRequest({
313
- grant_type: "refresh_token",
314
- refresh_token: current.refreshToken
315
- });
422
+ const key = current.dpop ? await importDpopKey(current.dpop) : null;
423
+ const payload = await tokenRequest(
424
+ { grant_type: "refresh_token", refresh_token: current.refreshToken },
425
+ key
426
+ );
316
427
  const refreshed = await cookie.write({
317
428
  accessToken: String(payload.access_token ?? ""),
318
429
  // A provider that rotates refresh tokens sends a new one; one that does not
319
430
  // expects the old one to keep working.
320
431
  refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : current.refreshToken,
321
432
  idToken: current.idToken,
322
- expiresAt: expiryFrom(payload.expires_in)
433
+ expiresAt: expiryFrom(payload.expires_in),
434
+ tokenType: typeof payload.token_type === "string" ? payload.token_type : current.tokenType,
435
+ dpop: current.dpop
323
436
  });
324
437
  return json({ ok: true }, 200, { "set-cookie": refreshed });
325
438
  }
@@ -328,9 +441,7 @@ function oauthRoutes(config) {
328
441
  if (!current?.accessToken) return json({ error: "no_session" }, 401);
329
442
  const { userinfo } = await resolve();
330
443
  if (!userinfo) return json({ error: "no_userinfo" }, 501);
331
- const response = await fetch(userinfo, {
332
- headers: { authorization: `Bearer ${current.accessToken}` }
333
- });
444
+ const response = await withToken(userinfo, current);
334
445
  if (!response.ok) return json({ error: "userinfo_failed" }, response.status);
335
446
  const claims = await response.json();
336
447
  return json(config.mapUser ? config.mapUser(claims, current.accessToken) : claims);
@@ -420,9 +531,7 @@ function sessionLocals(config) {
420
531
  try {
421
532
  const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
422
533
  if (endpoints?.userinfo) {
423
- const response = await fetch(endpoints.userinfo, {
424
- headers: { authorization: `Bearer ${session.accessToken}` }
425
- });
534
+ const response = await withToken(endpoints.userinfo, session);
426
535
  if (response.ok) {
427
536
  const claims = await response.json();
428
537
  user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
@@ -435,7 +544,11 @@ function sessionLocals(config) {
435
544
  return next();
436
545
  };
437
546
  }
547
+ function authServerModule(config) {
548
+ return defineModule({ name: "oauth2-server", middleware: [oauthRoutes(config), sessionLocals(config)] });
549
+ }
438
550
  export {
551
+ authServerModule,
439
552
  oauthRoutes,
440
553
  sessionLocals
441
554
  };