@fluixi/oauth2 1.0.0-alpha.11 → 1.0.0-alpha.13
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/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-N432SMC3.mjs → browser-SQXLVG25.mjs} +2 -2
- package/dist/browser.cjs +6 -1
- package/dist/browser.mjs +6 -1
- package/dist/callback.mjs +1 -1
- package/dist/{chunk-ZVDC3JW7.mjs → chunk-CVUD2UAP.mjs} +1 -1
- package/dist/chunk-H5KMWPVT.mjs +108 -0
- package/dist/{chunk-4TPZVJSR.mjs → chunk-QZFYCRMT.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-DYUR2CH2.mjs → dpop-7YXQSM5R.mjs} +4 -4
- package/dist/dpop-SP7YCDPE.mjs +22 -0
- package/dist/index.cjs +107 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.mjs +111 -11
- package/dist/server.cjs +649 -137
- package/dist/server.d.ts +64 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +376 -61
- package/dist/server.mjs +532 -139
- package/dist/service-client.cjs +7 -2
- package/dist/service-client.mjs +9 -4
- package/dist/service.cjs +104 -5
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +38 -6
- package/dist/service.mjs +105 -6
- 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/tsconfig.lib.tsbuildinfo +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/verify.cjs +7 -2
- package/dist/verify.mjs +12 -7
- package/package.json +15 -15
package/dist/server.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! @fluixi/oauth2 v1.0.0-alpha.
|
|
1
|
+
/*! @fluixi/oauth2 v1.0.0-alpha.13 | (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,11 +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, {
|
|
34
156
|
authServerModule: () => authServerModule,
|
|
157
|
+
memoryLogouts: () => memoryLogouts,
|
|
35
158
|
oauthRoutes: () => oauthRoutes,
|
|
159
|
+
requestAuth: () => requestAuth,
|
|
36
160
|
sessionLocals: () => sessionLocals
|
|
37
161
|
});
|
|
38
162
|
module.exports = __toCommonJS(server_exports);
|
|
@@ -61,17 +185,8 @@ function safeReturnTo(candidate, options = {}) {
|
|
|
61
185
|
return url.toString();
|
|
62
186
|
}
|
|
63
187
|
|
|
64
|
-
// src/types.ts
|
|
65
|
-
var OAuthError = class extends Error {
|
|
66
|
-
constructor(message, code, description) {
|
|
67
|
-
super(message);
|
|
68
|
-
this.code = code;
|
|
69
|
-
this.description = description;
|
|
70
|
-
this.name = "OAuthError";
|
|
71
|
-
}
|
|
72
|
-
};
|
|
73
|
-
|
|
74
188
|
// src/client-auth.ts
|
|
189
|
+
init_types();
|
|
75
190
|
var CANONICAL = {
|
|
76
191
|
basic: "client_secret_basic",
|
|
77
192
|
body: "client_secret_post",
|
|
@@ -125,6 +240,7 @@ async function applyClientAuth(auth, endpoint, headers, form) {
|
|
|
125
240
|
}
|
|
126
241
|
|
|
127
242
|
// src/discovery.ts
|
|
243
|
+
init_types();
|
|
128
244
|
var cache = /* @__PURE__ */ new Map();
|
|
129
245
|
function documentUrl(issuer) {
|
|
130
246
|
return `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
|
|
@@ -137,8 +253,12 @@ async function discover(issuer, fetcher = fetch) {
|
|
|
137
253
|
try {
|
|
138
254
|
response = await fetcher(documentUrl(issuer));
|
|
139
255
|
} catch (cause) {
|
|
256
|
+
const why = cause instanceof Error ? cause.message : String(cause);
|
|
140
257
|
throw new OAuthError(
|
|
141
|
-
|
|
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})`,
|
|
142
262
|
"discovery_unreachable"
|
|
143
263
|
);
|
|
144
264
|
}
|
|
@@ -163,6 +283,7 @@ async function discover(issuer, fetcher = fetch) {
|
|
|
163
283
|
jwks: doc.jwks_uri,
|
|
164
284
|
introspection: doc.introspection_endpoint,
|
|
165
285
|
revocation: doc.revocation_endpoint,
|
|
286
|
+
checkSession: doc.check_session_iframe,
|
|
166
287
|
issuer: doc.issuer ?? issuer
|
|
167
288
|
};
|
|
168
289
|
})();
|
|
@@ -171,6 +292,252 @@ async function discover(issuer, fetcher = fetch) {
|
|
|
171
292
|
return pending;
|
|
172
293
|
}
|
|
173
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
|
+
|
|
174
541
|
// src/pkce.ts
|
|
175
542
|
function base64url(bytes) {
|
|
176
543
|
let binary = "";
|
|
@@ -207,81 +574,62 @@ function expiryFrom(expiresIn, now = Date.now()) {
|
|
|
207
574
|
return Number.isFinite(seconds) && seconds > 0 ? now + seconds * 1e3 : void 0;
|
|
208
575
|
}
|
|
209
576
|
|
|
210
|
-
// src/
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
return {
|
|
228
|
-
privateKey,
|
|
229
|
-
publicJwk,
|
|
230
|
-
thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
|
|
231
|
-
alg
|
|
232
|
-
};
|
|
577
|
+
// src/server.ts
|
|
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" : ""}` };
|
|
233
595
|
}
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
return
|
|
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;
|
|
237
599
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
return {
|
|
244
|
-
privateKey,
|
|
245
|
-
publicJwk,
|
|
246
|
-
thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
|
|
247
|
-
alg: stored.alg
|
|
248
|
-
};
|
|
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");
|
|
249
605
|
}
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
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(/=+$/, "");
|
|
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("=");
|
|
275
610
|
}
|
|
276
|
-
|
|
277
|
-
return new jose.SignJWT(claims).setProtectedHeader({ typ: "dpop+jwt", alg: key.alg, jwk: key.publicJwk }).setIssuedAt().sign(key.privateKey);
|
|
611
|
+
return void 0;
|
|
278
612
|
}
|
|
279
|
-
function
|
|
280
|
-
return
|
|
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);
|
|
622
|
+
const base = (config.basePath ?? "/auth").replace(/\/$/, "");
|
|
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}` } });
|
|
281
632
|
}
|
|
282
|
-
|
|
283
|
-
// src/server.ts
|
|
284
|
-
var import_app = require("@fluixi/start/app");
|
|
285
633
|
var dpopNonces = /* @__PURE__ */ new Map();
|
|
286
634
|
async function prove(headers, key, method, url, accessToken) {
|
|
287
635
|
headers.dpop = await proofFor(key, { method, url, accessToken, nonce: dpopNonces.get(new URL(url).origin) });
|
|
@@ -294,27 +642,25 @@ function learnNonce(url, response) {
|
|
|
294
642
|
dpopNonces.set(origin, offered);
|
|
295
643
|
return fresh && !response.ok;
|
|
296
644
|
}
|
|
297
|
-
async function
|
|
645
|
+
async function sendAs(session, input, init = {}) {
|
|
646
|
+
const url = String(input);
|
|
647
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
298
648
|
const key = session.dpop && session.tokenType?.toLowerCase() === "dpop" ? await importDpopKey(session.dpop) : null;
|
|
299
649
|
const send = async () => {
|
|
300
|
-
const
|
|
650
|
+
const credentials = {
|
|
301
651
|
authorization: `${key ? "DPoP" : "Bearer"} ${session.accessToken}`
|
|
302
652
|
};
|
|
303
|
-
if (key) await prove(
|
|
304
|
-
|
|
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 });
|
|
305
657
|
};
|
|
306
658
|
let response = await send();
|
|
307
659
|
if (key && learnNonce(url, response)) response = await send();
|
|
308
660
|
return response;
|
|
309
661
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const scopes = config.scopes ?? ["openid", "profile", "email"];
|
|
313
|
-
const cookie = (0, import_server.createSessionCookie)({
|
|
314
|
-
secret: config.cookieSecret,
|
|
315
|
-
name: config.cookieName,
|
|
316
|
-
secure: config.secureCookie
|
|
317
|
-
});
|
|
662
|
+
var withToken = (url, session) => sendAs(session, url);
|
|
663
|
+
function tokenClient(config) {
|
|
318
664
|
let endpoints = config.endpoints ?? null;
|
|
319
665
|
const resolve = async () => {
|
|
320
666
|
if (endpoints) return endpoints;
|
|
@@ -324,10 +670,6 @@ function oauthRoutes(config) {
|
|
|
324
670
|
endpoints = await discover(config.issuer);
|
|
325
671
|
return endpoints;
|
|
326
672
|
};
|
|
327
|
-
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
|
|
328
|
-
status,
|
|
329
|
-
headers: { "content-type": "application/json", ...headers }
|
|
330
|
-
});
|
|
331
673
|
const clientAuth = {
|
|
332
674
|
clientId: config.clientId,
|
|
333
675
|
// `client_secret_post` by default here, which is what this half has always sent, so
|
|
@@ -360,17 +702,111 @@ function oauthRoutes(config) {
|
|
|
360
702
|
}
|
|
361
703
|
return payload;
|
|
362
704
|
}
|
|
705
|
+
return { resolve, tokenRequest, clientAuth };
|
|
706
|
+
}
|
|
707
|
+
var renewals = /* @__PURE__ */ new Map();
|
|
708
|
+
var RENEWAL_REUSE_MS = 3e4;
|
|
709
|
+
function renew(current, tokenRequest) {
|
|
710
|
+
const refreshToken = current.refreshToken;
|
|
711
|
+
const now = Date.now();
|
|
712
|
+
for (const [key, entry] of renewals) if (entry.until <= now) renewals.delete(key);
|
|
713
|
+
const known = renewals.get(refreshToken);
|
|
714
|
+
if (known) return known.result;
|
|
715
|
+
const result = (async () => {
|
|
716
|
+
const key = current.dpop ? await importDpopKey(current.dpop) : null;
|
|
717
|
+
const payload = await tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken }, key);
|
|
718
|
+
return {
|
|
719
|
+
accessToken: String(payload.access_token ?? ""),
|
|
720
|
+
// A provider that rotates refresh tokens sends a new one; one that does not
|
|
721
|
+
// expects the old one to keep working.
|
|
722
|
+
refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : refreshToken,
|
|
723
|
+
idToken: typeof payload.id_token === "string" ? payload.id_token : current.idToken,
|
|
724
|
+
expiresAt: expiryFrom(payload.expires_in),
|
|
725
|
+
tokenType: typeof payload.token_type === "string" ? payload.token_type : current.tokenType,
|
|
726
|
+
dpop: current.dpop,
|
|
727
|
+
sessionState: current.sessionState
|
|
728
|
+
};
|
|
729
|
+
})().catch((error) => {
|
|
730
|
+
if (error instanceof OAuthError) return null;
|
|
731
|
+
renewals.delete(refreshToken);
|
|
732
|
+
throw error;
|
|
733
|
+
});
|
|
734
|
+
renewals.set(refreshToken, { until: now + RENEWAL_REUSE_MS, result });
|
|
735
|
+
return result;
|
|
736
|
+
}
|
|
737
|
+
var EXPIRY_SKEW_MS = 3e4;
|
|
738
|
+
var expired = (session) => session.expiresAt !== void 0 && session.expiresAt - EXPIRY_SKEW_MS <= Date.now();
|
|
739
|
+
var defaultLogouts = /* @__PURE__ */ new WeakMap();
|
|
740
|
+
function logoutsOf(config) {
|
|
741
|
+
if (config.logouts) return config.logouts;
|
|
742
|
+
let store = defaultLogouts.get(config);
|
|
743
|
+
if (!store) defaultLogouts.set(config, store = memoryLogouts());
|
|
744
|
+
return store;
|
|
745
|
+
}
|
|
746
|
+
async function endedAtProvider(config, session) {
|
|
747
|
+
const id = idTokenClaims(session.idToken);
|
|
748
|
+
if (!id.sid && !id.sub) return false;
|
|
749
|
+
return logoutsOf(config).ended({ sid: id.sid, sub: id.sub, since: (id.iat ?? 0) * 1e3 });
|
|
750
|
+
}
|
|
751
|
+
function oauthRoutes(config) {
|
|
752
|
+
const { resolve, tokenRequest, clientAuth } = tokenClient(config);
|
|
753
|
+
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
|
|
754
|
+
status,
|
|
755
|
+
headers: { "content-type": "application/json", ...headers }
|
|
756
|
+
});
|
|
757
|
+
const base = (config.basePath ?? "/auth").replace(/\/$/, "");
|
|
758
|
+
const scopes = config.scopes ?? ["openid", "profile", "email"];
|
|
759
|
+
const cookie = (0, import_server.createSessionCookie)({
|
|
760
|
+
secret: config.cookieSecret,
|
|
761
|
+
name: config.cookieName,
|
|
762
|
+
secure: config.secureCookie
|
|
763
|
+
});
|
|
363
764
|
return async function middleware(request, next) {
|
|
364
765
|
const url = new URL(request.url);
|
|
365
766
|
if (!url.pathname.startsWith(`${base}/`)) return next();
|
|
366
767
|
const route = url.pathname.slice(base.length);
|
|
367
768
|
const session = async () => {
|
|
368
|
-
|
|
769
|
+
const current = await cookie.read(request);
|
|
770
|
+
return current && await endedAtProvider(config, current) ? null : current;
|
|
369
771
|
};
|
|
772
|
+
if (route === "/backchannel-logout") {
|
|
773
|
+
const headers = { "cache-control": "no-store" };
|
|
774
|
+
if (request.method !== "POST") return json({ error: "invalid_request" }, 405, headers);
|
|
775
|
+
try {
|
|
776
|
+
const form = await request.formData();
|
|
777
|
+
const token = form.get("logout_token");
|
|
778
|
+
if (typeof token !== "string" || !token) throw new Error("no logout_token");
|
|
779
|
+
const endpoints = await resolve();
|
|
780
|
+
const issuer = endpoints.issuer ?? config.issuer;
|
|
781
|
+
if (!issuer) throw new Error("no issuer");
|
|
782
|
+
if (!endpoints.jwks) throw new Error("no jwks_uri");
|
|
783
|
+
const jwks = await (await fetch(endpoints.jwks)).json();
|
|
784
|
+
const entry = await logoutTokenVerifier({ issuer, clientId: config.clientId, jwks })(token);
|
|
785
|
+
await logoutsOf(config).add(entry);
|
|
786
|
+
return new Response(null, { status: 200, headers });
|
|
787
|
+
} catch {
|
|
788
|
+
return json({ error: "invalid_request" }, 400, headers);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
370
791
|
if (route === "/login") {
|
|
371
|
-
|
|
792
|
+
let authorization;
|
|
793
|
+
try {
|
|
794
|
+
({ authorization } = await resolve());
|
|
795
|
+
} catch (error) {
|
|
796
|
+
if (!(error instanceof OAuthError) || error.code !== "discovery_unreachable") throw error;
|
|
797
|
+
const back = safeReturnTo(url.searchParams.get("returnTo"), {
|
|
798
|
+
fallback: config.defaultReturnTo ?? "/",
|
|
799
|
+
origin: url.origin,
|
|
800
|
+
allowedOrigins: config.allowedReturnOrigins
|
|
801
|
+
});
|
|
802
|
+
const target2 = new URL(back, url.origin);
|
|
803
|
+
target2.searchParams.set("auth_error", "provider_unreachable");
|
|
804
|
+
const location2 = target2.origin === url.origin ? target2.pathname + target2.search : target2.toString();
|
|
805
|
+
return new Response(null, { status: 302, headers: { location: location2 } });
|
|
806
|
+
}
|
|
372
807
|
const { verifier, challenge } = await createPkcePair();
|
|
373
|
-
const
|
|
808
|
+
const retry = url.searchParams.get("retry") === "1";
|
|
809
|
+
const state = createStateValue() + (retry ? RETRY_MARK : "");
|
|
374
810
|
const target = new URL(authorization);
|
|
375
811
|
target.searchParams.set("response_type", "code");
|
|
376
812
|
target.searchParams.set("client_id", config.clientId);
|
|
@@ -379,6 +815,8 @@ function oauthRoutes(config) {
|
|
|
379
815
|
target.searchParams.set("state", state);
|
|
380
816
|
target.searchParams.set("code_challenge", challenge);
|
|
381
817
|
target.searchParams.set("code_challenge_method", "S256");
|
|
818
|
+
const prompt = url.searchParams.get("prompt");
|
|
819
|
+
if (prompt && PROMPTS.has(prompt)) target.searchParams.set("prompt", prompt);
|
|
382
820
|
const pendingCookie = await cookie.write(
|
|
383
821
|
{
|
|
384
822
|
accessToken: "",
|
|
@@ -391,7 +829,8 @@ function oauthRoutes(config) {
|
|
|
391
829
|
fallback: "",
|
|
392
830
|
origin: url.origin,
|
|
393
831
|
allowedOrigins: config.allowedReturnOrigins
|
|
394
|
-
}) || void 0
|
|
832
|
+
}) || void 0,
|
|
833
|
+
silent: prompt === "none" || void 0
|
|
395
834
|
}
|
|
396
835
|
},
|
|
397
836
|
// Ten minutes: long enough to sign in, short enough that an abandoned attempt
|
|
@@ -410,12 +849,43 @@ function oauthRoutes(config) {
|
|
|
410
849
|
const current = await session();
|
|
411
850
|
const pending = current?.pending;
|
|
412
851
|
const error = url.searchParams.get("error");
|
|
413
|
-
if (error)
|
|
852
|
+
if (error) {
|
|
853
|
+
const ours = !!pending && timingSafeEqual(pending.state, url.searchParams.get("state") ?? "");
|
|
854
|
+
let location2 = safeReturnTo(ours ? pending.returnTo ?? config.defaultReturnTo : config.defaultReturnTo, {
|
|
855
|
+
fallback: "/",
|
|
856
|
+
origin: url.origin,
|
|
857
|
+
allowedOrigins: config.allowedReturnOrigins
|
|
858
|
+
});
|
|
859
|
+
const headers = new Headers();
|
|
860
|
+
headers.append("set-cookie", cookie.clear());
|
|
861
|
+
if (ours && pending.silent && SILENT_REFUSALS.has(error)) {
|
|
862
|
+
headers.append("set-cookie", checkedCookie(config, sessionStateFrom(url)).header);
|
|
863
|
+
} else {
|
|
864
|
+
const target = new URL(location2, url.origin);
|
|
865
|
+
target.searchParams.set("auth_error", error);
|
|
866
|
+
location2 = target.origin === url.origin ? target.pathname + target.search : target.toString();
|
|
867
|
+
}
|
|
868
|
+
headers.set("location", location2);
|
|
869
|
+
return new Response(null, { status: 302, headers });
|
|
870
|
+
}
|
|
414
871
|
const code = url.searchParams.get("code");
|
|
415
872
|
const state = url.searchParams.get("state");
|
|
416
873
|
if (!code) return json({ error: "no_code" }, 400);
|
|
417
874
|
if (!pending || !state || !timingSafeEqual(pending.state, state)) {
|
|
418
|
-
|
|
875
|
+
const home = safeReturnTo(config.defaultReturnTo, { fallback: "/", origin: url.origin, allowedOrigins: config.allowedReturnOrigins });
|
|
876
|
+
if (current?.accessToken) return new Response(null, { status: 302, headers: { location: home } });
|
|
877
|
+
if (!state?.endsWith(RETRY_MARK)) {
|
|
878
|
+
return new Response(null, { status: 302, headers: { location: `${base}/login?retry=1` } });
|
|
879
|
+
}
|
|
880
|
+
const target = new URL(home, url.origin);
|
|
881
|
+
target.searchParams.set("auth_error", "state_mismatch");
|
|
882
|
+
return new Response(null, {
|
|
883
|
+
status: 302,
|
|
884
|
+
headers: {
|
|
885
|
+
location: target.origin === url.origin ? target.pathname + target.search : target.toString(),
|
|
886
|
+
"set-cookie": cookie.clear()
|
|
887
|
+
}
|
|
888
|
+
});
|
|
419
889
|
}
|
|
420
890
|
const key = config.dpop ? await createDpopKey(config.dpopAlgorithm, { extractable: true }) : null;
|
|
421
891
|
const payload = await tokenRequest(
|
|
@@ -433,7 +903,8 @@ function oauthRoutes(config) {
|
|
|
433
903
|
idToken: typeof payload.id_token === "string" ? payload.id_token : void 0,
|
|
434
904
|
expiresAt: expiryFrom(payload.expires_in),
|
|
435
905
|
tokenType: typeof payload.token_type === "string" ? payload.token_type : void 0,
|
|
436
|
-
dpop: key ? await exportDpopKey(key) : void 0
|
|
906
|
+
dpop: key ? await exportDpopKey(key) : void 0,
|
|
907
|
+
sessionState: sessionStateFrom(url)
|
|
437
908
|
});
|
|
438
909
|
return new Response(null, {
|
|
439
910
|
status: 302,
|
|
@@ -453,22 +924,9 @@ function oauthRoutes(config) {
|
|
|
453
924
|
if (route === "/refresh") {
|
|
454
925
|
const current = await session();
|
|
455
926
|
if (!current?.refreshToken) return json({ error: "no_session" }, 401);
|
|
456
|
-
const
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
key
|
|
460
|
-
);
|
|
461
|
-
const refreshed = await cookie.write({
|
|
462
|
-
accessToken: String(payload.access_token ?? ""),
|
|
463
|
-
// A provider that rotates refresh tokens sends a new one; one that does not
|
|
464
|
-
// expects the old one to keep working.
|
|
465
|
-
refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : current.refreshToken,
|
|
466
|
-
idToken: current.idToken,
|
|
467
|
-
expiresAt: expiryFrom(payload.expires_in),
|
|
468
|
-
tokenType: typeof payload.token_type === "string" ? payload.token_type : current.tokenType,
|
|
469
|
-
dpop: current.dpop
|
|
470
|
-
});
|
|
471
|
-
return json({ ok: true }, 200, { "set-cookie": refreshed });
|
|
927
|
+
const renewed = await renew(current, tokenRequest);
|
|
928
|
+
if (!renewed) return json({ error: "refresh_refused" }, 401, { "set-cookie": cookie.clear() });
|
|
929
|
+
return json({ ok: true }, 200, { "set-cookie": await cookie.write(renewed) });
|
|
472
930
|
}
|
|
473
931
|
if (route === "/me") {
|
|
474
932
|
const current = await session();
|
|
@@ -480,6 +938,13 @@ function oauthRoutes(config) {
|
|
|
480
938
|
const claims = await response.json();
|
|
481
939
|
return json(config.mapUser ? config.mapUser(claims, current.accessToken) : claims);
|
|
482
940
|
}
|
|
941
|
+
if (route === "/session-check") {
|
|
942
|
+
const { checkSession } = await resolve().catch(() => ({ checkSession: void 0 }));
|
|
943
|
+
const current = await session();
|
|
944
|
+
const checked = cookieValue(request, checkedCookie(config).name);
|
|
945
|
+
const state = current?.accessToken ? current.sessionState : checked !== "1" ? checked : void 0;
|
|
946
|
+
return json({ frame: checkSession ?? null, clientId: config.clientId, state: state ?? null });
|
|
947
|
+
}
|
|
483
948
|
if (route === "/revoke") {
|
|
484
949
|
const current = await session();
|
|
485
950
|
const hint = url.searchParams.get("hint") === "access_token" ? "access_token" : "refresh_token";
|
|
@@ -502,31 +967,33 @@ function oauthRoutes(config) {
|
|
|
502
967
|
const { endSession } = await resolve().catch(() => ({ endSession: void 0 }));
|
|
503
968
|
const { revocation } = await resolve().catch(() => ({ revocation: void 0 }));
|
|
504
969
|
if (revocation && current?.refreshToken) {
|
|
505
|
-
const
|
|
970
|
+
const headers2 = {
|
|
506
971
|
"content-type": "application/x-www-form-urlencoded"
|
|
507
972
|
};
|
|
508
973
|
const form = new URLSearchParams({
|
|
509
974
|
token: current.refreshToken,
|
|
510
975
|
token_type_hint: "refresh_token"
|
|
511
976
|
});
|
|
512
|
-
await applyClientAuth(clientAuth, revocation,
|
|
513
|
-
await fetch(revocation, { method: "POST", headers, body: form.toString() }).catch(
|
|
977
|
+
await applyClientAuth(clientAuth, revocation, headers2, form);
|
|
978
|
+
await fetch(revocation, { method: "POST", headers: headers2, body: form.toString() }).catch(
|
|
514
979
|
() => void 0
|
|
515
980
|
);
|
|
516
981
|
}
|
|
517
982
|
const cleared = cookie.clear();
|
|
983
|
+
const headers = new Headers();
|
|
984
|
+
headers.append("set-cookie", cleared);
|
|
985
|
+
headers.append("set-cookie", checkedCookie(config).header);
|
|
518
986
|
if (endSession && current?.idToken) {
|
|
519
987
|
const target = new URL(endSession);
|
|
520
988
|
target.searchParams.set("id_token_hint", current.idToken);
|
|
521
989
|
if (config.defaultReturnTo) {
|
|
522
990
|
target.searchParams.set("post_logout_redirect_uri", config.defaultReturnTo);
|
|
523
991
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
headers: { location: target.toString(), "set-cookie": cleared }
|
|
527
|
-
});
|
|
992
|
+
headers.set("location", target.toString());
|
|
993
|
+
return new Response(null, { status: 302, headers });
|
|
528
994
|
}
|
|
529
|
-
|
|
995
|
+
headers.set("content-type", "application/json");
|
|
996
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
|
|
530
997
|
}
|
|
531
998
|
return next();
|
|
532
999
|
};
|
|
@@ -537,6 +1004,7 @@ function sessionLocals(config) {
|
|
|
537
1004
|
name: config.cookieName,
|
|
538
1005
|
secure: config.secureCookie
|
|
539
1006
|
});
|
|
1007
|
+
const { tokenRequest } = tokenClient(config);
|
|
540
1008
|
const ttl = config.userCacheMs ?? 3e4;
|
|
541
1009
|
const cache2 = /* @__PURE__ */ new Map();
|
|
542
1010
|
const remember = (token, user) => {
|
|
@@ -551,21 +1019,56 @@ function sessionLocals(config) {
|
|
|
551
1019
|
return async function middleware(request, next) {
|
|
552
1020
|
const { getRequestLocals } = await import("@fluixi/server");
|
|
553
1021
|
const locals = getRequestLocals(request);
|
|
554
|
-
|
|
555
|
-
|
|
1022
|
+
let session = await cookie.read(request);
|
|
1023
|
+
let setCookie;
|
|
1024
|
+
const respond = async (response) => {
|
|
1025
|
+
if (!setCookie) return response;
|
|
1026
|
+
const copy = new Response(response.body, response);
|
|
1027
|
+
copy.headers.append("set-cookie", setCookie);
|
|
1028
|
+
return copy;
|
|
1029
|
+
};
|
|
1030
|
+
const renewOrDrop = async (current2) => {
|
|
1031
|
+
const renewed = current2.refreshToken ? await renew(current2, tokenRequest) : null;
|
|
1032
|
+
setCookie = renewed ? await cookie.write(renewed) : cookie.clear();
|
|
1033
|
+
return renewed;
|
|
1034
|
+
};
|
|
1035
|
+
if (session?.accessToken && expired(session)) {
|
|
1036
|
+
session = await renewOrDrop(session).catch(() => session);
|
|
1037
|
+
}
|
|
1038
|
+
const signedOut = async () => {
|
|
1039
|
+
const redirect = signInRedirect(request, config);
|
|
1040
|
+
if (redirect) return respond(redirect);
|
|
556
1041
|
locals.auth = { user: null, session: null };
|
|
557
|
-
return next();
|
|
1042
|
+
return respond(await next());
|
|
1043
|
+
};
|
|
1044
|
+
if (!session?.accessToken) return signedOut();
|
|
1045
|
+
if (await endedAtProvider(config, session)) {
|
|
1046
|
+
setCookie = cookie.clear();
|
|
1047
|
+
session = null;
|
|
1048
|
+
return signedOut();
|
|
558
1049
|
}
|
|
559
|
-
const
|
|
1050
|
+
const live = session;
|
|
1051
|
+
const fetchAs = (input, init) => sendAs(live, input, init);
|
|
1052
|
+
const cached = cache2.get(live.accessToken);
|
|
560
1053
|
if (cached && cached.until > Date.now()) {
|
|
561
|
-
locals.auth = { user: cached.user, session: { expiresAt:
|
|
562
|
-
return next();
|
|
1054
|
+
locals.auth = { user: cached.user, session: { expiresAt: live.expiresAt }, fetch: fetchAs };
|
|
1055
|
+
return respond(await next());
|
|
563
1056
|
}
|
|
564
1057
|
let user = null;
|
|
1058
|
+
let unreachable = false;
|
|
565
1059
|
try {
|
|
566
1060
|
const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
|
|
567
1061
|
if (endpoints?.userinfo) {
|
|
568
|
-
|
|
1062
|
+
let response = await withToken(endpoints.userinfo, session);
|
|
1063
|
+
if (response.status === 401 && !setCookie) {
|
|
1064
|
+
session = await renewOrDrop(session).catch(() => session);
|
|
1065
|
+
if (!session) return signedOut();
|
|
1066
|
+
response = await withToken(endpoints.userinfo, session);
|
|
1067
|
+
}
|
|
1068
|
+
if (response.status === 401) {
|
|
1069
|
+
setCookie = cookie.clear();
|
|
1070
|
+
return signedOut();
|
|
1071
|
+
}
|
|
569
1072
|
if (response.ok) {
|
|
570
1073
|
const claims = await response.json();
|
|
571
1074
|
user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
|
|
@@ -573,11 +1076,20 @@ function sessionLocals(config) {
|
|
|
573
1076
|
}
|
|
574
1077
|
}
|
|
575
1078
|
} catch {
|
|
1079
|
+
unreachable = true;
|
|
576
1080
|
}
|
|
577
|
-
|
|
578
|
-
|
|
1081
|
+
if (!session) return signedOut();
|
|
1082
|
+
const current = session;
|
|
1083
|
+
const fetchCurrent = (input, init) => sendAs(current, input, init);
|
|
1084
|
+
locals.auth = user ? { user, session: { expiresAt: current.expiresAt }, fetch: fetchCurrent } : { user: null, session: null, ...unreachable ? { unreachable: true } : {} };
|
|
1085
|
+
return respond(await next());
|
|
579
1086
|
};
|
|
580
1087
|
}
|
|
1088
|
+
async function requestAuth(request) {
|
|
1089
|
+
const { getLocals, getRequestLocals } = await import("@fluixi/server");
|
|
1090
|
+
const locals = request ? getRequestLocals(request) : getLocals();
|
|
1091
|
+
return locals.auth ?? { user: null, session: null };
|
|
1092
|
+
}
|
|
581
1093
|
function authServerModule(config) {
|
|
582
1094
|
return (0, import_app.defineModule)({ name: "oauth2-server", middleware: [oauthRoutes(config), sessionLocals(config)] });
|
|
583
1095
|
}
|