@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/{browser-WDGFIB5Z.mjs → browser-ZDO2EHPH.mjs} +2 -2
- package/dist/browser.cjs +4 -2
- package/dist/browser.mjs +4 -2
- package/dist/{chunk-ZVQNMEAR.mjs → chunk-5NSLG6E3.mjs} +1 -1
- package/dist/{chunk-C6SXP47D.mjs → chunk-UEAYG4TW.mjs} +1 -1
- package/dist/{dpop-DLCJBCQN.mjs → dpop-CU42DUAJ.mjs} +26 -6
- package/dist/{dpop-NS7NKOWM.mjs → dpop-YPGCGL7S.mjs} +22 -2
- package/dist/dpop-store.cjs +4 -2
- package/dist/dpop-store.mjs +4 -2
- package/dist/dpop.cjs +22 -2
- package/dist/dpop.d.ts +18 -1
- package/dist/dpop.d.ts.map +1 -1
- package/dist/dpop.js +23 -2
- package/dist/dpop.mjs +22 -2
- package/dist/index.cjs +20 -3
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.mjs +23 -6
- package/dist/module.cjs +44 -0
- package/dist/module.d.ts +13 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +22 -0
- package/dist/module.mjs +23 -0
- package/dist/server.cjs +138 -25
- package/dist/server.d.ts +20 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +79 -17
- package/dist/server.mjs +140 -27
- package/dist/service-client.cjs +1 -1
- package/dist/service-client.mjs +3 -3
- package/dist/service.cjs +4 -2
- package/dist/service.mjs +4 -2
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/dist/verify.cjs +24 -3
- package/dist/verify.mjs +6 -6
- package/package.json +2 -2
package/dist/server.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! @fluixi/oauth2 v1.0.0-alpha.
|
|
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
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
346
|
-
|
|
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
|
|
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
|
|
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
|
package/dist/server.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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
|
|
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
|
-
import "./chunk-
|
|
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-
|
|
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
|
|
211
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
313
|
-
|
|
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
|
|
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
|
|
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
|
};
|