@fluixi/oauth2 1.0.0-alpha.1 → 1.0.0-alpha.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/dist/backchannel.cjs +462 -0
- package/dist/backchannel.d.ts +39 -0
- package/dist/backchannel.d.ts.map +1 -0
- package/dist/backchannel.js +97 -0
- package/dist/backchannel.mjs +307 -0
- package/dist/{browser-ONMHX3Z3.mjs → browser-TV25RBLT.mjs} +2 -2
- package/dist/browser.cjs +10 -3
- package/dist/browser.mjs +10 -3
- package/dist/callback.cjs +8 -7
- package/dist/callback.js +1 -1
- package/dist/callback.mjs +3 -2
- package/dist/{chunk-4NSOGPLM.mjs → chunk-2B3I72DV.mjs} +1 -1
- package/dist/chunk-KOP5Z4LB.mjs +108 -0
- package/dist/{chunk-25WE6UWI.mjs → chunk-KTGVSZE6.mjs} +1 -1
- package/dist/discovery.cjs +6 -1
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +6 -1
- package/dist/discovery.mjs +6 -1
- package/dist/dpop-O3HWR7AH.mjs +22 -0
- package/dist/{dpop-LVPWWADA.mjs → dpop-SGWG3K2I.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 +135 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.mjs +138 -13
- 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 +755 -80
- package/dist/server.d.ts +91 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +518 -65
- package/dist/server.mjs +638 -82
- package/dist/service-client.cjs +7 -2
- package/dist/service-client.mjs +9 -4
- package/dist/service.cjs +116 -7
- package/dist/service.d.ts +2 -18
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +48 -5
- package/dist/service.mjs +116 -7
- package/dist/session-monitor.cjs +98 -0
- package/dist/session-monitor.d.ts +32 -0
- package/dist/session-monitor.d.ts.map +1 -0
- package/dist/session-monitor.js +104 -0
- package/dist/session-monitor.mjs +77 -0
- package/dist/tokens.cjs +1 -1
- package/dist/tokens.d.ts +2 -2
- package/dist/tokens.d.ts.map +1 -1
- package/dist/tokens.js +1 -1
- package/dist/tokens.mjs +1 -1
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/dist/types.d.ts +12 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -12
- package/dist/verify.cjs +30 -4
- package/dist/verify.mjs +12 -7
- package/package.json +22 -16
package/dist/server.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! @fluixi/oauth2 v1.0.0-alpha.
|
|
1
|
+
/*! @fluixi/oauth2 v1.0.0-alpha.100 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
|
|
2
2
|
"use strict";
|
|
3
3
|
var __create = Object.create;
|
|
4
4
|
var __defProp = Object.defineProperty;
|
|
@@ -6,6 +6,9 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
6
6
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
7
|
var __getProtoOf = Object.getPrototypeOf;
|
|
8
8
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __esm = (fn, res) => function __init() {
|
|
10
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
11
|
+
};
|
|
9
12
|
var __export = (target, all) => {
|
|
10
13
|
for (var name in all)
|
|
11
14
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
@@ -28,10 +31,132 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
28
31
|
));
|
|
29
32
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
30
33
|
|
|
34
|
+
// src/types.ts
|
|
35
|
+
var OAuthError;
|
|
36
|
+
var init_types = __esm({
|
|
37
|
+
"src/types.ts"() {
|
|
38
|
+
"use strict";
|
|
39
|
+
OAuthError = class extends Error {
|
|
40
|
+
constructor(message, code, description) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.code = code;
|
|
43
|
+
this.description = description;
|
|
44
|
+
this.name = "OAuthError";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// src/dpop.ts
|
|
51
|
+
var dpop_exports = {};
|
|
52
|
+
__export(dpop_exports, {
|
|
53
|
+
boundKeyOf: () => boundKeyOf,
|
|
54
|
+
createDpopKey: () => createDpopKey,
|
|
55
|
+
exportDpopKey: () => exportDpopKey,
|
|
56
|
+
importDpopKey: () => importDpopKey,
|
|
57
|
+
nonceFrom: () => nonceFrom,
|
|
58
|
+
proofFor: () => proofFor,
|
|
59
|
+
thumbprintOf: () => thumbprintOf,
|
|
60
|
+
wantsNonce: () => wantsNonce
|
|
61
|
+
});
|
|
62
|
+
async function loadJose2() {
|
|
63
|
+
try {
|
|
64
|
+
return await import("jose");
|
|
65
|
+
} catch {
|
|
66
|
+
throw new OAuthError(
|
|
67
|
+
"DPoP needs `jose`. Install it: npm i jose. It is an optional peer, so a client not using DPoP does not carry it.",
|
|
68
|
+
"jose_missing"
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function createDpopKey(alg = "ES256", options = {}) {
|
|
73
|
+
const jose = await loadJose2();
|
|
74
|
+
const { privateKey, publicKey } = await jose.generateKeyPair(alg, {
|
|
75
|
+
extractable: options.extractable ?? false
|
|
76
|
+
});
|
|
77
|
+
const publicJwk = { ...await jose.exportJWK(publicKey) };
|
|
78
|
+
return {
|
|
79
|
+
privateKey,
|
|
80
|
+
publicJwk,
|
|
81
|
+
thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
|
|
82
|
+
alg
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async function exportDpopKey(key) {
|
|
86
|
+
const jose = await loadJose2();
|
|
87
|
+
return { jwk: { ...await jose.exportJWK(key.privateKey) }, alg: key.alg };
|
|
88
|
+
}
|
|
89
|
+
async function importDpopKey(stored) {
|
|
90
|
+
const jose = await loadJose2();
|
|
91
|
+
const privateKey = await jose.importJWK(stored.jwk, stored.alg);
|
|
92
|
+
const publicJwk = Object.fromEntries(Object.entries(stored.jwk).filter(([k]) => !PRIVATE_MEMBERS.has(k)));
|
|
93
|
+
return {
|
|
94
|
+
privateKey,
|
|
95
|
+
publicJwk,
|
|
96
|
+
thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
|
|
97
|
+
alg: stored.alg
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
async function thumbprintOf(jwk) {
|
|
101
|
+
const jose = await loadJose2();
|
|
102
|
+
return jose.calculateJwkThumbprint(jwk, "sha256");
|
|
103
|
+
}
|
|
104
|
+
async function proofFor(key, options) {
|
|
105
|
+
const jose = await loadJose2();
|
|
106
|
+
const htu = (() => {
|
|
107
|
+
try {
|
|
108
|
+
const url = new URL(options.url);
|
|
109
|
+
url.search = "";
|
|
110
|
+
url.hash = "";
|
|
111
|
+
return url.toString();
|
|
112
|
+
} catch {
|
|
113
|
+
return options.url;
|
|
114
|
+
}
|
|
115
|
+
})();
|
|
116
|
+
const claims = {
|
|
117
|
+
htm: options.method.toUpperCase(),
|
|
118
|
+
htu,
|
|
119
|
+
jti: crypto.randomUUID()
|
|
120
|
+
};
|
|
121
|
+
if (options.accessToken) {
|
|
122
|
+
const digest = await crypto.subtle.digest(
|
|
123
|
+
"SHA-256",
|
|
124
|
+
new TextEncoder().encode(options.accessToken)
|
|
125
|
+
);
|
|
126
|
+
let binary = "";
|
|
127
|
+
for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
|
|
128
|
+
claims.ath = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
129
|
+
}
|
|
130
|
+
if (options.nonce) claims.nonce = options.nonce;
|
|
131
|
+
return new jose.SignJWT(claims).setProtectedHeader({ typ: "dpop+jwt", alg: key.alg, jwk: key.publicJwk }).setIssuedAt().sign(key.privateKey);
|
|
132
|
+
}
|
|
133
|
+
function nonceFrom(response) {
|
|
134
|
+
return response.headers.get("dpop-nonce");
|
|
135
|
+
}
|
|
136
|
+
function wantsNonce(error, response) {
|
|
137
|
+
const code = typeof error === "string" ? error : error?.error;
|
|
138
|
+
return code === "use_dpop_nonce" || !!response && !!nonceFrom(response);
|
|
139
|
+
}
|
|
140
|
+
function boundKeyOf(claims) {
|
|
141
|
+
const cnf = claims.cnf;
|
|
142
|
+
return typeof cnf?.jkt === "string" ? cnf.jkt : null;
|
|
143
|
+
}
|
|
144
|
+
var PRIVATE_MEMBERS;
|
|
145
|
+
var init_dpop = __esm({
|
|
146
|
+
"src/dpop.ts"() {
|
|
147
|
+
"use strict";
|
|
148
|
+
init_types();
|
|
149
|
+
PRIVATE_MEMBERS = /* @__PURE__ */ new Set(["d", "p", "q", "dp", "dq", "qi", "oth"]);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
31
153
|
// src/server.ts
|
|
32
154
|
var server_exports = {};
|
|
33
155
|
__export(server_exports, {
|
|
156
|
+
authServerModule: () => authServerModule,
|
|
157
|
+
memoryLogouts: () => memoryLogouts,
|
|
34
158
|
oauthRoutes: () => oauthRoutes,
|
|
159
|
+
requestAuth: () => requestAuth,
|
|
35
160
|
sessionLocals: () => sessionLocals
|
|
36
161
|
});
|
|
37
162
|
module.exports = __toCommonJS(server_exports);
|
|
@@ -60,17 +185,8 @@ function safeReturnTo(candidate, options = {}) {
|
|
|
60
185
|
return url.toString();
|
|
61
186
|
}
|
|
62
187
|
|
|
63
|
-
// src/types.ts
|
|
64
|
-
var OAuthError = class extends Error {
|
|
65
|
-
constructor(message, code, description) {
|
|
66
|
-
super(message);
|
|
67
|
-
this.code = code;
|
|
68
|
-
this.description = description;
|
|
69
|
-
this.name = "OAuthError";
|
|
70
|
-
}
|
|
71
|
-
};
|
|
72
|
-
|
|
73
188
|
// src/client-auth.ts
|
|
189
|
+
init_types();
|
|
74
190
|
var CANONICAL = {
|
|
75
191
|
basic: "client_secret_basic",
|
|
76
192
|
body: "client_secret_post",
|
|
@@ -124,6 +240,7 @@ async function applyClientAuth(auth, endpoint, headers, form) {
|
|
|
124
240
|
}
|
|
125
241
|
|
|
126
242
|
// src/discovery.ts
|
|
243
|
+
init_types();
|
|
127
244
|
var cache = /* @__PURE__ */ new Map();
|
|
128
245
|
function documentUrl(issuer) {
|
|
129
246
|
return `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
|
|
@@ -136,8 +253,12 @@ async function discover(issuer, fetcher = fetch) {
|
|
|
136
253
|
try {
|
|
137
254
|
response = await fetcher(documentUrl(issuer));
|
|
138
255
|
} catch (cause) {
|
|
256
|
+
const why = cause instanceof Error ? cause.message : String(cause);
|
|
139
257
|
throw new OAuthError(
|
|
140
|
-
|
|
258
|
+
typeof window === "undefined" ? (
|
|
259
|
+
// On a server nothing blocks the read: the provider is down, or the address is wrong.
|
|
260
|
+
`Could not reach the provider at ${documentUrl(issuer)}: is it running, and is the issuer right? (${why})`
|
|
261
|
+
) : `Could not read the discovery document at ${documentUrl(issuer)}. In a browser this is usually the provider refusing a cross-origin read: give \`endpoints\` directly, or resolve them on a server. (${why})`,
|
|
141
262
|
"discovery_unreachable"
|
|
142
263
|
);
|
|
143
264
|
}
|
|
@@ -162,6 +283,7 @@ async function discover(issuer, fetcher = fetch) {
|
|
|
162
283
|
jwks: doc.jwks_uri,
|
|
163
284
|
introspection: doc.introspection_endpoint,
|
|
164
285
|
revocation: doc.revocation_endpoint,
|
|
286
|
+
checkSession: doc.check_session_iframe,
|
|
165
287
|
issuer: doc.issuer ?? issuer
|
|
166
288
|
};
|
|
167
289
|
})();
|
|
@@ -170,6 +292,252 @@ async function discover(issuer, fetcher = fetch) {
|
|
|
170
292
|
return pending;
|
|
171
293
|
}
|
|
172
294
|
|
|
295
|
+
// src/verify.ts
|
|
296
|
+
init_types();
|
|
297
|
+
var TokenError = class extends OAuthError {
|
|
298
|
+
constructor(message, code = "invalid_token") {
|
|
299
|
+
super(message, code);
|
|
300
|
+
this.name = "TokenError";
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
function bearerOf(request) {
|
|
304
|
+
const header = request.headers.get("authorization");
|
|
305
|
+
if (!header) return null;
|
|
306
|
+
const match = header.match(/^(?:Bearer|DPoP)\s+(.+)$/i);
|
|
307
|
+
return match ? match[1].trim() : null;
|
|
308
|
+
}
|
|
309
|
+
async function loadJose3() {
|
|
310
|
+
try {
|
|
311
|
+
return await import("jose");
|
|
312
|
+
} catch {
|
|
313
|
+
throw new OAuthError(
|
|
314
|
+
"Token verification needs `jose`. Install it: npm i jose. It is an optional peer so an app that does not verify does not carry it.",
|
|
315
|
+
"jose_missing"
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function createTokenVerifier(options) {
|
|
320
|
+
const issuers = (Array.isArray(options.issuer) ? options.issuer : [options.issuer]).filter(Boolean);
|
|
321
|
+
if (!issuers.length) {
|
|
322
|
+
throw new Error("[fluixi/oauth2] createTokenVerifier needs an issuer to accept tokens from.");
|
|
323
|
+
}
|
|
324
|
+
const discoveryIssuer = issuers[0];
|
|
325
|
+
if (options.audience === void 0) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
"[fluixi/oauth2] createTokenVerifier needs an audience, or `audience: false` to say this service really does accept a token minted for any client of the issuer."
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (options.audience === false && options.authorizedParty === void 0) {
|
|
331
|
+
console.warn(
|
|
332
|
+
"[fluixi/oauth2] This verifier checks neither `aud` nor `azp`, so a token minted for any client of the issuer is accepted. Set `authorizedParty` if that is not what you meant."
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
const algorithms = options.algorithms ?? ["RS256"];
|
|
336
|
+
if (algorithms.some((alg) => !alg || alg.toLowerCase() === "none")) {
|
|
337
|
+
throw new Error("[fluixi/oauth2] `none` is not a signing algorithm and cannot be accepted.");
|
|
338
|
+
}
|
|
339
|
+
let keys = null;
|
|
340
|
+
async function keySet() {
|
|
341
|
+
if (keys) return keys;
|
|
342
|
+
const jose = await loadJose3();
|
|
343
|
+
if (options.jwks) {
|
|
344
|
+
keys = jose.createLocalJWKSet(options.jwks);
|
|
345
|
+
return keys;
|
|
346
|
+
}
|
|
347
|
+
let uri = options.jwksUri;
|
|
348
|
+
if (!uri) {
|
|
349
|
+
const endpoints = await discover(discoveryIssuer);
|
|
350
|
+
uri = endpoints.jwks;
|
|
351
|
+
}
|
|
352
|
+
if (!uri) {
|
|
353
|
+
throw new OAuthError(
|
|
354
|
+
`${discoveryIssuer} published no jwks_uri. Give the verifier one directly.`,
|
|
355
|
+
"no_jwks"
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
keys = jose.createRemoteJWKSet(new URL(uri));
|
|
359
|
+
return keys;
|
|
360
|
+
}
|
|
361
|
+
const checkProof = async (claims, token, request) => {
|
|
362
|
+
const jose = await loadJose3();
|
|
363
|
+
const { boundKeyOf: boundKeyOf2, thumbprintOf: thumbprintOf2 } = await Promise.resolve().then(() => (init_dpop(), dpop_exports));
|
|
364
|
+
const bound = boundKeyOf2(claims);
|
|
365
|
+
if (!bound) {
|
|
366
|
+
throw new TokenError(
|
|
367
|
+
"This token is not bound to a key, and this service requires binding.",
|
|
368
|
+
"token_not_bound"
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
const proof = request.headers.get("dpop");
|
|
372
|
+
if (!proof) throw new TokenError("The request carried no DPoP proof.", "missing_dpop_proof");
|
|
373
|
+
let payload;
|
|
374
|
+
let header;
|
|
375
|
+
try {
|
|
376
|
+
const result = await jose.jwtVerify(proof, jose.EmbeddedJWK, { typ: "dpop+jwt" });
|
|
377
|
+
payload = result.payload;
|
|
378
|
+
header = result.protectedHeader;
|
|
379
|
+
} catch (cause) {
|
|
380
|
+
throw new TokenError(
|
|
381
|
+
`The DPoP proof did not verify: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
382
|
+
"invalid_dpop_proof"
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
const presented = await thumbprintOf2(header.jwk);
|
|
386
|
+
if (presented !== bound) {
|
|
387
|
+
throw new TokenError(
|
|
388
|
+
"The DPoP proof was signed with a different key from the one this token is bound to.",
|
|
389
|
+
"dpop_key_mismatch"
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
const url = new URL(request.url);
|
|
393
|
+
url.search = "";
|
|
394
|
+
url.hash = "";
|
|
395
|
+
if (payload.htm !== request.method.toUpperCase()) {
|
|
396
|
+
throw new TokenError("The DPoP proof was made for a different method.", "invalid_dpop_proof");
|
|
397
|
+
}
|
|
398
|
+
if (payload.htu !== url.toString()) {
|
|
399
|
+
throw new TokenError("The DPoP proof was made for a different URL.", "invalid_dpop_proof");
|
|
400
|
+
}
|
|
401
|
+
if (typeof payload.ath === "string") {
|
|
402
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
403
|
+
let binary = "";
|
|
404
|
+
for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
|
|
405
|
+
const expected = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
406
|
+
if (payload.ath !== expected) {
|
|
407
|
+
throw new TokenError("The DPoP proof was made for a different token.", "invalid_dpop_proof");
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const age = Math.abs(Date.now() / 1e3 - payload.iat);
|
|
411
|
+
if (!Number.isFinite(age) || age > (options.dpopProofAgeSec ?? 10)) {
|
|
412
|
+
throw new TokenError("The DPoP proof is too old to accept.", "invalid_dpop_proof");
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
const verify = async (token, request) => {
|
|
416
|
+
if (!token) throw new TokenError("No token to verify.", "no_token");
|
|
417
|
+
const jose = await loadJose3();
|
|
418
|
+
let payload;
|
|
419
|
+
try {
|
|
420
|
+
({ payload } = await jose.jwtVerify(token, await keySet(), {
|
|
421
|
+
// jose takes a list and accepts any of them, which is what a provider naming
|
|
422
|
+
// itself two ways needs.
|
|
423
|
+
issuer: issuers,
|
|
424
|
+
...options.audience === false ? {} : { audience: options.audience },
|
|
425
|
+
algorithms,
|
|
426
|
+
clockTolerance: options.clockToleranceSec ?? 5
|
|
427
|
+
}));
|
|
428
|
+
} catch (cause) {
|
|
429
|
+
throw new TokenError(
|
|
430
|
+
`The token was not accepted: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
if (options.authorizedParty !== void 0) {
|
|
434
|
+
const allowed = Array.isArray(options.authorizedParty) ? options.authorizedParty : [options.authorizedParty];
|
|
435
|
+
const issuedTo = payload.azp ?? payload.client_id;
|
|
436
|
+
if (typeof issuedTo !== "string" || !allowed.includes(issuedTo)) {
|
|
437
|
+
throw new TokenError("The token was issued to a different client.", "invalid_azp");
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (options.dpop) {
|
|
441
|
+
if (!request) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
"[fluixi/oauth2] `dpop: true` needs the request to read its proof. Use `verify.fromRequest(request)` rather than verifying a bare token."
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
await checkProof(payload, token, request);
|
|
447
|
+
}
|
|
448
|
+
if (options.require) {
|
|
449
|
+
const verdict = options.require(payload);
|
|
450
|
+
if (verdict !== true) {
|
|
451
|
+
throw new TokenError(
|
|
452
|
+
typeof verdict === "string" ? verdict : "The token did not meet this service's requirements.",
|
|
453
|
+
"insufficient_claims"
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return payload;
|
|
458
|
+
};
|
|
459
|
+
const verifier = verify;
|
|
460
|
+
verifier.bearerOf = bearerOf;
|
|
461
|
+
verifier.fromRequest = async (request) => {
|
|
462
|
+
const token = verifier.bearerOf(request);
|
|
463
|
+
if (!token) {
|
|
464
|
+
throw new TokenError("The request carried no bearer token.", "no_token");
|
|
465
|
+
}
|
|
466
|
+
return verify(token, request);
|
|
467
|
+
};
|
|
468
|
+
return verifier;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// src/backchannel.ts
|
|
472
|
+
var LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
473
|
+
function memoryLogouts(ttlMs = 14 * 24 * 36e5) {
|
|
474
|
+
const sids = /* @__PURE__ */ new Map();
|
|
475
|
+
const subs = /* @__PURE__ */ new Map();
|
|
476
|
+
const prune = (now) => {
|
|
477
|
+
for (const map of [sids, subs]) for (const [key, at] of map) if (now - at > ttlMs) map.delete(key);
|
|
478
|
+
};
|
|
479
|
+
return {
|
|
480
|
+
add(entry) {
|
|
481
|
+
prune(Date.now());
|
|
482
|
+
if (entry.sid) sids.set(entry.sid, entry.at);
|
|
483
|
+
else if (entry.sub) subs.set(entry.sub, Math.max(entry.at, subs.get(entry.sub) ?? 0));
|
|
484
|
+
},
|
|
485
|
+
ended(session) {
|
|
486
|
+
if (session.sid && sids.has(session.sid)) return true;
|
|
487
|
+
const all = session.sub ? subs.get(session.sub) : void 0;
|
|
488
|
+
return all !== void 0 && session.since <= all;
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
function idTokenClaims(idToken) {
|
|
493
|
+
if (!idToken) return {};
|
|
494
|
+
try {
|
|
495
|
+
const part = idToken.split(".")[1] ?? "";
|
|
496
|
+
const json = atob(part.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(part.length / 4) * 4, "="));
|
|
497
|
+
const claims = JSON.parse(json);
|
|
498
|
+
return {
|
|
499
|
+
sid: typeof claims.sid === "string" ? claims.sid : void 0,
|
|
500
|
+
sub: typeof claims.sub === "string" ? claims.sub : void 0,
|
|
501
|
+
iat: typeof claims.iat === "number" ? claims.iat : void 0
|
|
502
|
+
};
|
|
503
|
+
} catch {
|
|
504
|
+
return {};
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function headerOf(token) {
|
|
508
|
+
try {
|
|
509
|
+
const part = token.split(".")[0] ?? "";
|
|
510
|
+
return JSON.parse(atob(part.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(part.length / 4) * 4, "=")));
|
|
511
|
+
} catch {
|
|
512
|
+
return {};
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function logoutTokenVerifier(options) {
|
|
516
|
+
const verify = createTokenVerifier({
|
|
517
|
+
issuer: options.issuer,
|
|
518
|
+
audience: options.clientId,
|
|
519
|
+
jwksUri: options.jwksUri,
|
|
520
|
+
jwks: options.jwks,
|
|
521
|
+
require: (claims) => {
|
|
522
|
+
const events = claims.events;
|
|
523
|
+
if (!events || typeof events !== "object" || !(LOGOUT_EVENT in events)) return "no logout event";
|
|
524
|
+
if ("nonce" in claims) return "a logout token has no nonce";
|
|
525
|
+
if (typeof claims.sid !== "string" && typeof claims.sub !== "string") return "names no session and no subject";
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
return async (token) => {
|
|
530
|
+
const typ = headerOf(token).typ;
|
|
531
|
+
if (typ !== void 0 && typ !== "logout+jwt") throw new Error("not a logout token");
|
|
532
|
+
const claims = await verify(token);
|
|
533
|
+
return {
|
|
534
|
+
sid: typeof claims.sid === "string" ? claims.sid : void 0,
|
|
535
|
+
sub: typeof claims.sub === "string" ? claims.sub : void 0,
|
|
536
|
+
at: (typeof claims.iat === "number" ? claims.iat : Math.floor(Date.now() / 1e3)) * 1e3
|
|
537
|
+
};
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
173
541
|
// src/pkce.ts
|
|
174
542
|
function base64url(bytes) {
|
|
175
543
|
let binary = "";
|
|
@@ -207,14 +575,119 @@ function expiryFrom(expiresIn, now = Date.now()) {
|
|
|
207
575
|
}
|
|
208
576
|
|
|
209
577
|
// src/server.ts
|
|
210
|
-
|
|
578
|
+
init_types();
|
|
579
|
+
init_dpop();
|
|
580
|
+
var import_app = require("@fluixi/start/app");
|
|
581
|
+
var RETRY_MARK = ".retry";
|
|
582
|
+
var PROMPTS = /* @__PURE__ */ new Set(["none", "login", "consent", "select_account"]);
|
|
583
|
+
var SILENT_REFUSALS = /* @__PURE__ */ new Set([
|
|
584
|
+
"login_required",
|
|
585
|
+
"consent_required",
|
|
586
|
+
"interaction_required",
|
|
587
|
+
"account_selection_required"
|
|
588
|
+
]);
|
|
589
|
+
function checkedCookie(config, sessionState) {
|
|
590
|
+
const name = `${config.cookieName ?? "fx_session"}_checked`;
|
|
591
|
+
const secure = config.secureCookie ?? true;
|
|
592
|
+
const maxAge = Math.max(0, Math.floor(config.silentRecheck ?? 60));
|
|
593
|
+
const value = sessionState ?? "1";
|
|
594
|
+
return { name, header: `${name}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}` };
|
|
595
|
+
}
|
|
596
|
+
function sessionStateFrom(url) {
|
|
597
|
+
const value = url.searchParams.get("session_state");
|
|
598
|
+
return value && /^[\w-]{1,128}\.[\w-]{1,64}$/.test(value) ? value : void 0;
|
|
599
|
+
}
|
|
600
|
+
function isPageRequest(request) {
|
|
601
|
+
if (request.method !== "GET") return false;
|
|
602
|
+
const dest = request.headers.get("sec-fetch-dest");
|
|
603
|
+
if (dest) return dest === "document";
|
|
604
|
+
return (request.headers.get("accept") ?? "").includes("text/html");
|
|
605
|
+
}
|
|
606
|
+
function cookieValue(request, name) {
|
|
607
|
+
for (const part of (request.headers.get("cookie") ?? "").split(";")) {
|
|
608
|
+
const [key, ...rest] = part.trim().split("=");
|
|
609
|
+
if (key === name) return rest.join("=");
|
|
610
|
+
}
|
|
611
|
+
return void 0;
|
|
612
|
+
}
|
|
613
|
+
function hasCookie(request, name) {
|
|
614
|
+
return cookieValue(request, name) !== void 0;
|
|
615
|
+
}
|
|
616
|
+
function under(pathname, path) {
|
|
617
|
+
return pathname === path || pathname.startsWith(path.endsWith("/") ? path : `${path}/`);
|
|
618
|
+
}
|
|
619
|
+
function signInRedirect(request, config) {
|
|
620
|
+
if (!isPageRequest(request)) return null;
|
|
621
|
+
const url = new URL(request.url);
|
|
211
622
|
const base = (config.basePath ?? "/auth").replace(/\/$/, "");
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
623
|
+
const guarded = (config.protectedPaths ?? []).some((path) => under(url.pathname, path));
|
|
624
|
+
const mode = guarded ? "required" : config.signIn ?? "manual";
|
|
625
|
+
if (mode === "manual") return null;
|
|
626
|
+
if ([base, ...config.publicPaths ?? []].some((path) => under(url.pathname, path))) return null;
|
|
627
|
+
if (url.searchParams.has("auth_error")) return null;
|
|
628
|
+
if (mode === "silent" && hasCookie(request, checkedCookie(config).name)) return null;
|
|
629
|
+
const login = new URLSearchParams({ returnTo: url.pathname + url.search });
|
|
630
|
+
if (mode === "silent") login.set("prompt", "none");
|
|
631
|
+
return new Response(null, { status: 302, headers: { location: `${base}/login?${login}` } });
|
|
632
|
+
}
|
|
633
|
+
var dpopNonces = /* @__PURE__ */ new Map();
|
|
634
|
+
async function prove(headers, key, method, url, accessToken) {
|
|
635
|
+
headers.dpop = await proofFor(key, { method, url, accessToken, nonce: dpopNonces.get(new URL(url).origin) });
|
|
636
|
+
}
|
|
637
|
+
function learnNonce(url, response) {
|
|
638
|
+
const offered = nonceFrom(response);
|
|
639
|
+
if (!offered) return false;
|
|
640
|
+
const origin = new URL(url).origin;
|
|
641
|
+
const fresh = offered !== dpopNonces.get(origin);
|
|
642
|
+
dpopNonces.set(origin, offered);
|
|
643
|
+
return fresh && !response.ok;
|
|
644
|
+
}
|
|
645
|
+
async function sendAs(session, input, init = {}) {
|
|
646
|
+
const url = String(input);
|
|
647
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
648
|
+
const key = session.dpop && session.tokenType?.toLowerCase() === "dpop" ? await importDpopKey(session.dpop) : null;
|
|
649
|
+
const send = async () => {
|
|
650
|
+
const credentials = {
|
|
651
|
+
authorization: `${key ? "DPoP" : "Bearer"} ${session.accessToken}`
|
|
652
|
+
};
|
|
653
|
+
if (key) await prove(credentials, key, method, url, session.accessToken);
|
|
654
|
+
const headers = new Headers(init.headers);
|
|
655
|
+
for (const [name, value] of Object.entries(credentials)) headers.set(name, value);
|
|
656
|
+
return fetch(url, { ...init, method, headers });
|
|
657
|
+
};
|
|
658
|
+
let response = await send();
|
|
659
|
+
if (key && learnNonce(url, response)) response = await send();
|
|
660
|
+
return response;
|
|
661
|
+
}
|
|
662
|
+
var withToken = (url, session) => sendAs(session, url);
|
|
663
|
+
var ProviderUnavailable = class extends OAuthError {
|
|
664
|
+
constructor(retryAfterMs) {
|
|
665
|
+
super("The authorization server is unavailable.", "temporarily_unavailable");
|
|
666
|
+
this.retryAfterMs = retryAfterMs;
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
var AnswerLost = class extends OAuthError {
|
|
670
|
+
constructor(what) {
|
|
671
|
+
super(`The token request's answer was lost (${what}).`, "answer_lost");
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
var NOT_SENT = /* @__PURE__ */ new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ENETUNREACH", "EHOSTUNREACH", "UND_ERR_CONNECT_TIMEOUT"]);
|
|
675
|
+
var RETRY_DEFAULT_MS = 5e3;
|
|
676
|
+
var RETRY_MAX_MS = 3e5;
|
|
677
|
+
function retryAfter(response) {
|
|
678
|
+
const header = response.headers.get("retry-after");
|
|
679
|
+
if (!header) return RETRY_DEFAULT_MS;
|
|
680
|
+
const seconds = Number(header);
|
|
681
|
+
const ms = Number.isFinite(seconds) ? seconds * 1e3 : Date.parse(header) - Date.now();
|
|
682
|
+
return Number.isFinite(ms) ? Math.min(Math.max(ms, 1e3), RETRY_MAX_MS) : RETRY_DEFAULT_MS;
|
|
683
|
+
}
|
|
684
|
+
function unanswered(error) {
|
|
685
|
+
const code = error?.cause?.code;
|
|
686
|
+
if (code && NOT_SENT.has(code)) return new ProviderUnavailable(RETRY_DEFAULT_MS);
|
|
687
|
+
return new AnswerLost(code ?? (error instanceof Error ? error.name : "no answer"));
|
|
688
|
+
}
|
|
689
|
+
function tokenClient(config) {
|
|
690
|
+
const timeoutMs = config.tokenTimeoutMs ?? 1e4;
|
|
218
691
|
let endpoints = config.endpoints ?? null;
|
|
219
692
|
const resolve = async () => {
|
|
220
693
|
if (endpoints) return endpoints;
|
|
@@ -224,10 +697,6 @@ function oauthRoutes(config) {
|
|
|
224
697
|
endpoints = await discover(config.issuer);
|
|
225
698
|
return endpoints;
|
|
226
699
|
};
|
|
227
|
-
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
|
|
228
|
-
status,
|
|
229
|
-
headers: { "content-type": "application/json", ...headers }
|
|
230
|
-
});
|
|
231
700
|
const clientAuth = {
|
|
232
701
|
clientId: config.clientId,
|
|
233
702
|
// `client_secret_post` by default here, which is what this half has always sent, so
|
|
@@ -238,34 +707,151 @@ function oauthRoutes(config) {
|
|
|
238
707
|
keyId: config.keyId,
|
|
239
708
|
assertionAlgorithm: config.assertionAlgorithm
|
|
240
709
|
};
|
|
241
|
-
async function tokenRequest(body) {
|
|
710
|
+
async function tokenRequest(body, key = null) {
|
|
242
711
|
const { token } = await resolve();
|
|
243
|
-
const
|
|
244
|
-
|
|
712
|
+
const attempt = async () => {
|
|
713
|
+
const headers = {
|
|
714
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
715
|
+
};
|
|
716
|
+
const form = new URLSearchParams(body);
|
|
717
|
+
await applyClientAuth(clientAuth, token, headers, form);
|
|
718
|
+
if (key) await prove(headers, key, "POST", token);
|
|
719
|
+
return fetch(token, { method: "POST", headers, body: form.toString(), signal: AbortSignal.timeout(timeoutMs) });
|
|
245
720
|
};
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
721
|
+
let response;
|
|
722
|
+
let payload = null;
|
|
723
|
+
try {
|
|
724
|
+
response = await attempt();
|
|
725
|
+
if (key && learnNonce(token, response)) response = await attempt();
|
|
726
|
+
if (response.status === 429 || response.status >= 500) throw new ProviderUnavailable(retryAfter(response));
|
|
727
|
+
payload = await response.json().catch(() => null);
|
|
728
|
+
} catch (error) {
|
|
729
|
+
throw error instanceof OAuthError ? error : unanswered(error);
|
|
730
|
+
}
|
|
250
731
|
if (!response.ok) {
|
|
251
732
|
throw new OAuthError(
|
|
252
|
-
String(payload
|
|
253
|
-
String(payload
|
|
733
|
+
String(payload?.error ?? "token_request_failed"),
|
|
734
|
+
String(payload?.error ?? "token_request_failed")
|
|
254
735
|
);
|
|
255
736
|
}
|
|
737
|
+
if (typeof payload?.access_token !== "string" || !payload.access_token) {
|
|
738
|
+
throw new AnswerLost("a success without an access token");
|
|
739
|
+
}
|
|
256
740
|
return payload;
|
|
257
741
|
}
|
|
742
|
+
return { resolve, tokenRequest, clientAuth };
|
|
743
|
+
}
|
|
744
|
+
var renewals = /* @__PURE__ */ new Map();
|
|
745
|
+
var RENEWAL_REUSE_MS = 3e4;
|
|
746
|
+
var SPENT_MS = 5 * 6e4;
|
|
747
|
+
function renew(current, tokenRequest) {
|
|
748
|
+
const refreshToken = current.refreshToken;
|
|
749
|
+
const now = Date.now();
|
|
750
|
+
for (const [key, entry] of renewals) if (entry.until <= now) renewals.delete(key);
|
|
751
|
+
const known = renewals.get(refreshToken);
|
|
752
|
+
if (known) return known.result;
|
|
753
|
+
const result = (async () => {
|
|
754
|
+
const key = current.dpop ? await importDpopKey(current.dpop) : null;
|
|
755
|
+
const payload = await tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken }, key);
|
|
756
|
+
return {
|
|
757
|
+
accessToken: String(payload.access_token ?? ""),
|
|
758
|
+
// A provider that rotates refresh tokens sends a new one; one that does not
|
|
759
|
+
// expects the old one to keep working.
|
|
760
|
+
refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : refreshToken,
|
|
761
|
+
idToken: typeof payload.id_token === "string" ? payload.id_token : current.idToken,
|
|
762
|
+
expiresAt: expiryFrom(payload.expires_in),
|
|
763
|
+
tokenType: typeof payload.token_type === "string" ? payload.token_type : current.tokenType,
|
|
764
|
+
dpop: current.dpop,
|
|
765
|
+
sessionState: current.sessionState
|
|
766
|
+
};
|
|
767
|
+
})().catch((error) => {
|
|
768
|
+
if (error instanceof ProviderUnavailable) {
|
|
769
|
+
renewals.set(refreshToken, { until: Date.now() + error.retryAfterMs, result });
|
|
770
|
+
throw error;
|
|
771
|
+
}
|
|
772
|
+
if (error instanceof OAuthError) {
|
|
773
|
+
renewals.set(refreshToken, { until: Date.now() + SPENT_MS, result });
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
renewals.delete(refreshToken);
|
|
777
|
+
throw error;
|
|
778
|
+
});
|
|
779
|
+
renewals.set(refreshToken, { until: now + RENEWAL_REUSE_MS, result });
|
|
780
|
+
return result;
|
|
781
|
+
}
|
|
782
|
+
var EXPIRY_SKEW_MS = 3e4;
|
|
783
|
+
var expired = (session) => session.expiresAt !== void 0 && session.expiresAt - EXPIRY_SKEW_MS <= Date.now();
|
|
784
|
+
var defaultLogouts = /* @__PURE__ */ new WeakMap();
|
|
785
|
+
function logoutsOf(config) {
|
|
786
|
+
if (config.logouts) return config.logouts;
|
|
787
|
+
let store = defaultLogouts.get(config);
|
|
788
|
+
if (!store) defaultLogouts.set(config, store = memoryLogouts());
|
|
789
|
+
return store;
|
|
790
|
+
}
|
|
791
|
+
async function endedAtProvider(config, session) {
|
|
792
|
+
const id = idTokenClaims(session.idToken);
|
|
793
|
+
if (!id.sid && !id.sub) return false;
|
|
794
|
+
return logoutsOf(config).ended({ sid: id.sid, sub: id.sub, since: (id.iat ?? 0) * 1e3 });
|
|
795
|
+
}
|
|
796
|
+
function oauthRoutes(config) {
|
|
797
|
+
const { resolve, tokenRequest, clientAuth } = tokenClient(config);
|
|
798
|
+
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
|
|
799
|
+
status,
|
|
800
|
+
headers: { "content-type": "application/json", ...headers }
|
|
801
|
+
});
|
|
802
|
+
const base = (config.basePath ?? "/auth").replace(/\/$/, "");
|
|
803
|
+
const scopes = config.scopes ?? ["openid", "profile", "email"];
|
|
804
|
+
const cookie = (0, import_server.createSessionCookie)({
|
|
805
|
+
secret: config.cookieSecret,
|
|
806
|
+
name: config.cookieName,
|
|
807
|
+
secure: config.secureCookie
|
|
808
|
+
});
|
|
258
809
|
return async function middleware(request, next) {
|
|
259
810
|
const url = new URL(request.url);
|
|
260
811
|
if (!url.pathname.startsWith(`${base}/`)) return next();
|
|
261
812
|
const route = url.pathname.slice(base.length);
|
|
262
813
|
const session = async () => {
|
|
263
|
-
|
|
814
|
+
const current = await cookie.read(request);
|
|
815
|
+
return current && await endedAtProvider(config, current) ? null : current;
|
|
264
816
|
};
|
|
817
|
+
if (route === "/backchannel-logout") {
|
|
818
|
+
const headers = { "cache-control": "no-store" };
|
|
819
|
+
if (request.method !== "POST") return json({ error: "invalid_request" }, 405, headers);
|
|
820
|
+
try {
|
|
821
|
+
const form = await request.formData();
|
|
822
|
+
const token = form.get("logout_token");
|
|
823
|
+
if (typeof token !== "string" || !token) throw new Error("no logout_token");
|
|
824
|
+
const endpoints = await resolve();
|
|
825
|
+
const issuer = endpoints.issuer ?? config.issuer;
|
|
826
|
+
if (!issuer) throw new Error("no issuer");
|
|
827
|
+
if (!endpoints.jwks) throw new Error("no jwks_uri");
|
|
828
|
+
const jwks = await (await fetch(endpoints.jwks)).json();
|
|
829
|
+
const entry = await logoutTokenVerifier({ issuer, clientId: config.clientId, jwks })(token);
|
|
830
|
+
await logoutsOf(config).add(entry);
|
|
831
|
+
return new Response(null, { status: 200, headers });
|
|
832
|
+
} catch {
|
|
833
|
+
return json({ error: "invalid_request" }, 400, headers);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
265
836
|
if (route === "/login") {
|
|
266
|
-
|
|
837
|
+
let authorization;
|
|
838
|
+
try {
|
|
839
|
+
({ authorization } = await resolve());
|
|
840
|
+
} catch (error) {
|
|
841
|
+
if (!(error instanceof OAuthError) || error.code !== "discovery_unreachable") throw error;
|
|
842
|
+
const back = safeReturnTo(url.searchParams.get("returnTo"), {
|
|
843
|
+
fallback: config.defaultReturnTo ?? "/",
|
|
844
|
+
origin: url.origin,
|
|
845
|
+
allowedOrigins: config.allowedReturnOrigins
|
|
846
|
+
});
|
|
847
|
+
const target2 = new URL(back, url.origin);
|
|
848
|
+
target2.searchParams.set("auth_error", "provider_unreachable");
|
|
849
|
+
const location2 = target2.origin === url.origin ? target2.pathname + target2.search : target2.toString();
|
|
850
|
+
return new Response(null, { status: 302, headers: { location: location2 } });
|
|
851
|
+
}
|
|
267
852
|
const { verifier, challenge } = await createPkcePair();
|
|
268
|
-
const
|
|
853
|
+
const retry = url.searchParams.get("retry") === "1";
|
|
854
|
+
const state = createStateValue() + (retry ? RETRY_MARK : "");
|
|
269
855
|
const target = new URL(authorization);
|
|
270
856
|
target.searchParams.set("response_type", "code");
|
|
271
857
|
target.searchParams.set("client_id", config.clientId);
|
|
@@ -274,6 +860,8 @@ function oauthRoutes(config) {
|
|
|
274
860
|
target.searchParams.set("state", state);
|
|
275
861
|
target.searchParams.set("code_challenge", challenge);
|
|
276
862
|
target.searchParams.set("code_challenge_method", "S256");
|
|
863
|
+
const prompt = url.searchParams.get("prompt");
|
|
864
|
+
if (prompt && PROMPTS.has(prompt)) target.searchParams.set("prompt", prompt);
|
|
277
865
|
const pendingCookie = await cookie.write(
|
|
278
866
|
{
|
|
279
867
|
accessToken: "",
|
|
@@ -286,7 +874,8 @@ function oauthRoutes(config) {
|
|
|
286
874
|
fallback: "",
|
|
287
875
|
origin: url.origin,
|
|
288
876
|
allowedOrigins: config.allowedReturnOrigins
|
|
289
|
-
}) || void 0
|
|
877
|
+
}) || void 0,
|
|
878
|
+
silent: prompt === "none" || void 0
|
|
290
879
|
}
|
|
291
880
|
},
|
|
292
881
|
// Ten minutes: long enough to sign in, short enough that an abandoned attempt
|
|
@@ -305,24 +894,62 @@ function oauthRoutes(config) {
|
|
|
305
894
|
const current = await session();
|
|
306
895
|
const pending = current?.pending;
|
|
307
896
|
const error = url.searchParams.get("error");
|
|
308
|
-
if (error)
|
|
897
|
+
if (error) {
|
|
898
|
+
const ours = !!pending && timingSafeEqual(pending.state, url.searchParams.get("state") ?? "");
|
|
899
|
+
let location2 = safeReturnTo(ours ? pending.returnTo ?? config.defaultReturnTo : config.defaultReturnTo, {
|
|
900
|
+
fallback: "/",
|
|
901
|
+
origin: url.origin,
|
|
902
|
+
allowedOrigins: config.allowedReturnOrigins
|
|
903
|
+
});
|
|
904
|
+
const headers = new Headers();
|
|
905
|
+
headers.append("set-cookie", cookie.clear());
|
|
906
|
+
if (ours && pending.silent && SILENT_REFUSALS.has(error)) {
|
|
907
|
+
headers.append("set-cookie", checkedCookie(config, sessionStateFrom(url)).header);
|
|
908
|
+
} else {
|
|
909
|
+
const target = new URL(location2, url.origin);
|
|
910
|
+
target.searchParams.set("auth_error", error);
|
|
911
|
+
location2 = target.origin === url.origin ? target.pathname + target.search : target.toString();
|
|
912
|
+
}
|
|
913
|
+
headers.set("location", location2);
|
|
914
|
+
return new Response(null, { status: 302, headers });
|
|
915
|
+
}
|
|
309
916
|
const code = url.searchParams.get("code");
|
|
310
917
|
const state = url.searchParams.get("state");
|
|
311
918
|
if (!code) return json({ error: "no_code" }, 400);
|
|
312
919
|
if (!pending || !state || !timingSafeEqual(pending.state, state)) {
|
|
313
|
-
|
|
920
|
+
const home = safeReturnTo(config.defaultReturnTo, { fallback: "/", origin: url.origin, allowedOrigins: config.allowedReturnOrigins });
|
|
921
|
+
if (current?.accessToken) return new Response(null, { status: 302, headers: { location: home } });
|
|
922
|
+
if (!state?.endsWith(RETRY_MARK)) {
|
|
923
|
+
return new Response(null, { status: 302, headers: { location: `${base}/login?retry=1` } });
|
|
924
|
+
}
|
|
925
|
+
const target = new URL(home, url.origin);
|
|
926
|
+
target.searchParams.set("auth_error", "state_mismatch");
|
|
927
|
+
return new Response(null, {
|
|
928
|
+
status: 302,
|
|
929
|
+
headers: {
|
|
930
|
+
location: target.origin === url.origin ? target.pathname + target.search : target.toString(),
|
|
931
|
+
"set-cookie": cookie.clear()
|
|
932
|
+
}
|
|
933
|
+
});
|
|
314
934
|
}
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
935
|
+
const key = config.dpop ? await createDpopKey(config.dpopAlgorithm, { extractable: true }) : null;
|
|
936
|
+
const payload = await tokenRequest(
|
|
937
|
+
{
|
|
938
|
+
grant_type: "authorization_code",
|
|
939
|
+
code,
|
|
940
|
+
redirect_uri: config.redirectUri,
|
|
941
|
+
code_verifier: pending.verifier
|
|
942
|
+
},
|
|
943
|
+
key
|
|
944
|
+
);
|
|
321
945
|
const signedIn = await cookie.write({
|
|
322
946
|
accessToken: String(payload.access_token ?? ""),
|
|
323
947
|
refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : void 0,
|
|
324
948
|
idToken: typeof payload.id_token === "string" ? payload.id_token : void 0,
|
|
325
|
-
expiresAt: expiryFrom(payload.expires_in)
|
|
949
|
+
expiresAt: expiryFrom(payload.expires_in),
|
|
950
|
+
tokenType: typeof payload.token_type === "string" ? payload.token_type : void 0,
|
|
951
|
+
dpop: key ? await exportDpopKey(key) : void 0,
|
|
952
|
+
sessionState: sessionStateFrom(url)
|
|
326
953
|
});
|
|
327
954
|
return new Response(null, {
|
|
328
955
|
status: 302,
|
|
@@ -342,32 +969,27 @@ function oauthRoutes(config) {
|
|
|
342
969
|
if (route === "/refresh") {
|
|
343
970
|
const current = await session();
|
|
344
971
|
if (!current?.refreshToken) return json({ error: "no_session" }, 401);
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
});
|
|
349
|
-
const refreshed = await cookie.write({
|
|
350
|
-
accessToken: String(payload.access_token ?? ""),
|
|
351
|
-
// A provider that rotates refresh tokens sends a new one; one that does not
|
|
352
|
-
// expects the old one to keep working.
|
|
353
|
-
refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : current.refreshToken,
|
|
354
|
-
idToken: current.idToken,
|
|
355
|
-
expiresAt: expiryFrom(payload.expires_in)
|
|
356
|
-
});
|
|
357
|
-
return json({ ok: true }, 200, { "set-cookie": refreshed });
|
|
972
|
+
const renewed = await renew(current, tokenRequest);
|
|
973
|
+
if (!renewed) return json({ error: "refresh_refused" }, 401, { "set-cookie": cookie.clear() });
|
|
974
|
+
return json({ ok: true }, 200, { "set-cookie": await cookie.write(renewed) });
|
|
358
975
|
}
|
|
359
976
|
if (route === "/me") {
|
|
360
977
|
const current = await session();
|
|
361
978
|
if (!current?.accessToken) return json({ error: "no_session" }, 401);
|
|
362
979
|
const { userinfo } = await resolve();
|
|
363
980
|
if (!userinfo) return json({ error: "no_userinfo" }, 501);
|
|
364
|
-
const response = await
|
|
365
|
-
headers: { authorization: `Bearer ${current.accessToken}` }
|
|
366
|
-
});
|
|
981
|
+
const response = await withToken(userinfo, current);
|
|
367
982
|
if (!response.ok) return json({ error: "userinfo_failed" }, response.status);
|
|
368
983
|
const claims = await response.json();
|
|
369
984
|
return json(config.mapUser ? config.mapUser(claims, current.accessToken) : claims);
|
|
370
985
|
}
|
|
986
|
+
if (route === "/session-check") {
|
|
987
|
+
const { checkSession } = await resolve().catch(() => ({ checkSession: void 0 }));
|
|
988
|
+
const current = await session();
|
|
989
|
+
const checked = cookieValue(request, checkedCookie(config).name);
|
|
990
|
+
const state = current?.accessToken ? current.sessionState : checked !== "1" ? checked : void 0;
|
|
991
|
+
return json({ frame: checkSession ?? null, clientId: config.clientId, state: state ?? null });
|
|
992
|
+
}
|
|
371
993
|
if (route === "/revoke") {
|
|
372
994
|
const current = await session();
|
|
373
995
|
const hint = url.searchParams.get("hint") === "access_token" ? "access_token" : "refresh_token";
|
|
@@ -390,31 +1012,33 @@ function oauthRoutes(config) {
|
|
|
390
1012
|
const { endSession } = await resolve().catch(() => ({ endSession: void 0 }));
|
|
391
1013
|
const { revocation } = await resolve().catch(() => ({ revocation: void 0 }));
|
|
392
1014
|
if (revocation && current?.refreshToken) {
|
|
393
|
-
const
|
|
1015
|
+
const headers2 = {
|
|
394
1016
|
"content-type": "application/x-www-form-urlencoded"
|
|
395
1017
|
};
|
|
396
1018
|
const form = new URLSearchParams({
|
|
397
1019
|
token: current.refreshToken,
|
|
398
1020
|
token_type_hint: "refresh_token"
|
|
399
1021
|
});
|
|
400
|
-
await applyClientAuth(clientAuth, revocation,
|
|
401
|
-
await fetch(revocation, { method: "POST", headers, body: form.toString() }).catch(
|
|
1022
|
+
await applyClientAuth(clientAuth, revocation, headers2, form);
|
|
1023
|
+
await fetch(revocation, { method: "POST", headers: headers2, body: form.toString() }).catch(
|
|
402
1024
|
() => void 0
|
|
403
1025
|
);
|
|
404
1026
|
}
|
|
405
1027
|
const cleared = cookie.clear();
|
|
1028
|
+
const headers = new Headers();
|
|
1029
|
+
headers.append("set-cookie", cleared);
|
|
1030
|
+
headers.append("set-cookie", checkedCookie(config).header);
|
|
406
1031
|
if (endSession && current?.idToken) {
|
|
407
1032
|
const target = new URL(endSession);
|
|
408
1033
|
target.searchParams.set("id_token_hint", current.idToken);
|
|
409
1034
|
if (config.defaultReturnTo) {
|
|
410
1035
|
target.searchParams.set("post_logout_redirect_uri", config.defaultReturnTo);
|
|
411
1036
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
headers: { location: target.toString(), "set-cookie": cleared }
|
|
415
|
-
});
|
|
1037
|
+
headers.set("location", target.toString());
|
|
1038
|
+
return new Response(null, { status: 302, headers });
|
|
416
1039
|
}
|
|
417
|
-
|
|
1040
|
+
headers.set("content-type", "application/json");
|
|
1041
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
|
|
418
1042
|
}
|
|
419
1043
|
return next();
|
|
420
1044
|
};
|
|
@@ -425,6 +1049,7 @@ function sessionLocals(config) {
|
|
|
425
1049
|
name: config.cookieName,
|
|
426
1050
|
secure: config.secureCookie
|
|
427
1051
|
});
|
|
1052
|
+
const { tokenRequest } = tokenClient(config);
|
|
428
1053
|
const ttl = config.userCacheMs ?? 3e4;
|
|
429
1054
|
const cache2 = /* @__PURE__ */ new Map();
|
|
430
1055
|
const remember = (token, user) => {
|
|
@@ -439,23 +1064,61 @@ function sessionLocals(config) {
|
|
|
439
1064
|
return async function middleware(request, next) {
|
|
440
1065
|
const { getRequestLocals } = await import("@fluixi/server");
|
|
441
1066
|
const locals = getRequestLocals(request);
|
|
442
|
-
|
|
443
|
-
|
|
1067
|
+
let session = await cookie.read(request);
|
|
1068
|
+
let setCookie;
|
|
1069
|
+
const respond = async (response) => {
|
|
1070
|
+
if (!setCookie) return response;
|
|
1071
|
+
const copy = new Response(response.body, response);
|
|
1072
|
+
copy.headers.append("set-cookie", setCookie);
|
|
1073
|
+
return copy;
|
|
1074
|
+
};
|
|
1075
|
+
const renewOrDrop = async (current2) => {
|
|
1076
|
+
const renewed = current2.refreshToken ? await renew(current2, tokenRequest) : null;
|
|
1077
|
+
setCookie = renewed ? await cookie.write(renewed) : cookie.clear();
|
|
1078
|
+
return renewed;
|
|
1079
|
+
};
|
|
1080
|
+
let paused = false;
|
|
1081
|
+
const keep = (current2) => (error) => {
|
|
1082
|
+
if (error instanceof ProviderUnavailable) paused = true;
|
|
1083
|
+
return current2;
|
|
1084
|
+
};
|
|
1085
|
+
if (session?.accessToken && expired(session)) {
|
|
1086
|
+
session = await renewOrDrop(session).catch(keep(session));
|
|
1087
|
+
}
|
|
1088
|
+
const signedOut = async () => {
|
|
1089
|
+
const redirect = signInRedirect(request, config);
|
|
1090
|
+
if (redirect) return respond(redirect);
|
|
444
1091
|
locals.auth = { user: null, session: null };
|
|
445
|
-
return next();
|
|
1092
|
+
return respond(await next());
|
|
1093
|
+
};
|
|
1094
|
+
if (!session?.accessToken) return signedOut();
|
|
1095
|
+
if (await endedAtProvider(config, session)) {
|
|
1096
|
+
setCookie = cookie.clear();
|
|
1097
|
+
session = null;
|
|
1098
|
+
return signedOut();
|
|
446
1099
|
}
|
|
447
|
-
const
|
|
1100
|
+
const live = session;
|
|
1101
|
+
const fetchAs = (input, init) => sendAs(live, input, init);
|
|
1102
|
+
const cached = cache2.get(live.accessToken);
|
|
448
1103
|
if (cached && cached.until > Date.now()) {
|
|
449
|
-
locals.auth = { user: cached.user, session: { expiresAt:
|
|
450
|
-
return next();
|
|
1104
|
+
locals.auth = { user: cached.user, session: { expiresAt: live.expiresAt }, fetch: fetchAs };
|
|
1105
|
+
return respond(await next());
|
|
451
1106
|
}
|
|
452
1107
|
let user = null;
|
|
1108
|
+
let unreachable = false;
|
|
453
1109
|
try {
|
|
454
1110
|
const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
|
|
455
1111
|
if (endpoints?.userinfo) {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
1112
|
+
let response = await withToken(endpoints.userinfo, session);
|
|
1113
|
+
if (response.status === 401 && !setCookie && !paused) {
|
|
1114
|
+
session = await renewOrDrop(session).catch(keep(session));
|
|
1115
|
+
if (!session) return signedOut();
|
|
1116
|
+
response = await withToken(endpoints.userinfo, session);
|
|
1117
|
+
}
|
|
1118
|
+
if (response.status === 401) {
|
|
1119
|
+
if (!paused) setCookie = cookie.clear();
|
|
1120
|
+
return signedOut();
|
|
1121
|
+
}
|
|
459
1122
|
if (response.ok) {
|
|
460
1123
|
const claims = await response.json();
|
|
461
1124
|
user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
|
|
@@ -463,8 +1126,20 @@ function sessionLocals(config) {
|
|
|
463
1126
|
}
|
|
464
1127
|
}
|
|
465
1128
|
} catch {
|
|
1129
|
+
unreachable = true;
|
|
466
1130
|
}
|
|
467
|
-
|
|
468
|
-
|
|
1131
|
+
if (!session) return signedOut();
|
|
1132
|
+
const current = session;
|
|
1133
|
+
const fetchCurrent = (input, init) => sendAs(current, input, init);
|
|
1134
|
+
locals.auth = user ? { user, session: { expiresAt: current.expiresAt }, fetch: fetchCurrent } : { user: null, session: null, ...unreachable ? { unreachable: true } : {} };
|
|
1135
|
+
return respond(await next());
|
|
469
1136
|
};
|
|
470
1137
|
}
|
|
1138
|
+
async function requestAuth(request) {
|
|
1139
|
+
const { getLocals, getRequestLocals } = await import("@fluixi/server");
|
|
1140
|
+
const locals = request ? getRequestLocals(request) : getLocals();
|
|
1141
|
+
return locals.auth ?? { user: null, session: null };
|
|
1142
|
+
}
|
|
1143
|
+
function authServerModule(config) {
|
|
1144
|
+
return (0, import_app.defineModule)({ name: "oauth2-server", middleware: [oauthRoutes(config), sessionLocals(config)] });
|
|
1145
|
+
}
|