@fluixi/oauth2 1.0.0-alpha.3 → 1.0.0-alpha.5

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.
@@ -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;AACxD,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;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;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,iBAAiB,GAAG,SAAS,CAErE"}
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,7 +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';
37
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
+ }
38
77
  /**
39
78
  * The middleware.
40
79
  *
@@ -73,14 +112,23 @@ export function oauthRoutes(config) {
73
112
  keyId: config.keyId,
74
113
  assertionAlgorithm: config.assertionAlgorithm,
75
114
  };
76
- async function tokenRequest(body) {
115
+ async function tokenRequest(body, key = null) {
77
116
  const { token } = await resolve();
78
- const headers = {
79
- '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() });
80
128
  };
81
- const form = new URLSearchParams(body);
82
- await applyClientAuth(clientAuth, token, headers, form);
83
- 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();
84
132
  const payload = (await response.json().catch(() => ({})));
85
133
  if (!response.ok) {
86
134
  throw new OAuthError(String(payload.error ?? 'token_request_failed'), String(payload.error ?? 'token_request_failed'));
@@ -147,17 +195,23 @@ export function oauthRoutes(config) {
147
195
  if (!pending || !state || !timingSafeEqual(pending.state, state)) {
148
196
  return json({ error: 'state_mismatch' }, 400);
149
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;
150
202
  const payload = await tokenRequest({
151
203
  grant_type: 'authorization_code',
152
204
  code,
153
205
  redirect_uri: config.redirectUri,
154
206
  code_verifier: pending.verifier,
155
- });
207
+ }, key);
156
208
  const signedIn = await cookie.write({
157
209
  accessToken: String(payload.access_token ?? ''),
158
210
  refreshToken: typeof payload.refresh_token === 'string' ? payload.refresh_token : undefined,
159
211
  idToken: typeof payload.id_token === 'string' ? payload.id_token : undefined,
160
212
  expiresAt: expiryFrom(payload.expires_in),
213
+ tokenType: typeof payload.token_type === 'string' ? payload.token_type : undefined,
214
+ dpop: key ? await exportDpopKey(key) : undefined,
161
215
  });
162
216
  return new Response(null, {
163
217
  status: 302,
@@ -178,10 +232,9 @@ export function oauthRoutes(config) {
178
232
  const current = await session();
179
233
  if (!current?.refreshToken)
180
234
  return json({ error: 'no_session' }, 401);
181
- const payload = await tokenRequest({
182
- grant_type: 'refresh_token',
183
- refresh_token: current.refreshToken,
184
- });
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);
185
238
  const refreshed = await cookie.write({
186
239
  accessToken: String(payload.access_token ?? ''),
187
240
  // A provider that rotates refresh tokens sends a new one; one that does not
@@ -191,6 +244,8 @@ export function oauthRoutes(config) {
191
244
  : current.refreshToken,
192
245
  idToken: current.idToken,
193
246
  expiresAt: expiryFrom(payload.expires_in),
247
+ tokenType: typeof payload.token_type === 'string' ? payload.token_type : current.tokenType,
248
+ dpop: current.dpop,
194
249
  });
195
250
  return json({ ok: true }, 200, { 'set-cookie': refreshed });
196
251
  }
@@ -201,9 +256,7 @@ export function oauthRoutes(config) {
201
256
  const { userinfo } = await resolve();
202
257
  if (!userinfo)
203
258
  return json({ error: 'no_userinfo' }, 501);
204
- const response = await fetch(userinfo, {
205
- headers: { authorization: `Bearer ${current.accessToken}` },
206
- });
259
+ const response = await withToken(userinfo, current);
207
260
  if (!response.ok)
208
261
  return json({ error: 'userinfo_failed' }, response.status);
209
262
  const claims = (await response.json());
@@ -339,9 +392,7 @@ export function sessionLocals(config) {
339
392
  try {
340
393
  const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
341
394
  if (endpoints?.userinfo) {
342
- const response = await fetch(endpoints.userinfo, {
343
- headers: { authorization: `Bearer ${session.accessToken}` },
344
- });
395
+ const response = await withToken(endpoints.userinfo, session);
345
396
  if (response.ok) {
346
397
  const claims = (await response.json());
347
398
  user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
package/dist/server.mjs CHANGED
@@ -1,5 +1,5 @@
1
- /*! @fluixi/oauth2 v1.0.0-alpha.3 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
- import "./chunk-4GSQS5YG.mjs";
1
+ /*! @fluixi/oauth2 v1.0.0-alpha.5 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
+ import "./chunk-DCBOCQLJ.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-QQDMUXPB.mjs");
56
+ return await import("./browser-QTUJRR3J.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,8 +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-QTUJRR3J.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
177
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
+ }
178
276
  function oauthRoutes(config) {
179
277
  const base = (config.basePath ?? "/auth").replace(/\/$/, "");
180
278
  const scopes = config.scopes ?? ["openid", "profile", "email"];
@@ -206,14 +304,19 @@ function oauthRoutes(config) {
206
304
  keyId: config.keyId,
207
305
  assertionAlgorithm: config.assertionAlgorithm
208
306
  };
209
- async function tokenRequest(body) {
307
+ async function tokenRequest(body, key = null) {
210
308
  const { token } = await resolve();
211
- const headers = {
212
- "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() });
213
317
  };
214
- const form = new URLSearchParams(body);
215
- await applyClientAuth(clientAuth, token, headers, form);
216
- 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();
217
320
  const payload = await response.json().catch(() => ({}));
218
321
  if (!response.ok) {
219
322
  throw new OAuthError(
@@ -280,17 +383,23 @@ function oauthRoutes(config) {
280
383
  if (!pending || !state || !timingSafeEqual(pending.state, state)) {
281
384
  return json({ error: "state_mismatch" }, 400);
282
385
  }
283
- const payload = await tokenRequest({
284
- grant_type: "authorization_code",
285
- code,
286
- redirect_uri: config.redirectUri,
287
- code_verifier: pending.verifier
288
- });
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
+ );
289
396
  const signedIn = await cookie.write({
290
397
  accessToken: String(payload.access_token ?? ""),
291
398
  refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : void 0,
292
399
  idToken: typeof payload.id_token === "string" ? payload.id_token : void 0,
293
- 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
294
403
  });
295
404
  return new Response(null, {
296
405
  status: 302,
@@ -310,17 +419,20 @@ function oauthRoutes(config) {
310
419
  if (route === "/refresh") {
311
420
  const current = await session();
312
421
  if (!current?.refreshToken) return json({ error: "no_session" }, 401);
313
- const payload = await tokenRequest({
314
- grant_type: "refresh_token",
315
- refresh_token: current.refreshToken
316
- });
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
+ );
317
427
  const refreshed = await cookie.write({
318
428
  accessToken: String(payload.access_token ?? ""),
319
429
  // A provider that rotates refresh tokens sends a new one; one that does not
320
430
  // expects the old one to keep working.
321
431
  refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : current.refreshToken,
322
432
  idToken: current.idToken,
323
- 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
324
436
  });
325
437
  return json({ ok: true }, 200, { "set-cookie": refreshed });
326
438
  }
@@ -329,9 +441,7 @@ function oauthRoutes(config) {
329
441
  if (!current?.accessToken) return json({ error: "no_session" }, 401);
330
442
  const { userinfo } = await resolve();
331
443
  if (!userinfo) return json({ error: "no_userinfo" }, 501);
332
- const response = await fetch(userinfo, {
333
- headers: { authorization: `Bearer ${current.accessToken}` }
334
- });
444
+ const response = await withToken(userinfo, current);
335
445
  if (!response.ok) return json({ error: "userinfo_failed" }, response.status);
336
446
  const claims = await response.json();
337
447
  return json(config.mapUser ? config.mapUser(claims, current.accessToken) : claims);
@@ -421,9 +531,7 @@ function sessionLocals(config) {
421
531
  try {
422
532
  const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
423
533
  if (endpoints?.userinfo) {
424
- const response = await fetch(endpoints.userinfo, {
425
- headers: { authorization: `Bearer ${session.accessToken}` }
426
- });
534
+ const response = await withToken(endpoints.userinfo, session);
427
535
  if (response.ok) {
428
536
  const claims = await response.json();
429
537
  user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
@@ -1,4 +1,4 @@
1
- /*! @fluixi/oauth2 v1.0.0-alpha.3 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
1
+ /*! @fluixi/oauth2 v1.0.0-alpha.5 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
2
  "use strict";
3
3
  var __create = Object.create;
4
4
  var __defProp = Object.defineProperty;
@@ -1,5 +1,5 @@
1
- /*! @fluixi/oauth2 v1.0.0-alpha.3 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
- import "./chunk-4GSQS5YG.mjs";
1
+ /*! @fluixi/oauth2 v1.0.0-alpha.5 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
2
+ import "./chunk-DCBOCQLJ.mjs";
3
3
 
4
4
  // src/types.ts
5
5
  var OAuthError = class extends Error {
@@ -42,7 +42,7 @@ function assertUsable(auth, who) {
42
42
  }
43
43
  async function loadJose() {
44
44
  try {
45
- return await import("./browser-QQDMUXPB.mjs");
45
+ return await import("./browser-QTUJRR3J.mjs");
46
46
  } catch {
47
47
  throw new OAuthError(
48
48
  "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.",
package/dist/service.cjs CHANGED
@@ -191,9 +191,11 @@ async function loadJose2() {
191
191
  );
192
192
  }
193
193
  }
194
- async function createDpopKey(alg = "ES256") {
194
+ async function createDpopKey(alg = "ES256", options = {}) {
195
195
  const jose = await loadJose2();
196
- const { privateKey, publicKey } = await jose.generateKeyPair(alg, { extractable: false });
196
+ const { privateKey, publicKey } = await jose.generateKeyPair(alg, {
197
+ extractable: options.extractable ?? false
198
+ });
197
199
  const publicJwk = { ...await jose.exportJWK(publicKey) };
198
200
  return {
199
201
  privateKey,
package/dist/service.d.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * else lines up directly: `fetchUser`, `refresh`, `logout` and `getExpiry`.
14
14
  */
15
15
  import { type AccessControl, type Session } from '@fluixi/session';
16
- import { createAuthHooks } from '@fluixi/auth/client';
16
+ import { createAuthHooks, type AuthHooksOptions } from '@fluixi/auth/client';
17
17
  import type { AuthClient, AuthConfig, CallbackParams, LoginOptions } from './types.js';
18
18
  /** The credential a redirect flow produces. */
19
19
  export type AuthCredentials = CallbackParams;
@@ -55,9 +55,7 @@ export interface AuthServiceOptions<U, P = unknown, Q = (p: P) => boolean> {
55
55
  * client-side navigation; leave it out and it assigns `location`, which reloads the
56
56
  * document and discards the reactive tree.
57
57
  */
58
- navigate?: {
59
- handler: () => (to: string, options?: unknown) => void;
60
- };
58
+ navigate?: AuthHooksOptions['navigate'];
61
59
  /** Where a signed-out visitor is sent. Default `/login`. */
62
60
  loginPath?: string;
63
61
  /** Where a signed-in visitor is sent from a guest-only page. Default `/`. */
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"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"}
package/dist/service.mjs CHANGED
@@ -158,9 +158,11 @@ async function loadJose2() {
158
158
  );
159
159
  }
160
160
  }
161
- async function createDpopKey(alg = "ES256") {
161
+ async function createDpopKey(alg = "ES256", options = {}) {
162
162
  const jose = await loadJose2();
163
- const { privateKey, publicKey } = await jose.generateKeyPair(alg, { extractable: false });
163
+ const { privateKey, publicKey } = await jose.generateKeyPair(alg, {
164
+ extractable: options.extractable ?? false
165
+ });
164
166
  const publicJwk = { ...await jose.exportJWK(publicKey) };
165
167
  return {
166
168
  privateKey,