@fluixi/oauth2 1.0.0-alpha.1 → 1.0.0-alpha.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/dist/backchannel.cjs +462 -0
- package/dist/backchannel.d.ts +39 -0
- package/dist/backchannel.d.ts.map +1 -0
- package/dist/backchannel.js +97 -0
- package/dist/backchannel.mjs +307 -0
- package/dist/{browser-ONMHX3Z3.mjs → browser-TV25RBLT.mjs} +2 -2
- package/dist/browser.cjs +10 -3
- package/dist/browser.mjs +10 -3
- package/dist/callback.cjs +8 -7
- package/dist/callback.js +1 -1
- package/dist/callback.mjs +3 -2
- package/dist/{chunk-4NSOGPLM.mjs → chunk-2B3I72DV.mjs} +1 -1
- package/dist/chunk-KOP5Z4LB.mjs +108 -0
- package/dist/{chunk-25WE6UWI.mjs → chunk-KTGVSZE6.mjs} +1 -1
- package/dist/discovery.cjs +6 -1
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +6 -1
- package/dist/discovery.mjs +6 -1
- package/dist/dpop-O3HWR7AH.mjs +22 -0
- package/dist/{dpop-LVPWWADA.mjs → dpop-SGWG3K2I.mjs} +26 -6
- package/dist/{dpop-NS7NKOWM.mjs → dpop-YPGCGL7S.mjs} +22 -2
- package/dist/dpop-store.cjs +4 -2
- package/dist/dpop-store.mjs +4 -2
- package/dist/dpop.cjs +22 -2
- package/dist/dpop.d.ts +18 -1
- package/dist/dpop.d.ts.map +1 -1
- package/dist/dpop.js +23 -2
- package/dist/dpop.mjs +22 -2
- package/dist/index.cjs +135 -10
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.mjs +138 -13
- package/dist/module.cjs +44 -0
- package/dist/module.d.ts +13 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +22 -0
- package/dist/module.mjs +23 -0
- package/dist/server.cjs +755 -80
- package/dist/server.d.ts +91 -4
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +518 -65
- package/dist/server.mjs +638 -82
- package/dist/service-client.cjs +7 -2
- package/dist/service-client.mjs +9 -4
- package/dist/service.cjs +116 -7
- package/dist/service.d.ts +2 -18
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +48 -5
- package/dist/service.mjs +116 -7
- package/dist/session-monitor.cjs +98 -0
- package/dist/session-monitor.d.ts +32 -0
- package/dist/session-monitor.d.ts.map +1 -0
- package/dist/session-monitor.js +104 -0
- package/dist/session-monitor.mjs +77 -0
- package/dist/tokens.cjs +1 -1
- package/dist/tokens.d.ts +2 -2
- package/dist/tokens.d.ts.map +1 -1
- package/dist/tokens.js +1 -1
- package/dist/tokens.mjs +1 -1
- package/dist/tsconfig.lib.tsbuildinfo +1 -1
- package/dist/types.d.ts +12 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -12
- package/dist/verify.cjs +30 -4
- package/dist/verify.mjs +12 -7
- package/package.json +22 -16
package/dist/server.mjs
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
/*! @fluixi/oauth2 v1.0.0-alpha.
|
|
2
|
-
import
|
|
1
|
+
/*! @fluixi/oauth2 v1.0.0-alpha.100 | (c) 2026 Ibrahima Touré and Fluixi contributors | MIT */
|
|
2
|
+
import {
|
|
3
|
+
OAuthError,
|
|
4
|
+
createDpopKey,
|
|
5
|
+
exportDpopKey,
|
|
6
|
+
importDpopKey,
|
|
7
|
+
nonceFrom,
|
|
8
|
+
proofFor
|
|
9
|
+
} from "./chunk-KOP5Z4LB.mjs";
|
|
10
|
+
import "./chunk-KTGVSZE6.mjs";
|
|
3
11
|
|
|
4
12
|
// src/server.ts
|
|
5
13
|
import { createSessionCookie } from "@fluixi/session/server";
|
|
@@ -27,16 +35,6 @@ function safeReturnTo(candidate, options = {}) {
|
|
|
27
35
|
return url.toString();
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
// src/types.ts
|
|
31
|
-
var OAuthError = class extends Error {
|
|
32
|
-
constructor(message, code, description) {
|
|
33
|
-
super(message);
|
|
34
|
-
this.code = code;
|
|
35
|
-
this.description = description;
|
|
36
|
-
this.name = "OAuthError";
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
|
-
|
|
40
38
|
// src/client-auth.ts
|
|
41
39
|
var CANONICAL = {
|
|
42
40
|
basic: "client_secret_basic",
|
|
@@ -53,7 +51,7 @@ function resolveMethod(auth) {
|
|
|
53
51
|
}
|
|
54
52
|
async function loadJose() {
|
|
55
53
|
try {
|
|
56
|
-
return await import("./browser-
|
|
54
|
+
return await import("./browser-TV25RBLT.mjs");
|
|
57
55
|
} catch {
|
|
58
56
|
throw new OAuthError(
|
|
59
57
|
"private_key_jwt needs `jose`. Install it: npm i jose. It is an optional peer, so a client using a secret does not carry it.",
|
|
@@ -103,8 +101,12 @@ async function discover(issuer, fetcher = fetch) {
|
|
|
103
101
|
try {
|
|
104
102
|
response = await fetcher(documentUrl(issuer));
|
|
105
103
|
} catch (cause) {
|
|
104
|
+
const why = cause instanceof Error ? cause.message : String(cause);
|
|
106
105
|
throw new OAuthError(
|
|
107
|
-
|
|
106
|
+
typeof window === "undefined" ? (
|
|
107
|
+
// On a server nothing blocks the read: the provider is down, or the address is wrong.
|
|
108
|
+
`Could not reach the provider at ${documentUrl(issuer)}: is it running, and is the issuer right? (${why})`
|
|
109
|
+
) : `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})`,
|
|
108
110
|
"discovery_unreachable"
|
|
109
111
|
);
|
|
110
112
|
}
|
|
@@ -129,6 +131,7 @@ async function discover(issuer, fetcher = fetch) {
|
|
|
129
131
|
jwks: doc.jwks_uri,
|
|
130
132
|
introspection: doc.introspection_endpoint,
|
|
131
133
|
revocation: doc.revocation_endpoint,
|
|
134
|
+
checkSession: doc.check_session_iframe,
|
|
132
135
|
issuer: doc.issuer ?? issuer
|
|
133
136
|
};
|
|
134
137
|
})();
|
|
@@ -137,6 +140,251 @@ async function discover(issuer, fetcher = fetch) {
|
|
|
137
140
|
return pending;
|
|
138
141
|
}
|
|
139
142
|
|
|
143
|
+
// src/verify.ts
|
|
144
|
+
var TokenError = class extends OAuthError {
|
|
145
|
+
constructor(message, code = "invalid_token") {
|
|
146
|
+
super(message, code);
|
|
147
|
+
this.name = "TokenError";
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
function bearerOf(request) {
|
|
151
|
+
const header = request.headers.get("authorization");
|
|
152
|
+
if (!header) return null;
|
|
153
|
+
const match = header.match(/^(?:Bearer|DPoP)\s+(.+)$/i);
|
|
154
|
+
return match ? match[1].trim() : null;
|
|
155
|
+
}
|
|
156
|
+
async function loadJose2() {
|
|
157
|
+
try {
|
|
158
|
+
return await import("./browser-TV25RBLT.mjs");
|
|
159
|
+
} catch {
|
|
160
|
+
throw new OAuthError(
|
|
161
|
+
"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.",
|
|
162
|
+
"jose_missing"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function createTokenVerifier(options) {
|
|
167
|
+
const issuers = (Array.isArray(options.issuer) ? options.issuer : [options.issuer]).filter(Boolean);
|
|
168
|
+
if (!issuers.length) {
|
|
169
|
+
throw new Error("[fluixi/oauth2] createTokenVerifier needs an issuer to accept tokens from.");
|
|
170
|
+
}
|
|
171
|
+
const discoveryIssuer = issuers[0];
|
|
172
|
+
if (options.audience === void 0) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
"[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."
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (options.audience === false && options.authorizedParty === void 0) {
|
|
178
|
+
console.warn(
|
|
179
|
+
"[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."
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
const algorithms = options.algorithms ?? ["RS256"];
|
|
183
|
+
if (algorithms.some((alg) => !alg || alg.toLowerCase() === "none")) {
|
|
184
|
+
throw new Error("[fluixi/oauth2] `none` is not a signing algorithm and cannot be accepted.");
|
|
185
|
+
}
|
|
186
|
+
let keys = null;
|
|
187
|
+
async function keySet() {
|
|
188
|
+
if (keys) return keys;
|
|
189
|
+
const jose = await loadJose2();
|
|
190
|
+
if (options.jwks) {
|
|
191
|
+
keys = jose.createLocalJWKSet(options.jwks);
|
|
192
|
+
return keys;
|
|
193
|
+
}
|
|
194
|
+
let uri = options.jwksUri;
|
|
195
|
+
if (!uri) {
|
|
196
|
+
const endpoints = await discover(discoveryIssuer);
|
|
197
|
+
uri = endpoints.jwks;
|
|
198
|
+
}
|
|
199
|
+
if (!uri) {
|
|
200
|
+
throw new OAuthError(
|
|
201
|
+
`${discoveryIssuer} published no jwks_uri. Give the verifier one directly.`,
|
|
202
|
+
"no_jwks"
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
keys = jose.createRemoteJWKSet(new URL(uri));
|
|
206
|
+
return keys;
|
|
207
|
+
}
|
|
208
|
+
const checkProof = async (claims, token, request) => {
|
|
209
|
+
const jose = await loadJose2();
|
|
210
|
+
const { boundKeyOf, thumbprintOf } = await import("./dpop-O3HWR7AH.mjs");
|
|
211
|
+
const bound = boundKeyOf(claims);
|
|
212
|
+
if (!bound) {
|
|
213
|
+
throw new TokenError(
|
|
214
|
+
"This token is not bound to a key, and this service requires binding.",
|
|
215
|
+
"token_not_bound"
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const proof = request.headers.get("dpop");
|
|
219
|
+
if (!proof) throw new TokenError("The request carried no DPoP proof.", "missing_dpop_proof");
|
|
220
|
+
let payload;
|
|
221
|
+
let header;
|
|
222
|
+
try {
|
|
223
|
+
const result = await jose.jwtVerify(proof, jose.EmbeddedJWK, { typ: "dpop+jwt" });
|
|
224
|
+
payload = result.payload;
|
|
225
|
+
header = result.protectedHeader;
|
|
226
|
+
} catch (cause) {
|
|
227
|
+
throw new TokenError(
|
|
228
|
+
`The DPoP proof did not verify: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
229
|
+
"invalid_dpop_proof"
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
const presented = await thumbprintOf(header.jwk);
|
|
233
|
+
if (presented !== bound) {
|
|
234
|
+
throw new TokenError(
|
|
235
|
+
"The DPoP proof was signed with a different key from the one this token is bound to.",
|
|
236
|
+
"dpop_key_mismatch"
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const url = new URL(request.url);
|
|
240
|
+
url.search = "";
|
|
241
|
+
url.hash = "";
|
|
242
|
+
if (payload.htm !== request.method.toUpperCase()) {
|
|
243
|
+
throw new TokenError("The DPoP proof was made for a different method.", "invalid_dpop_proof");
|
|
244
|
+
}
|
|
245
|
+
if (payload.htu !== url.toString()) {
|
|
246
|
+
throw new TokenError("The DPoP proof was made for a different URL.", "invalid_dpop_proof");
|
|
247
|
+
}
|
|
248
|
+
if (typeof payload.ath === "string") {
|
|
249
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
|
|
250
|
+
let binary = "";
|
|
251
|
+
for (const byte of new Uint8Array(digest)) binary += String.fromCharCode(byte);
|
|
252
|
+
const expected = btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
253
|
+
if (payload.ath !== expected) {
|
|
254
|
+
throw new TokenError("The DPoP proof was made for a different token.", "invalid_dpop_proof");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const age = Math.abs(Date.now() / 1e3 - payload.iat);
|
|
258
|
+
if (!Number.isFinite(age) || age > (options.dpopProofAgeSec ?? 10)) {
|
|
259
|
+
throw new TokenError("The DPoP proof is too old to accept.", "invalid_dpop_proof");
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
const verify = async (token, request) => {
|
|
263
|
+
if (!token) throw new TokenError("No token to verify.", "no_token");
|
|
264
|
+
const jose = await loadJose2();
|
|
265
|
+
let payload;
|
|
266
|
+
try {
|
|
267
|
+
({ payload } = await jose.jwtVerify(token, await keySet(), {
|
|
268
|
+
// jose takes a list and accepts any of them, which is what a provider naming
|
|
269
|
+
// itself two ways needs.
|
|
270
|
+
issuer: issuers,
|
|
271
|
+
...options.audience === false ? {} : { audience: options.audience },
|
|
272
|
+
algorithms,
|
|
273
|
+
clockTolerance: options.clockToleranceSec ?? 5
|
|
274
|
+
}));
|
|
275
|
+
} catch (cause) {
|
|
276
|
+
throw new TokenError(
|
|
277
|
+
`The token was not accepted: ${cause instanceof Error ? cause.message : String(cause)}`
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (options.authorizedParty !== void 0) {
|
|
281
|
+
const allowed = Array.isArray(options.authorizedParty) ? options.authorizedParty : [options.authorizedParty];
|
|
282
|
+
const issuedTo = payload.azp ?? payload.client_id;
|
|
283
|
+
if (typeof issuedTo !== "string" || !allowed.includes(issuedTo)) {
|
|
284
|
+
throw new TokenError("The token was issued to a different client.", "invalid_azp");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (options.dpop) {
|
|
288
|
+
if (!request) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
"[fluixi/oauth2] `dpop: true` needs the request to read its proof. Use `verify.fromRequest(request)` rather than verifying a bare token."
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
await checkProof(payload, token, request);
|
|
294
|
+
}
|
|
295
|
+
if (options.require) {
|
|
296
|
+
const verdict = options.require(payload);
|
|
297
|
+
if (verdict !== true) {
|
|
298
|
+
throw new TokenError(
|
|
299
|
+
typeof verdict === "string" ? verdict : "The token did not meet this service's requirements.",
|
|
300
|
+
"insufficient_claims"
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return payload;
|
|
305
|
+
};
|
|
306
|
+
const verifier = verify;
|
|
307
|
+
verifier.bearerOf = bearerOf;
|
|
308
|
+
verifier.fromRequest = async (request) => {
|
|
309
|
+
const token = verifier.bearerOf(request);
|
|
310
|
+
if (!token) {
|
|
311
|
+
throw new TokenError("The request carried no bearer token.", "no_token");
|
|
312
|
+
}
|
|
313
|
+
return verify(token, request);
|
|
314
|
+
};
|
|
315
|
+
return verifier;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// src/backchannel.ts
|
|
319
|
+
var LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout";
|
|
320
|
+
function memoryLogouts(ttlMs = 14 * 24 * 36e5) {
|
|
321
|
+
const sids = /* @__PURE__ */ new Map();
|
|
322
|
+
const subs = /* @__PURE__ */ new Map();
|
|
323
|
+
const prune = (now) => {
|
|
324
|
+
for (const map of [sids, subs]) for (const [key, at] of map) if (now - at > ttlMs) map.delete(key);
|
|
325
|
+
};
|
|
326
|
+
return {
|
|
327
|
+
add(entry) {
|
|
328
|
+
prune(Date.now());
|
|
329
|
+
if (entry.sid) sids.set(entry.sid, entry.at);
|
|
330
|
+
else if (entry.sub) subs.set(entry.sub, Math.max(entry.at, subs.get(entry.sub) ?? 0));
|
|
331
|
+
},
|
|
332
|
+
ended(session) {
|
|
333
|
+
if (session.sid && sids.has(session.sid)) return true;
|
|
334
|
+
const all = session.sub ? subs.get(session.sub) : void 0;
|
|
335
|
+
return all !== void 0 && session.since <= all;
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function idTokenClaims(idToken) {
|
|
340
|
+
if (!idToken) return {};
|
|
341
|
+
try {
|
|
342
|
+
const part = idToken.split(".")[1] ?? "";
|
|
343
|
+
const json = atob(part.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(part.length / 4) * 4, "="));
|
|
344
|
+
const claims = JSON.parse(json);
|
|
345
|
+
return {
|
|
346
|
+
sid: typeof claims.sid === "string" ? claims.sid : void 0,
|
|
347
|
+
sub: typeof claims.sub === "string" ? claims.sub : void 0,
|
|
348
|
+
iat: typeof claims.iat === "number" ? claims.iat : void 0
|
|
349
|
+
};
|
|
350
|
+
} catch {
|
|
351
|
+
return {};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function headerOf(token) {
|
|
355
|
+
try {
|
|
356
|
+
const part = token.split(".")[0] ?? "";
|
|
357
|
+
return JSON.parse(atob(part.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(part.length / 4) * 4, "=")));
|
|
358
|
+
} catch {
|
|
359
|
+
return {};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function logoutTokenVerifier(options) {
|
|
363
|
+
const verify = createTokenVerifier({
|
|
364
|
+
issuer: options.issuer,
|
|
365
|
+
audience: options.clientId,
|
|
366
|
+
jwksUri: options.jwksUri,
|
|
367
|
+
jwks: options.jwks,
|
|
368
|
+
require: (claims) => {
|
|
369
|
+
const events = claims.events;
|
|
370
|
+
if (!events || typeof events !== "object" || !(LOGOUT_EVENT in events)) return "no logout event";
|
|
371
|
+
if ("nonce" in claims) return "a logout token has no nonce";
|
|
372
|
+
if (typeof claims.sid !== "string" && typeof claims.sub !== "string") return "names no session and no subject";
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
return async (token) => {
|
|
377
|
+
const typ = headerOf(token).typ;
|
|
378
|
+
if (typ !== void 0 && typ !== "logout+jwt") throw new Error("not a logout token");
|
|
379
|
+
const claims = await verify(token);
|
|
380
|
+
return {
|
|
381
|
+
sid: typeof claims.sid === "string" ? claims.sid : void 0,
|
|
382
|
+
sub: typeof claims.sub === "string" ? claims.sub : void 0,
|
|
383
|
+
at: (typeof claims.iat === "number" ? claims.iat : Math.floor(Date.now() / 1e3)) * 1e3
|
|
384
|
+
};
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
140
388
|
// src/pkce.ts
|
|
141
389
|
function base64url(bytes) {
|
|
142
390
|
let binary = "";
|
|
@@ -174,14 +422,117 @@ function expiryFrom(expiresIn, now = Date.now()) {
|
|
|
174
422
|
}
|
|
175
423
|
|
|
176
424
|
// src/server.ts
|
|
177
|
-
|
|
425
|
+
import { defineModule } from "@fluixi/start/app";
|
|
426
|
+
var RETRY_MARK = ".retry";
|
|
427
|
+
var PROMPTS = /* @__PURE__ */ new Set(["none", "login", "consent", "select_account"]);
|
|
428
|
+
var SILENT_REFUSALS = /* @__PURE__ */ new Set([
|
|
429
|
+
"login_required",
|
|
430
|
+
"consent_required",
|
|
431
|
+
"interaction_required",
|
|
432
|
+
"account_selection_required"
|
|
433
|
+
]);
|
|
434
|
+
function checkedCookie(config, sessionState) {
|
|
435
|
+
const name = `${config.cookieName ?? "fx_session"}_checked`;
|
|
436
|
+
const secure = config.secureCookie ?? true;
|
|
437
|
+
const maxAge = Math.max(0, Math.floor(config.silentRecheck ?? 60));
|
|
438
|
+
const value = sessionState ?? "1";
|
|
439
|
+
return { name, header: `${name}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${secure ? "; Secure" : ""}` };
|
|
440
|
+
}
|
|
441
|
+
function sessionStateFrom(url) {
|
|
442
|
+
const value = url.searchParams.get("session_state");
|
|
443
|
+
return value && /^[\w-]{1,128}\.[\w-]{1,64}$/.test(value) ? value : void 0;
|
|
444
|
+
}
|
|
445
|
+
function isPageRequest(request) {
|
|
446
|
+
if (request.method !== "GET") return false;
|
|
447
|
+
const dest = request.headers.get("sec-fetch-dest");
|
|
448
|
+
if (dest) return dest === "document";
|
|
449
|
+
return (request.headers.get("accept") ?? "").includes("text/html");
|
|
450
|
+
}
|
|
451
|
+
function cookieValue(request, name) {
|
|
452
|
+
for (const part of (request.headers.get("cookie") ?? "").split(";")) {
|
|
453
|
+
const [key, ...rest] = part.trim().split("=");
|
|
454
|
+
if (key === name) return rest.join("=");
|
|
455
|
+
}
|
|
456
|
+
return void 0;
|
|
457
|
+
}
|
|
458
|
+
function hasCookie(request, name) {
|
|
459
|
+
return cookieValue(request, name) !== void 0;
|
|
460
|
+
}
|
|
461
|
+
function under(pathname, path) {
|
|
462
|
+
return pathname === path || pathname.startsWith(path.endsWith("/") ? path : `${path}/`);
|
|
463
|
+
}
|
|
464
|
+
function signInRedirect(request, config) {
|
|
465
|
+
if (!isPageRequest(request)) return null;
|
|
466
|
+
const url = new URL(request.url);
|
|
178
467
|
const base = (config.basePath ?? "/auth").replace(/\/$/, "");
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
468
|
+
const guarded = (config.protectedPaths ?? []).some((path) => under(url.pathname, path));
|
|
469
|
+
const mode = guarded ? "required" : config.signIn ?? "manual";
|
|
470
|
+
if (mode === "manual") return null;
|
|
471
|
+
if ([base, ...config.publicPaths ?? []].some((path) => under(url.pathname, path))) return null;
|
|
472
|
+
if (url.searchParams.has("auth_error")) return null;
|
|
473
|
+
if (mode === "silent" && hasCookie(request, checkedCookie(config).name)) return null;
|
|
474
|
+
const login = new URLSearchParams({ returnTo: url.pathname + url.search });
|
|
475
|
+
if (mode === "silent") login.set("prompt", "none");
|
|
476
|
+
return new Response(null, { status: 302, headers: { location: `${base}/login?${login}` } });
|
|
477
|
+
}
|
|
478
|
+
var dpopNonces = /* @__PURE__ */ new Map();
|
|
479
|
+
async function prove(headers, key, method, url, accessToken) {
|
|
480
|
+
headers.dpop = await proofFor(key, { method, url, accessToken, nonce: dpopNonces.get(new URL(url).origin) });
|
|
481
|
+
}
|
|
482
|
+
function learnNonce(url, response) {
|
|
483
|
+
const offered = nonceFrom(response);
|
|
484
|
+
if (!offered) return false;
|
|
485
|
+
const origin = new URL(url).origin;
|
|
486
|
+
const fresh = offered !== dpopNonces.get(origin);
|
|
487
|
+
dpopNonces.set(origin, offered);
|
|
488
|
+
return fresh && !response.ok;
|
|
489
|
+
}
|
|
490
|
+
async function sendAs(session, input, init = {}) {
|
|
491
|
+
const url = String(input);
|
|
492
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
493
|
+
const key = session.dpop && session.tokenType?.toLowerCase() === "dpop" ? await importDpopKey(session.dpop) : null;
|
|
494
|
+
const send = async () => {
|
|
495
|
+
const credentials = {
|
|
496
|
+
authorization: `${key ? "DPoP" : "Bearer"} ${session.accessToken}`
|
|
497
|
+
};
|
|
498
|
+
if (key) await prove(credentials, key, method, url, session.accessToken);
|
|
499
|
+
const headers = new Headers(init.headers);
|
|
500
|
+
for (const [name, value] of Object.entries(credentials)) headers.set(name, value);
|
|
501
|
+
return fetch(url, { ...init, method, headers });
|
|
502
|
+
};
|
|
503
|
+
let response = await send();
|
|
504
|
+
if (key && learnNonce(url, response)) response = await send();
|
|
505
|
+
return response;
|
|
506
|
+
}
|
|
507
|
+
var withToken = (url, session) => sendAs(session, url);
|
|
508
|
+
var ProviderUnavailable = class extends OAuthError {
|
|
509
|
+
constructor(retryAfterMs) {
|
|
510
|
+
super("The authorization server is unavailable.", "temporarily_unavailable");
|
|
511
|
+
this.retryAfterMs = retryAfterMs;
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
var AnswerLost = class extends OAuthError {
|
|
515
|
+
constructor(what) {
|
|
516
|
+
super(`The token request's answer was lost (${what}).`, "answer_lost");
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
var NOT_SENT = /* @__PURE__ */ new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "ENETUNREACH", "EHOSTUNREACH", "UND_ERR_CONNECT_TIMEOUT"]);
|
|
520
|
+
var RETRY_DEFAULT_MS = 5e3;
|
|
521
|
+
var RETRY_MAX_MS = 3e5;
|
|
522
|
+
function retryAfter(response) {
|
|
523
|
+
const header = response.headers.get("retry-after");
|
|
524
|
+
if (!header) return RETRY_DEFAULT_MS;
|
|
525
|
+
const seconds = Number(header);
|
|
526
|
+
const ms = Number.isFinite(seconds) ? seconds * 1e3 : Date.parse(header) - Date.now();
|
|
527
|
+
return Number.isFinite(ms) ? Math.min(Math.max(ms, 1e3), RETRY_MAX_MS) : RETRY_DEFAULT_MS;
|
|
528
|
+
}
|
|
529
|
+
function unanswered(error) {
|
|
530
|
+
const code = error?.cause?.code;
|
|
531
|
+
if (code && NOT_SENT.has(code)) return new ProviderUnavailable(RETRY_DEFAULT_MS);
|
|
532
|
+
return new AnswerLost(code ?? (error instanceof Error ? error.name : "no answer"));
|
|
533
|
+
}
|
|
534
|
+
function tokenClient(config) {
|
|
535
|
+
const timeoutMs = config.tokenTimeoutMs ?? 1e4;
|
|
185
536
|
let endpoints = config.endpoints ?? null;
|
|
186
537
|
const resolve = async () => {
|
|
187
538
|
if (endpoints) return endpoints;
|
|
@@ -191,10 +542,6 @@ function oauthRoutes(config) {
|
|
|
191
542
|
endpoints = await discover(config.issuer);
|
|
192
543
|
return endpoints;
|
|
193
544
|
};
|
|
194
|
-
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
|
|
195
|
-
status,
|
|
196
|
-
headers: { "content-type": "application/json", ...headers }
|
|
197
|
-
});
|
|
198
545
|
const clientAuth = {
|
|
199
546
|
clientId: config.clientId,
|
|
200
547
|
// `client_secret_post` by default here, which is what this half has always sent, so
|
|
@@ -205,34 +552,151 @@ function oauthRoutes(config) {
|
|
|
205
552
|
keyId: config.keyId,
|
|
206
553
|
assertionAlgorithm: config.assertionAlgorithm
|
|
207
554
|
};
|
|
208
|
-
async function tokenRequest(body) {
|
|
555
|
+
async function tokenRequest(body, key = null) {
|
|
209
556
|
const { token } = await resolve();
|
|
210
|
-
const
|
|
211
|
-
|
|
557
|
+
const attempt = async () => {
|
|
558
|
+
const headers = {
|
|
559
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
560
|
+
};
|
|
561
|
+
const form = new URLSearchParams(body);
|
|
562
|
+
await applyClientAuth(clientAuth, token, headers, form);
|
|
563
|
+
if (key) await prove(headers, key, "POST", token);
|
|
564
|
+
return fetch(token, { method: "POST", headers, body: form.toString(), signal: AbortSignal.timeout(timeoutMs) });
|
|
212
565
|
};
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
566
|
+
let response;
|
|
567
|
+
let payload = null;
|
|
568
|
+
try {
|
|
569
|
+
response = await attempt();
|
|
570
|
+
if (key && learnNonce(token, response)) response = await attempt();
|
|
571
|
+
if (response.status === 429 || response.status >= 500) throw new ProviderUnavailable(retryAfter(response));
|
|
572
|
+
payload = await response.json().catch(() => null);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
throw error instanceof OAuthError ? error : unanswered(error);
|
|
575
|
+
}
|
|
217
576
|
if (!response.ok) {
|
|
218
577
|
throw new OAuthError(
|
|
219
|
-
String(payload
|
|
220
|
-
String(payload
|
|
578
|
+
String(payload?.error ?? "token_request_failed"),
|
|
579
|
+
String(payload?.error ?? "token_request_failed")
|
|
221
580
|
);
|
|
222
581
|
}
|
|
582
|
+
if (typeof payload?.access_token !== "string" || !payload.access_token) {
|
|
583
|
+
throw new AnswerLost("a success without an access token");
|
|
584
|
+
}
|
|
223
585
|
return payload;
|
|
224
586
|
}
|
|
587
|
+
return { resolve, tokenRequest, clientAuth };
|
|
588
|
+
}
|
|
589
|
+
var renewals = /* @__PURE__ */ new Map();
|
|
590
|
+
var RENEWAL_REUSE_MS = 3e4;
|
|
591
|
+
var SPENT_MS = 5 * 6e4;
|
|
592
|
+
function renew(current, tokenRequest) {
|
|
593
|
+
const refreshToken = current.refreshToken;
|
|
594
|
+
const now = Date.now();
|
|
595
|
+
for (const [key, entry] of renewals) if (entry.until <= now) renewals.delete(key);
|
|
596
|
+
const known = renewals.get(refreshToken);
|
|
597
|
+
if (known) return known.result;
|
|
598
|
+
const result = (async () => {
|
|
599
|
+
const key = current.dpop ? await importDpopKey(current.dpop) : null;
|
|
600
|
+
const payload = await tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken }, key);
|
|
601
|
+
return {
|
|
602
|
+
accessToken: String(payload.access_token ?? ""),
|
|
603
|
+
// A provider that rotates refresh tokens sends a new one; one that does not
|
|
604
|
+
// expects the old one to keep working.
|
|
605
|
+
refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : refreshToken,
|
|
606
|
+
idToken: typeof payload.id_token === "string" ? payload.id_token : current.idToken,
|
|
607
|
+
expiresAt: expiryFrom(payload.expires_in),
|
|
608
|
+
tokenType: typeof payload.token_type === "string" ? payload.token_type : current.tokenType,
|
|
609
|
+
dpop: current.dpop,
|
|
610
|
+
sessionState: current.sessionState
|
|
611
|
+
};
|
|
612
|
+
})().catch((error) => {
|
|
613
|
+
if (error instanceof ProviderUnavailable) {
|
|
614
|
+
renewals.set(refreshToken, { until: Date.now() + error.retryAfterMs, result });
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
if (error instanceof OAuthError) {
|
|
618
|
+
renewals.set(refreshToken, { until: Date.now() + SPENT_MS, result });
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
renewals.delete(refreshToken);
|
|
622
|
+
throw error;
|
|
623
|
+
});
|
|
624
|
+
renewals.set(refreshToken, { until: now + RENEWAL_REUSE_MS, result });
|
|
625
|
+
return result;
|
|
626
|
+
}
|
|
627
|
+
var EXPIRY_SKEW_MS = 3e4;
|
|
628
|
+
var expired = (session) => session.expiresAt !== void 0 && session.expiresAt - EXPIRY_SKEW_MS <= Date.now();
|
|
629
|
+
var defaultLogouts = /* @__PURE__ */ new WeakMap();
|
|
630
|
+
function logoutsOf(config) {
|
|
631
|
+
if (config.logouts) return config.logouts;
|
|
632
|
+
let store = defaultLogouts.get(config);
|
|
633
|
+
if (!store) defaultLogouts.set(config, store = memoryLogouts());
|
|
634
|
+
return store;
|
|
635
|
+
}
|
|
636
|
+
async function endedAtProvider(config, session) {
|
|
637
|
+
const id = idTokenClaims(session.idToken);
|
|
638
|
+
if (!id.sid && !id.sub) return false;
|
|
639
|
+
return logoutsOf(config).ended({ sid: id.sid, sub: id.sub, since: (id.iat ?? 0) * 1e3 });
|
|
640
|
+
}
|
|
641
|
+
function oauthRoutes(config) {
|
|
642
|
+
const { resolve, tokenRequest, clientAuth } = tokenClient(config);
|
|
643
|
+
const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), {
|
|
644
|
+
status,
|
|
645
|
+
headers: { "content-type": "application/json", ...headers }
|
|
646
|
+
});
|
|
647
|
+
const base = (config.basePath ?? "/auth").replace(/\/$/, "");
|
|
648
|
+
const scopes = config.scopes ?? ["openid", "profile", "email"];
|
|
649
|
+
const cookie = createSessionCookie({
|
|
650
|
+
secret: config.cookieSecret,
|
|
651
|
+
name: config.cookieName,
|
|
652
|
+
secure: config.secureCookie
|
|
653
|
+
});
|
|
225
654
|
return async function middleware(request, next) {
|
|
226
655
|
const url = new URL(request.url);
|
|
227
656
|
if (!url.pathname.startsWith(`${base}/`)) return next();
|
|
228
657
|
const route = url.pathname.slice(base.length);
|
|
229
658
|
const session = async () => {
|
|
230
|
-
|
|
659
|
+
const current = await cookie.read(request);
|
|
660
|
+
return current && await endedAtProvider(config, current) ? null : current;
|
|
231
661
|
};
|
|
662
|
+
if (route === "/backchannel-logout") {
|
|
663
|
+
const headers = { "cache-control": "no-store" };
|
|
664
|
+
if (request.method !== "POST") return json({ error: "invalid_request" }, 405, headers);
|
|
665
|
+
try {
|
|
666
|
+
const form = await request.formData();
|
|
667
|
+
const token = form.get("logout_token");
|
|
668
|
+
if (typeof token !== "string" || !token) throw new Error("no logout_token");
|
|
669
|
+
const endpoints = await resolve();
|
|
670
|
+
const issuer = endpoints.issuer ?? config.issuer;
|
|
671
|
+
if (!issuer) throw new Error("no issuer");
|
|
672
|
+
if (!endpoints.jwks) throw new Error("no jwks_uri");
|
|
673
|
+
const jwks = await (await fetch(endpoints.jwks)).json();
|
|
674
|
+
const entry = await logoutTokenVerifier({ issuer, clientId: config.clientId, jwks })(token);
|
|
675
|
+
await logoutsOf(config).add(entry);
|
|
676
|
+
return new Response(null, { status: 200, headers });
|
|
677
|
+
} catch {
|
|
678
|
+
return json({ error: "invalid_request" }, 400, headers);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
232
681
|
if (route === "/login") {
|
|
233
|
-
|
|
682
|
+
let authorization;
|
|
683
|
+
try {
|
|
684
|
+
({ authorization } = await resolve());
|
|
685
|
+
} catch (error) {
|
|
686
|
+
if (!(error instanceof OAuthError) || error.code !== "discovery_unreachable") throw error;
|
|
687
|
+
const back = safeReturnTo(url.searchParams.get("returnTo"), {
|
|
688
|
+
fallback: config.defaultReturnTo ?? "/",
|
|
689
|
+
origin: url.origin,
|
|
690
|
+
allowedOrigins: config.allowedReturnOrigins
|
|
691
|
+
});
|
|
692
|
+
const target2 = new URL(back, url.origin);
|
|
693
|
+
target2.searchParams.set("auth_error", "provider_unreachable");
|
|
694
|
+
const location2 = target2.origin === url.origin ? target2.pathname + target2.search : target2.toString();
|
|
695
|
+
return new Response(null, { status: 302, headers: { location: location2 } });
|
|
696
|
+
}
|
|
234
697
|
const { verifier, challenge } = await createPkcePair();
|
|
235
|
-
const
|
|
698
|
+
const retry = url.searchParams.get("retry") === "1";
|
|
699
|
+
const state = createStateValue() + (retry ? RETRY_MARK : "");
|
|
236
700
|
const target = new URL(authorization);
|
|
237
701
|
target.searchParams.set("response_type", "code");
|
|
238
702
|
target.searchParams.set("client_id", config.clientId);
|
|
@@ -241,6 +705,8 @@ function oauthRoutes(config) {
|
|
|
241
705
|
target.searchParams.set("state", state);
|
|
242
706
|
target.searchParams.set("code_challenge", challenge);
|
|
243
707
|
target.searchParams.set("code_challenge_method", "S256");
|
|
708
|
+
const prompt = url.searchParams.get("prompt");
|
|
709
|
+
if (prompt && PROMPTS.has(prompt)) target.searchParams.set("prompt", prompt);
|
|
244
710
|
const pendingCookie = await cookie.write(
|
|
245
711
|
{
|
|
246
712
|
accessToken: "",
|
|
@@ -253,7 +719,8 @@ function oauthRoutes(config) {
|
|
|
253
719
|
fallback: "",
|
|
254
720
|
origin: url.origin,
|
|
255
721
|
allowedOrigins: config.allowedReturnOrigins
|
|
256
|
-
}) || void 0
|
|
722
|
+
}) || void 0,
|
|
723
|
+
silent: prompt === "none" || void 0
|
|
257
724
|
}
|
|
258
725
|
},
|
|
259
726
|
// Ten minutes: long enough to sign in, short enough that an abandoned attempt
|
|
@@ -272,24 +739,62 @@ function oauthRoutes(config) {
|
|
|
272
739
|
const current = await session();
|
|
273
740
|
const pending = current?.pending;
|
|
274
741
|
const error = url.searchParams.get("error");
|
|
275
|
-
if (error)
|
|
742
|
+
if (error) {
|
|
743
|
+
const ours = !!pending && timingSafeEqual(pending.state, url.searchParams.get("state") ?? "");
|
|
744
|
+
let location2 = safeReturnTo(ours ? pending.returnTo ?? config.defaultReturnTo : config.defaultReturnTo, {
|
|
745
|
+
fallback: "/",
|
|
746
|
+
origin: url.origin,
|
|
747
|
+
allowedOrigins: config.allowedReturnOrigins
|
|
748
|
+
});
|
|
749
|
+
const headers = new Headers();
|
|
750
|
+
headers.append("set-cookie", cookie.clear());
|
|
751
|
+
if (ours && pending.silent && SILENT_REFUSALS.has(error)) {
|
|
752
|
+
headers.append("set-cookie", checkedCookie(config, sessionStateFrom(url)).header);
|
|
753
|
+
} else {
|
|
754
|
+
const target = new URL(location2, url.origin);
|
|
755
|
+
target.searchParams.set("auth_error", error);
|
|
756
|
+
location2 = target.origin === url.origin ? target.pathname + target.search : target.toString();
|
|
757
|
+
}
|
|
758
|
+
headers.set("location", location2);
|
|
759
|
+
return new Response(null, { status: 302, headers });
|
|
760
|
+
}
|
|
276
761
|
const code = url.searchParams.get("code");
|
|
277
762
|
const state = url.searchParams.get("state");
|
|
278
763
|
if (!code) return json({ error: "no_code" }, 400);
|
|
279
764
|
if (!pending || !state || !timingSafeEqual(pending.state, state)) {
|
|
280
|
-
|
|
765
|
+
const home = safeReturnTo(config.defaultReturnTo, { fallback: "/", origin: url.origin, allowedOrigins: config.allowedReturnOrigins });
|
|
766
|
+
if (current?.accessToken) return new Response(null, { status: 302, headers: { location: home } });
|
|
767
|
+
if (!state?.endsWith(RETRY_MARK)) {
|
|
768
|
+
return new Response(null, { status: 302, headers: { location: `${base}/login?retry=1` } });
|
|
769
|
+
}
|
|
770
|
+
const target = new URL(home, url.origin);
|
|
771
|
+
target.searchParams.set("auth_error", "state_mismatch");
|
|
772
|
+
return new Response(null, {
|
|
773
|
+
status: 302,
|
|
774
|
+
headers: {
|
|
775
|
+
location: target.origin === url.origin ? target.pathname + target.search : target.toString(),
|
|
776
|
+
"set-cookie": cookie.clear()
|
|
777
|
+
}
|
|
778
|
+
});
|
|
281
779
|
}
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
780
|
+
const key = config.dpop ? await createDpopKey(config.dpopAlgorithm, { extractable: true }) : null;
|
|
781
|
+
const payload = await tokenRequest(
|
|
782
|
+
{
|
|
783
|
+
grant_type: "authorization_code",
|
|
784
|
+
code,
|
|
785
|
+
redirect_uri: config.redirectUri,
|
|
786
|
+
code_verifier: pending.verifier
|
|
787
|
+
},
|
|
788
|
+
key
|
|
789
|
+
);
|
|
288
790
|
const signedIn = await cookie.write({
|
|
289
791
|
accessToken: String(payload.access_token ?? ""),
|
|
290
792
|
refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : void 0,
|
|
291
793
|
idToken: typeof payload.id_token === "string" ? payload.id_token : void 0,
|
|
292
|
-
expiresAt: expiryFrom(payload.expires_in)
|
|
794
|
+
expiresAt: expiryFrom(payload.expires_in),
|
|
795
|
+
tokenType: typeof payload.token_type === "string" ? payload.token_type : void 0,
|
|
796
|
+
dpop: key ? await exportDpopKey(key) : void 0,
|
|
797
|
+
sessionState: sessionStateFrom(url)
|
|
293
798
|
});
|
|
294
799
|
return new Response(null, {
|
|
295
800
|
status: 302,
|
|
@@ -309,32 +814,27 @@ function oauthRoutes(config) {
|
|
|
309
814
|
if (route === "/refresh") {
|
|
310
815
|
const current = await session();
|
|
311
816
|
if (!current?.refreshToken) return json({ error: "no_session" }, 401);
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
});
|
|
316
|
-
const refreshed = await cookie.write({
|
|
317
|
-
accessToken: String(payload.access_token ?? ""),
|
|
318
|
-
// A provider that rotates refresh tokens sends a new one; one that does not
|
|
319
|
-
// expects the old one to keep working.
|
|
320
|
-
refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : current.refreshToken,
|
|
321
|
-
idToken: current.idToken,
|
|
322
|
-
expiresAt: expiryFrom(payload.expires_in)
|
|
323
|
-
});
|
|
324
|
-
return json({ ok: true }, 200, { "set-cookie": refreshed });
|
|
817
|
+
const renewed = await renew(current, tokenRequest);
|
|
818
|
+
if (!renewed) return json({ error: "refresh_refused" }, 401, { "set-cookie": cookie.clear() });
|
|
819
|
+
return json({ ok: true }, 200, { "set-cookie": await cookie.write(renewed) });
|
|
325
820
|
}
|
|
326
821
|
if (route === "/me") {
|
|
327
822
|
const current = await session();
|
|
328
823
|
if (!current?.accessToken) return json({ error: "no_session" }, 401);
|
|
329
824
|
const { userinfo } = await resolve();
|
|
330
825
|
if (!userinfo) return json({ error: "no_userinfo" }, 501);
|
|
331
|
-
const response = await
|
|
332
|
-
headers: { authorization: `Bearer ${current.accessToken}` }
|
|
333
|
-
});
|
|
826
|
+
const response = await withToken(userinfo, current);
|
|
334
827
|
if (!response.ok) return json({ error: "userinfo_failed" }, response.status);
|
|
335
828
|
const claims = await response.json();
|
|
336
829
|
return json(config.mapUser ? config.mapUser(claims, current.accessToken) : claims);
|
|
337
830
|
}
|
|
831
|
+
if (route === "/session-check") {
|
|
832
|
+
const { checkSession } = await resolve().catch(() => ({ checkSession: void 0 }));
|
|
833
|
+
const current = await session();
|
|
834
|
+
const checked = cookieValue(request, checkedCookie(config).name);
|
|
835
|
+
const state = current?.accessToken ? current.sessionState : checked !== "1" ? checked : void 0;
|
|
836
|
+
return json({ frame: checkSession ?? null, clientId: config.clientId, state: state ?? null });
|
|
837
|
+
}
|
|
338
838
|
if (route === "/revoke") {
|
|
339
839
|
const current = await session();
|
|
340
840
|
const hint = url.searchParams.get("hint") === "access_token" ? "access_token" : "refresh_token";
|
|
@@ -357,31 +857,33 @@ function oauthRoutes(config) {
|
|
|
357
857
|
const { endSession } = await resolve().catch(() => ({ endSession: void 0 }));
|
|
358
858
|
const { revocation } = await resolve().catch(() => ({ revocation: void 0 }));
|
|
359
859
|
if (revocation && current?.refreshToken) {
|
|
360
|
-
const
|
|
860
|
+
const headers2 = {
|
|
361
861
|
"content-type": "application/x-www-form-urlencoded"
|
|
362
862
|
};
|
|
363
863
|
const form = new URLSearchParams({
|
|
364
864
|
token: current.refreshToken,
|
|
365
865
|
token_type_hint: "refresh_token"
|
|
366
866
|
});
|
|
367
|
-
await applyClientAuth(clientAuth, revocation,
|
|
368
|
-
await fetch(revocation, { method: "POST", headers, body: form.toString() }).catch(
|
|
867
|
+
await applyClientAuth(clientAuth, revocation, headers2, form);
|
|
868
|
+
await fetch(revocation, { method: "POST", headers: headers2, body: form.toString() }).catch(
|
|
369
869
|
() => void 0
|
|
370
870
|
);
|
|
371
871
|
}
|
|
372
872
|
const cleared = cookie.clear();
|
|
873
|
+
const headers = new Headers();
|
|
874
|
+
headers.append("set-cookie", cleared);
|
|
875
|
+
headers.append("set-cookie", checkedCookie(config).header);
|
|
373
876
|
if (endSession && current?.idToken) {
|
|
374
877
|
const target = new URL(endSession);
|
|
375
878
|
target.searchParams.set("id_token_hint", current.idToken);
|
|
376
879
|
if (config.defaultReturnTo) {
|
|
377
880
|
target.searchParams.set("post_logout_redirect_uri", config.defaultReturnTo);
|
|
378
881
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
headers: { location: target.toString(), "set-cookie": cleared }
|
|
382
|
-
});
|
|
882
|
+
headers.set("location", target.toString());
|
|
883
|
+
return new Response(null, { status: 302, headers });
|
|
383
884
|
}
|
|
384
|
-
|
|
885
|
+
headers.set("content-type", "application/json");
|
|
886
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
|
|
385
887
|
}
|
|
386
888
|
return next();
|
|
387
889
|
};
|
|
@@ -392,6 +894,7 @@ function sessionLocals(config) {
|
|
|
392
894
|
name: config.cookieName,
|
|
393
895
|
secure: config.secureCookie
|
|
394
896
|
});
|
|
897
|
+
const { tokenRequest } = tokenClient(config);
|
|
395
898
|
const ttl = config.userCacheMs ?? 3e4;
|
|
396
899
|
const cache2 = /* @__PURE__ */ new Map();
|
|
397
900
|
const remember = (token, user) => {
|
|
@@ -406,23 +909,61 @@ function sessionLocals(config) {
|
|
|
406
909
|
return async function middleware(request, next) {
|
|
407
910
|
const { getRequestLocals } = await import("@fluixi/server");
|
|
408
911
|
const locals = getRequestLocals(request);
|
|
409
|
-
|
|
410
|
-
|
|
912
|
+
let session = await cookie.read(request);
|
|
913
|
+
let setCookie;
|
|
914
|
+
const respond = async (response) => {
|
|
915
|
+
if (!setCookie) return response;
|
|
916
|
+
const copy = new Response(response.body, response);
|
|
917
|
+
copy.headers.append("set-cookie", setCookie);
|
|
918
|
+
return copy;
|
|
919
|
+
};
|
|
920
|
+
const renewOrDrop = async (current2) => {
|
|
921
|
+
const renewed = current2.refreshToken ? await renew(current2, tokenRequest) : null;
|
|
922
|
+
setCookie = renewed ? await cookie.write(renewed) : cookie.clear();
|
|
923
|
+
return renewed;
|
|
924
|
+
};
|
|
925
|
+
let paused = false;
|
|
926
|
+
const keep = (current2) => (error) => {
|
|
927
|
+
if (error instanceof ProviderUnavailable) paused = true;
|
|
928
|
+
return current2;
|
|
929
|
+
};
|
|
930
|
+
if (session?.accessToken && expired(session)) {
|
|
931
|
+
session = await renewOrDrop(session).catch(keep(session));
|
|
932
|
+
}
|
|
933
|
+
const signedOut = async () => {
|
|
934
|
+
const redirect = signInRedirect(request, config);
|
|
935
|
+
if (redirect) return respond(redirect);
|
|
411
936
|
locals.auth = { user: null, session: null };
|
|
412
|
-
return next();
|
|
937
|
+
return respond(await next());
|
|
938
|
+
};
|
|
939
|
+
if (!session?.accessToken) return signedOut();
|
|
940
|
+
if (await endedAtProvider(config, session)) {
|
|
941
|
+
setCookie = cookie.clear();
|
|
942
|
+
session = null;
|
|
943
|
+
return signedOut();
|
|
413
944
|
}
|
|
414
|
-
const
|
|
945
|
+
const live = session;
|
|
946
|
+
const fetchAs = (input, init) => sendAs(live, input, init);
|
|
947
|
+
const cached = cache2.get(live.accessToken);
|
|
415
948
|
if (cached && cached.until > Date.now()) {
|
|
416
|
-
locals.auth = { user: cached.user, session: { expiresAt:
|
|
417
|
-
return next();
|
|
949
|
+
locals.auth = { user: cached.user, session: { expiresAt: live.expiresAt }, fetch: fetchAs };
|
|
950
|
+
return respond(await next());
|
|
418
951
|
}
|
|
419
952
|
let user = null;
|
|
953
|
+
let unreachable = false;
|
|
420
954
|
try {
|
|
421
955
|
const endpoints = config.endpoints ?? (config.issuer ? await discover(config.issuer) : null);
|
|
422
956
|
if (endpoints?.userinfo) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
957
|
+
let response = await withToken(endpoints.userinfo, session);
|
|
958
|
+
if (response.status === 401 && !setCookie && !paused) {
|
|
959
|
+
session = await renewOrDrop(session).catch(keep(session));
|
|
960
|
+
if (!session) return signedOut();
|
|
961
|
+
response = await withToken(endpoints.userinfo, session);
|
|
962
|
+
}
|
|
963
|
+
if (response.status === 401) {
|
|
964
|
+
if (!paused) setCookie = cookie.clear();
|
|
965
|
+
return signedOut();
|
|
966
|
+
}
|
|
426
967
|
if (response.ok) {
|
|
427
968
|
const claims = await response.json();
|
|
428
969
|
user = config.mapUser ? config.mapUser(claims, session.accessToken) : claims;
|
|
@@ -430,12 +971,27 @@ function sessionLocals(config) {
|
|
|
430
971
|
}
|
|
431
972
|
}
|
|
432
973
|
} catch {
|
|
974
|
+
unreachable = true;
|
|
433
975
|
}
|
|
434
|
-
|
|
435
|
-
|
|
976
|
+
if (!session) return signedOut();
|
|
977
|
+
const current = session;
|
|
978
|
+
const fetchCurrent = (input, init) => sendAs(current, input, init);
|
|
979
|
+
locals.auth = user ? { user, session: { expiresAt: current.expiresAt }, fetch: fetchCurrent } : { user: null, session: null, ...unreachable ? { unreachable: true } : {} };
|
|
980
|
+
return respond(await next());
|
|
436
981
|
};
|
|
437
982
|
}
|
|
983
|
+
async function requestAuth(request) {
|
|
984
|
+
const { getLocals, getRequestLocals } = await import("@fluixi/server");
|
|
985
|
+
const locals = request ? getRequestLocals(request) : getLocals();
|
|
986
|
+
return locals.auth ?? { user: null, session: null };
|
|
987
|
+
}
|
|
988
|
+
function authServerModule(config) {
|
|
989
|
+
return defineModule({ name: "oauth2-server", middleware: [oauthRoutes(config), sessionLocals(config)] });
|
|
990
|
+
}
|
|
438
991
|
export {
|
|
992
|
+
authServerModule,
|
|
993
|
+
memoryLogouts,
|
|
439
994
|
oauthRoutes,
|
|
995
|
+
requestAuth,
|
|
440
996
|
sessionLocals
|
|
441
997
|
};
|