@fluixi/oauth2 1.0.0-alpha.10 → 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-36FHB6KG.mjs → browser-TV25RBLT.mjs} +2 -2
- package/dist/browser.cjs +6 -1
- package/dist/browser.mjs +6 -1
- package/dist/callback.cjs +2 -2
- package/dist/callback.js +1 -1
- package/dist/callback.mjs +3 -3
- package/dist/{chunk-F52RUVDM.mjs → chunk-2B3I72DV.mjs} +1 -1
- package/dist/chunk-KOP5Z4LB.mjs +108 -0
- package/dist/{chunk-OGS6MQ3D.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-GOWCCCFR.mjs → dpop-SGWG3K2I.mjs} +4 -4
- package/dist/index.cjs +108 -8
- 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 +112 -12
- package/dist/module.cjs +1 -1
- package/dist/module.mjs +1 -1
- package/dist/server.cjs +705 -143
- package/dist/server.d.ts +71 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +455 -64
- package/dist/server.mjs +588 -145
- 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/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 +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 +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,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
|
-
|
|
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" : ""}` };
|
|
220
595
|
}
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
extractable: options.extractable ?? false
|
|
225
|
-
});
|
|
226
|
-
const publicJwk = { ...await jose.exportJWK(publicKey) };
|
|
227
|
-
return {
|
|
228
|
-
privateKey,
|
|
229
|
-
publicJwk,
|
|
230
|
-
thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
|
|
231
|
-
alg
|
|
232
|
-
};
|
|
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;
|
|
233
599
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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");
|
|
237
605
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
const publicJwk = Object.fromEntries(Object.entries(stored.jwk).filter(([k]) => !PRIVATE_MEMBERS.has(k)));
|
|
243
|
-
return {
|
|
244
|
-
privateKey,
|
|
245
|
-
publicJwk,
|
|
246
|
-
thumbprint: await jose.calculateJwkThumbprint(publicJwk, "sha256"),
|
|
247
|
-
alg: stored.alg
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
async function proofFor(key, options) {
|
|
251
|
-
const jose = await loadJose2();
|
|
252
|
-
const htu = (() => {
|
|
253
|
-
try {
|
|
254
|
-
const url = new URL(options.url);
|
|
255
|
-
url.search = "";
|
|
256
|
-
url.hash = "";
|
|
257
|
-
return url.toString();
|
|
258
|
-
} catch {
|
|
259
|
-
return options.url;
|
|
260
|
-
}
|
|
261
|
-
})();
|
|
262
|
-
const claims = {
|
|
263
|
-
htm: options.method.toUpperCase(),
|
|
264
|
-
htu,
|
|
265
|
-
jti: crypto.randomUUID()
|
|
266
|
-
};
|
|
267
|
-
if (options.accessToken) {
|
|
268
|
-
const digest = await crypto.subtle.digest(
|
|
269
|
-
"SHA-256",
|
|
270
|
-
new TextEncoder().encode(options.accessToken)
|
|
271
|
-
);
|
|
272
|
-
let binary = "";
|
|
273
|
-
for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
|
|
274
|
-
claims.ath = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
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,52 @@ 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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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;
|
|
318
691
|
let endpoints = config.endpoints ?? null;
|
|
319
692
|
const resolve = async () => {
|
|
320
693
|
if (endpoints) return endpoints;
|
|
@@ -324,10 +697,6 @@ function oauthRoutes(config) {
|
|
|
324
697
|
endpoints = await discover(config.issuer);
|
|
325
698
|
return endpoints;
|
|
326
699
|
};
|
|
327
|
-
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
|
|
328
|
-
status,
|
|
329
|
-
headers: { "content-type": "application/json", ...headers }
|
|
330
|
-
});
|
|
331
700
|
const clientAuth = {
|
|
332
701
|
clientId: config.clientId,
|
|
333
702
|
// `client_secret_post` by default here, which is what this half has always sent, so
|
|
@@ -347,30 +716,142 @@ function oauthRoutes(config) {
|
|
|
347
716
|
const form = new URLSearchParams(body);
|
|
348
717
|
await applyClientAuth(clientAuth, token, headers, form);
|
|
349
718
|
if (key) await prove(headers, key, "POST", token);
|
|
350
|
-
return fetch(token, { method: "POST", headers, body: form.toString() });
|
|
719
|
+
return fetch(token, { method: "POST", headers, body: form.toString(), signal: AbortSignal.timeout(timeoutMs) });
|
|
351
720
|
};
|
|
352
|
-
let response
|
|
353
|
-
|
|
354
|
-
|
|
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
|
+
}
|
|
355
731
|
if (!response.ok) {
|
|
356
732
|
throw new OAuthError(
|
|
357
|
-
String(payload
|
|
358
|
-
String(payload
|
|
733
|
+
String(payload?.error ?? "token_request_failed"),
|
|
734
|
+
String(payload?.error ?? "token_request_failed")
|
|
359
735
|
);
|
|
360
736
|
}
|
|
737
|
+
if (typeof payload?.access_token !== "string" || !payload.access_token) {
|
|
738
|
+
throw new AnswerLost("a success without an access token");
|
|
739
|
+
}
|
|
361
740
|
return payload;
|
|
362
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
|
+
});
|
|
363
809
|
return async function middleware(request, next) {
|
|
364
810
|
const url = new URL(request.url);
|
|
365
811
|
if (!url.pathname.startsWith(`${base}/`)) return next();
|
|
366
812
|
const route = url.pathname.slice(base.length);
|
|
367
813
|
const session = async () => {
|
|
368
|
-
|
|
814
|
+
const current = await cookie.read(request);
|
|
815
|
+
return current && await endedAtProvider(config, current) ? null : current;
|
|
369
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
|
+
}
|
|
370
836
|
if (route === "/login") {
|
|
371
|
-
|
|
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
|
+
}
|
|
372
852
|
const { verifier, challenge } = await createPkcePair();
|
|
373
|
-
const
|
|
853
|
+
const retry = url.searchParams.get("retry") === "1";
|
|
854
|
+
const state = createStateValue() + (retry ? RETRY_MARK : "");
|
|
374
855
|
const target = new URL(authorization);
|
|
375
856
|
target.searchParams.set("response_type", "code");
|
|
376
857
|
target.searchParams.set("client_id", config.clientId);
|
|
@@ -379,6 +860,8 @@ function oauthRoutes(config) {
|
|
|
379
860
|
target.searchParams.set("state", state);
|
|
380
861
|
target.searchParams.set("code_challenge", challenge);
|
|
381
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);
|
|
382
865
|
const pendingCookie = await cookie.write(
|
|
383
866
|
{
|
|
384
867
|
accessToken: "",
|
|
@@ -391,7 +874,8 @@ function oauthRoutes(config) {
|
|
|
391
874
|
fallback: "",
|
|
392
875
|
origin: url.origin,
|
|
393
876
|
allowedOrigins: config.allowedReturnOrigins
|
|
394
|
-
}) || void 0
|
|
877
|
+
}) || void 0,
|
|
878
|
+
silent: prompt === "none" || void 0
|
|
395
879
|
}
|
|
396
880
|
},
|
|
397
881
|
// Ten minutes: long enough to sign in, short enough that an abandoned attempt
|
|
@@ -410,12 +894,43 @@ function oauthRoutes(config) {
|
|
|
410
894
|
const current = await session();
|
|
411
895
|
const pending = current?.pending;
|
|
412
896
|
const error = url.searchParams.get("error");
|
|
413
|
-
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
|
+
}
|
|
414
916
|
const code = url.searchParams.get("code");
|
|
415
917
|
const state = url.searchParams.get("state");
|
|
416
918
|
if (!code) return json({ error: "no_code" }, 400);
|
|
417
919
|
if (!pending || !state || !timingSafeEqual(pending.state, state)) {
|
|
418
|
-
|
|
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
|
+
});
|
|
419
934
|
}
|
|
420
935
|
const key = config.dpop ? await createDpopKey(config.dpopAlgorithm, { extractable: true }) : null;
|
|
421
936
|
const payload = await tokenRequest(
|
|
@@ -433,7 +948,8 @@ function oauthRoutes(config) {
|
|
|
433
948
|
idToken: typeof payload.id_token === "string" ? payload.id_token : void 0,
|
|
434
949
|
expiresAt: expiryFrom(payload.expires_in),
|
|
435
950
|
tokenType: typeof payload.token_type === "string" ? payload.token_type : void 0,
|
|
436
|
-
dpop: key ? await exportDpopKey(key) : void 0
|
|
951
|
+
dpop: key ? await exportDpopKey(key) : void 0,
|
|
952
|
+
sessionState: sessionStateFrom(url)
|
|
437
953
|
});
|
|
438
954
|
return new Response(null, {
|
|
439
955
|
status: 302,
|
|
@@ -453,22 +969,9 @@ function oauthRoutes(config) {
|
|
|
453
969
|
if (route === "/refresh") {
|
|
454
970
|
const current = await session();
|
|
455
971
|
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 });
|
|
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) });
|
|
472
975
|
}
|
|
473
976
|
if (route === "/me") {
|
|
474
977
|
const current = await session();
|
|
@@ -480,6 +983,13 @@ function oauthRoutes(config) {
|
|
|
480
983
|
const claims = await response.json();
|
|
481
984
|
return json(config.mapUser ? config.mapUser(claims, current.accessToken) : claims);
|
|
482
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
|
+
}
|
|
483
993
|
if (route === "/revoke") {
|
|
484
994
|
const current = await session();
|
|
485
995
|
const hint = url.searchParams.get("hint") === "access_token" ? "access_token" : "refresh_token";
|
|
@@ -502,31 +1012,33 @@ function oauthRoutes(config) {
|
|
|
502
1012
|
const { endSession } = await resolve().catch(() => ({ endSession: void 0 }));
|
|
503
1013
|
const { revocation } = await resolve().catch(() => ({ revocation: void 0 }));
|
|
504
1014
|
if (revocation && current?.refreshToken) {
|
|
505
|
-
const
|
|
1015
|
+
const headers2 = {
|
|
506
1016
|
"content-type": "application/x-www-form-urlencoded"
|
|
507
1017
|
};
|
|
508
1018
|
const form = new URLSearchParams({
|
|
509
1019
|
token: current.refreshToken,
|
|
510
1020
|
token_type_hint: "refresh_token"
|
|
511
1021
|
});
|
|
512
|
-
await applyClientAuth(clientAuth, revocation,
|
|
513
|
-
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(
|
|
514
1024
|
() => void 0
|
|
515
1025
|
);
|
|
516
1026
|
}
|
|
517
1027
|
const cleared = cookie.clear();
|
|
1028
|
+
const headers = new Headers();
|
|
1029
|
+
headers.append("set-cookie", cleared);
|
|
1030
|
+
headers.append("set-cookie", checkedCookie(config).header);
|
|
518
1031
|
if (endSession && current?.idToken) {
|
|
519
1032
|
const target = new URL(endSession);
|
|
520
1033
|
target.searchParams.set("id_token_hint", current.idToken);
|
|
521
1034
|
if (config.defaultReturnTo) {
|
|
522
1035
|
target.searchParams.set("post_logout_redirect_uri", config.defaultReturnTo);
|
|
523
1036
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
headers: { location: target.toString(), "set-cookie": cleared }
|
|
527
|
-
});
|
|
1037
|
+
headers.set("location", target.toString());
|
|
1038
|
+
return new Response(null, { status: 302, headers });
|
|
528
1039
|
}
|
|
529
|
-
|
|
1040
|
+
headers.set("content-type", "application/json");
|
|
1041
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
|
|
530
1042
|
}
|
|
531
1043
|
return next();
|
|
532
1044
|
};
|
|
@@ -537,6 +1049,7 @@ function sessionLocals(config) {
|
|
|
537
1049
|
name: config.cookieName,
|
|
538
1050
|
secure: config.secureCookie
|
|
539
1051
|
});
|
|
1052
|
+
const { tokenRequest } = tokenClient(config);
|
|
540
1053
|
const ttl = config.userCacheMs ?? 3e4;
|
|
541
1054
|
const cache2 = /* @__PURE__ */ new Map();
|
|
542
1055
|
const remember = (token, user) => {
|
|
@@ -551,21 +1064,61 @@ function sessionLocals(config) {
|
|
|
551
1064
|
return async function middleware(request, next) {
|
|
552
1065
|
const { getRequestLocals } = await import("@fluixi/server");
|
|
553
1066
|
const locals = getRequestLocals(request);
|
|
554
|
-
|
|
555
|
-
|
|
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);
|
|
556
1091
|
locals.auth = { user: null, session: null };
|
|
557
|
-
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();
|
|
558
1099
|
}
|
|
559
|
-
const
|
|
1100
|
+
const live = session;
|
|
1101
|
+
const fetchAs = (input, init) => sendAs(live, input, init);
|
|
1102
|
+
const cached = cache2.get(live.accessToken);
|
|
560
1103
|
if (cached && cached.until > Date.now()) {
|
|
561
|
-
locals.auth = { user: cached.user, session: { expiresAt:
|
|
562
|
-
return next();
|
|
1104
|
+
locals.auth = { user: cached.user, session: { expiresAt: live.expiresAt }, fetch: fetchAs };
|
|
1105
|
+
return respond(await next());
|
|
563
1106
|
}
|
|
564
1107
|
let user = null;
|
|
1108
|
+
let unreachable = false;
|
|
565
1109
|
try {
|
|
566
1110
|
const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
|
|
567
1111
|
if (endpoints?.userinfo) {
|
|
568
|
-
|
|
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
|
+
}
|
|
569
1122
|
if (response.ok) {
|
|
570
1123
|
const claims = await response.json();
|
|
571
1124
|
user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
|
|
@@ -573,11 +1126,20 @@ function sessionLocals(config) {
|
|
|
573
1126
|
}
|
|
574
1127
|
}
|
|
575
1128
|
} catch {
|
|
1129
|
+
unreachable = true;
|
|
576
1130
|
}
|
|
577
|
-
|
|
578
|
-
|
|
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());
|
|
579
1136
|
};
|
|
580
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
|
+
}
|
|
581
1143
|
function authServerModule(config) {
|
|
582
1144
|
return (0, import_app.defineModule)({ name: "oauth2-server", middleware: [oauthRoutes(config), sessionLocals(config)] });
|
|
583
1145
|
}
|