@eminuckan/spine 0.2.2 → 0.3.0
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/CHANGELOG.md +42 -0
- package/CODE_OF_CONDUCT.md +39 -0
- package/CONTRIBUTING.md +78 -0
- package/MAINTAINERS.md +43 -0
- package/README.md +111 -55
- package/ROADMAP.md +58 -0
- package/SECURITY.md +37 -0
- package/dist/access/evaluator.d.ts +3 -0
- package/dist/access/evaluator.d.ts.map +1 -0
- package/dist/access/index.d.ts +3 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +4 -0
- package/dist/access/types.d.ts +38 -0
- package/dist/access/types.d.ts.map +1 -0
- package/dist/access-CGrsDMwu.js +61 -0
- package/dist/access-CGrsDMwu.js.map +1 -0
- package/dist/api-client/api-config.server.d.ts +27 -0
- package/dist/api-client/api-config.server.d.ts.map +1 -1
- package/dist/api-client/error-codes.d.ts +6 -31
- package/dist/api-client/error-codes.d.ts.map +1 -1
- package/dist/api-client/index.js +3 -3
- package/dist/api-client/server.d.ts +1 -1
- package/dist/api-client/server.d.ts.map +1 -1
- package/dist/api-client/server.js +4 -4
- package/dist/{api-client-DsrxK05Z.js → api-client-CgZUMukO.js} +9 -26
- package/dist/api-client-CgZUMukO.js.map +1 -0
- package/dist/auth/auth.server.d.ts +21 -16
- package/dist/auth/auth.server.d.ts.map +1 -1
- package/dist/auth/authorization-parameters.d.ts +5 -0
- package/dist/auth/authorization-parameters.d.ts.map +1 -0
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/redis-session-storage.server.d.ts +18 -1
- package/dist/auth/redis-session-storage.server.d.ts.map +1 -1
- package/dist/auth/route-protection.server.d.ts +20 -4
- package/dist/auth/route-protection.server.d.ts.map +1 -1
- package/dist/auth/server.d.ts +1 -1
- package/dist/auth/server.d.ts.map +1 -1
- package/dist/auth/server.js +5 -5
- package/dist/auth/types.d.ts +88 -21
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/auth.server-D5oiz9aF.js +1066 -0
- package/dist/auth.server-D5oiz9aF.js.map +1 -0
- package/dist/auth.server-lC94lmlx.js +5 -0
- package/dist/identity/identity-context.server.d.ts +2 -0
- package/dist/identity/identity-context.server.d.ts.map +1 -1
- package/dist/identity/identity-store.d.ts +19 -2
- package/dist/identity/identity-store.d.ts.map +1 -1
- package/dist/identity/index.js +2 -2
- package/dist/identity/server.js +3 -3
- package/dist/identity/types.d.ts +3 -1
- package/dist/identity/types.d.ts.map +1 -1
- package/dist/{identity-CFa1txoj.js → identity-C5un-xAs.js} +104 -60
- package/dist/identity-C5un-xAs.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -10
- package/dist/logging/index.js +1 -1
- package/dist/{logging-_h2mi3L9.js → logging-DviiAkLx.js} +2 -2
- package/dist/{logging-_h2mi3L9.js.map → logging-DviiAkLx.js.map} +1 -1
- package/dist/permission-service-CvGpwI0d.js +95 -0
- package/dist/permission-service-CvGpwI0d.js.map +1 -0
- package/dist/permissions/constants.d.ts +3 -0
- package/dist/permissions/constants.d.ts.map +1 -1
- package/dist/permissions/index.js +2 -1
- package/dist/permissions/permission-store.d.ts +1 -1
- package/dist/permissions/types.d.ts +1 -1
- package/dist/permissions/types.d.ts.map +1 -1
- package/dist/{permissions-D5ScGGnd.js → permissions-_n1hIG7Q.js} +8 -106
- package/dist/permissions-_n1hIG7Q.js.map +1 -0
- package/dist/query-client/index.js +1 -1
- package/dist/query-client/types.d.ts +9 -9
- package/dist/query-client/types.d.ts.map +1 -1
- package/dist/{query-client-Bpe08pk9.js → query-client-Bh3TyB8W.js} +2 -2
- package/dist/query-client-Bh3TyB8W.js.map +1 -0
- package/dist/react-router/index.js +12 -10
- package/dist/react-router/server.js +19 -17
- package/dist/redis-session-storage.server-v2VcpFsQ.js +500 -0
- package/dist/redis-session-storage.server-v2VcpFsQ.js.map +1 -0
- package/dist/{server-BHfKHKF_.js → server-B6PCxc-T.js} +39 -87
- package/dist/server-B6PCxc-T.js.map +1 -0
- package/dist/{server-DZmcd2Lr.js → server-C96NMFBy.js} +18 -10
- package/dist/server-C96NMFBy.js.map +1 -0
- package/dist/{server-BxvRxjCK.js → server-Ca-3DJf2.js} +44 -11
- package/dist/server-Ca-3DJf2.js.map +1 -0
- package/dist/{server-C3L8J7iE.js → server-Dxsww9v9.js} +6 -5
- package/dist/server-Dxsww9v9.js.map +1 -0
- package/dist/{server-CouYoZiw.js → server-hGrElJMG.js} +2 -2
- package/dist/{server-CouYoZiw.js.map → server-hGrElJMG.js.map} +1 -1
- package/dist/server.js +19 -17
- package/dist/signalr/index.js +1 -1
- package/dist/signalr/types.d.ts +1 -1
- package/dist/{signalr-SJ-1sIxw.js → signalr-k-70VHKo.js} +3 -2
- package/dist/signalr-k-70VHKo.js.map +1 -0
- package/dist/tenant/index.js +2 -2
- package/dist/tenant/server.js +4 -4
- package/dist/tenant/tenant-context.d.ts +17 -1
- package/dist/tenant/tenant-context.d.ts.map +1 -1
- package/dist/tenant/tenant-cookie.server.d.ts.map +1 -1
- package/dist/tenant/tenant-store.d.ts +54 -9
- package/dist/tenant/tenant-store.d.ts.map +1 -1
- package/dist/tenant/tenant.server.d.ts +20 -1
- package/dist/tenant/tenant.server.d.ts.map +1 -1
- package/dist/tenant/types.d.ts +29 -9
- package/dist/tenant/types.d.ts.map +1 -1
- package/dist/tenant-DfLemb6n.js +384 -0
- package/dist/tenant-DfLemb6n.js.map +1 -0
- package/dist/{types-BnKaWdfe.js → types-BaaE6wjY.js} +1 -1
- package/dist/{types-BnKaWdfe.js.map → types-BaaE6wjY.js.map} +1 -1
- package/docs/adapters.md +87 -0
- package/docs/architecture.md +193 -0
- package/docs/backend-adaptation.md +249 -0
- package/docs/codex-for-oss-application.md +31 -0
- package/docs/codex-maintenance.md +47 -0
- package/docs/installation.md +213 -0
- package/docs/module-reference.md +207 -0
- package/docs/releasing.md +92 -0
- package/package.json +27 -4
- package/dist/api-client-DsrxK05Z.js.map +0 -1
- package/dist/auth.server-BPiCtlJE.js +0 -5
- package/dist/auth.server-Cx2h2qu3.js +0 -724
- package/dist/auth.server-Cx2h2qu3.js.map +0 -1
- package/dist/identity-CFa1txoj.js.map +0 -1
- package/dist/permissions-D5ScGGnd.js.map +0 -1
- package/dist/query-client-Bpe08pk9.js.map +0 -1
- package/dist/redis-session-storage.server-BR_0y-QO.js +0 -250
- package/dist/redis-session-storage.server-BR_0y-QO.js.map +0 -1
- package/dist/server-BHfKHKF_.js.map +0 -1
- package/dist/server-BxvRxjCK.js.map +0 -1
- package/dist/server-C3L8J7iE.js.map +0 -1
- package/dist/server-DZmcd2Lr.js.map +0 -1
- package/dist/signalr-SJ-1sIxw.js.map +0 -1
- package/dist/tenant-C6KPaSCX.js +0 -256
- package/dist/tenant-C6KPaSCX.js.map +0 -1
- /package/dist/{src-CJ2RLi94.js → src-B9rDUgSb.js} +0 -0
|
@@ -0,0 +1,1066 @@
|
|
|
1
|
+
import { logger } from "./logging-DviiAkLx.js";
|
|
2
|
+
import { AUTH_ERROR_COOKIE_PREFIX, createAuthSession, createOAuthState, deleteOAuthState, destroyAuthSession, destroyAuthSessionsByIdentitySession, destroyAuthSessionsBySid, destroyAuthSessionsForUser, getAuthSession, getOAuthState, listAuthSessionDataForUser, updateAuthSession } from "./redis-session-storage.server-v2VcpFsQ.js";
|
|
3
|
+
import * as oidc from "openid-client";
|
|
4
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
5
|
+
|
|
6
|
+
//#region src/http/response.ts
|
|
7
|
+
/**
|
|
8
|
+
* Create a redirect response without relying on a router/framework helper.
|
|
9
|
+
*/
|
|
10
|
+
function createRedirectResponse(location, init = {}) {
|
|
11
|
+
const headers = new Headers(init.headers);
|
|
12
|
+
headers.set("Location", location);
|
|
13
|
+
return new Response(null, {
|
|
14
|
+
status: init.status ?? 302,
|
|
15
|
+
headers
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
//#endregion
|
|
20
|
+
//#region src/auth/authorization-parameters.ts
|
|
21
|
+
const RESERVED_AUTHORIZATION_PARAMETER_NAMES = new Set([
|
|
22
|
+
"client_id",
|
|
23
|
+
"code_challenge",
|
|
24
|
+
"code_challenge_method",
|
|
25
|
+
"kc_action",
|
|
26
|
+
"nonce",
|
|
27
|
+
"prompt",
|
|
28
|
+
"redirect_uri",
|
|
29
|
+
"response_mode",
|
|
30
|
+
"response_type",
|
|
31
|
+
"scope",
|
|
32
|
+
"state"
|
|
33
|
+
]);
|
|
34
|
+
const DEFAULT_EXTRA_AUTHORIZATION_PARAMETER_NAMES = new Set([
|
|
35
|
+
"acr_values",
|
|
36
|
+
"kc_idp_hint",
|
|
37
|
+
"login_hint",
|
|
38
|
+
"ui_locales"
|
|
39
|
+
]);
|
|
40
|
+
function isAllowedExtraAuthorizationParameter(key, allowedNames, allowedPrefixes) {
|
|
41
|
+
const normalized = key.toLowerCase();
|
|
42
|
+
return allowedNames.has(normalized) || allowedPrefixes.some((prefix) => normalized.startsWith(prefix));
|
|
43
|
+
}
|
|
44
|
+
function sanitizeExtraAuthorizationParameters(params, options = {}) {
|
|
45
|
+
if (!params) return {};
|
|
46
|
+
const allowedNames = new Set([...DEFAULT_EXTRA_AUTHORIZATION_PARAMETER_NAMES, ...(options.extraAuthParamNames ?? []).map((name) => name.trim().toLowerCase()).filter(Boolean)]);
|
|
47
|
+
const allowedPrefixes = (options.extraAuthParamPrefixes ?? []).map((prefix) => prefix.trim().toLowerCase()).filter(Boolean);
|
|
48
|
+
const sanitized = {};
|
|
49
|
+
for (const [key, value] of Object.entries(params)) {
|
|
50
|
+
const normalizedKey = key.trim();
|
|
51
|
+
if (normalizedKey.length === 0 || RESERVED_AUTHORIZATION_PARAMETER_NAMES.has(normalizedKey.toLowerCase()) || !isAllowedExtraAuthorizationParameter(normalizedKey, allowedNames, allowedPrefixes) || value === null || value === void 0) continue;
|
|
52
|
+
const normalizedValue = String(value).trim();
|
|
53
|
+
if (normalizedValue.length === 0) continue;
|
|
54
|
+
sanitized[normalizedKey] = normalizedValue;
|
|
55
|
+
}
|
|
56
|
+
return sanitized;
|
|
57
|
+
}
|
|
58
|
+
function normalizePublicAuthorizationStateContext(context) {
|
|
59
|
+
if (!context) return null;
|
|
60
|
+
const normalized = {};
|
|
61
|
+
for (const [key, value] of Object.entries(context)) {
|
|
62
|
+
const normalizedKey = key.trim();
|
|
63
|
+
if (normalizedKey.length === 0 || RESERVED_AUTHORIZATION_PARAMETER_NAMES.has(normalizedKey.toLowerCase()) || value === null || value === void 0) continue;
|
|
64
|
+
const normalizedValue = String(value).trim();
|
|
65
|
+
if (normalizedValue.length === 0) continue;
|
|
66
|
+
normalized[normalizedKey] = normalizedValue;
|
|
67
|
+
}
|
|
68
|
+
return Object.keys(normalized).length > 0 ? normalized : null;
|
|
69
|
+
}
|
|
70
|
+
function buildAuthorizationState(baseState, publicStateContext) {
|
|
71
|
+
const normalizedContext = normalizePublicAuthorizationStateContext(publicStateContext);
|
|
72
|
+
if (!normalizedContext) return baseState;
|
|
73
|
+
return `${baseState}.${Buffer.from(JSON.stringify(normalizedContext), "utf8").toString("base64url")}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region src/auth/auth.server.ts
|
|
78
|
+
/**
|
|
79
|
+
* Get effective hasLandingPage setting based on config
|
|
80
|
+
*/
|
|
81
|
+
function getEffectiveHasLandingPage(config) {
|
|
82
|
+
if (config.hasLandingPage !== void 0) return config.hasLandingPage;
|
|
83
|
+
switch (config.applicationType) {
|
|
84
|
+
case "no-landing-page":
|
|
85
|
+
case "dashboard": return false;
|
|
86
|
+
case "landing-page":
|
|
87
|
+
case "tenant-app": return true;
|
|
88
|
+
default: return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Create OAuth configuration from environment variables
|
|
93
|
+
*/
|
|
94
|
+
function createAuthConfig() {
|
|
95
|
+
const requiredEnvVars = {
|
|
96
|
+
authority: process.env.OIDC_AUTHORITY,
|
|
97
|
+
clientId: process.env.OIDC_CLIENT_ID,
|
|
98
|
+
redirectUri: process.env.OIDC_REDIRECT_URI
|
|
99
|
+
};
|
|
100
|
+
for (const [key, value] of Object.entries(requiredEnvVars)) if (!value) throw new Error(`Missing required environment variable: OIDC_${key.toUpperCase()}`);
|
|
101
|
+
const appTypeEnv = process.env.OIDC_APPLICATION_TYPE?.toLowerCase();
|
|
102
|
+
let applicationType = "custom";
|
|
103
|
+
if (appTypeEnv === "no-landing-page" || appTypeEnv === "no_landing_page") applicationType = "no-landing-page";
|
|
104
|
+
else if (appTypeEnv === "landing-page" || appTypeEnv === "landing_page") applicationType = "landing-page";
|
|
105
|
+
else if (appTypeEnv === "dashboard") applicationType = "dashboard";
|
|
106
|
+
else if (appTypeEnv === "tenant-app" || appTypeEnv === "tenant_app") applicationType = "tenant-app";
|
|
107
|
+
const clientAuthMethod = parseOidcClientAuthMethod(process.env.OIDC_CLIENT_AUTH_METHOD, process.env.OIDC_CLIENT_SECRET ? "client_secret_post" : "none");
|
|
108
|
+
const hasLandingPage = process.env.OIDC_HAS_LANDING_PAGE !== void 0 ? process.env.OIDC_HAS_LANDING_PAGE === "true" : void 0;
|
|
109
|
+
return {
|
|
110
|
+
authority: requiredEnvVars.authority,
|
|
111
|
+
clientId: requiredEnvVars.clientId,
|
|
112
|
+
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
|
113
|
+
clientAuthMethod,
|
|
114
|
+
redirectUri: requiredEnvVars.redirectUri,
|
|
115
|
+
scope: process.env.OIDC_SCOPE || "openid profile email api",
|
|
116
|
+
postLogoutRedirectUri: process.env.OIDC_POST_LOGOUT_REDIRECT_URI,
|
|
117
|
+
applicationType,
|
|
118
|
+
hasLandingPage
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function parseOidcClientAuthMethod(value, fallback) {
|
|
122
|
+
const normalized = value?.trim().toLowerCase();
|
|
123
|
+
switch (normalized) {
|
|
124
|
+
case void 0:
|
|
125
|
+
case "": return fallback;
|
|
126
|
+
case "none": return "none";
|
|
127
|
+
case "client_secret_post":
|
|
128
|
+
case "post": return "client_secret_post";
|
|
129
|
+
case "client_secret_basic":
|
|
130
|
+
case "basic": return "client_secret_basic";
|
|
131
|
+
default: throw new Error(`Unsupported OIDC_CLIENT_AUTH_METHOD: ${value}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
let OIDC_CONFIG = null;
|
|
135
|
+
function getAuthConfig() {
|
|
136
|
+
if (!OIDC_CONFIG) OIDC_CONFIG = createAuthConfig();
|
|
137
|
+
return OIDC_CONFIG;
|
|
138
|
+
}
|
|
139
|
+
let authServerCache = null;
|
|
140
|
+
const CACHE_TTL = 60 * 60 * 1e3;
|
|
141
|
+
const jwksCache = new Map();
|
|
142
|
+
function normalizeIssuer(value) {
|
|
143
|
+
const normalized = value?.trim().replace(/\/+$/, "");
|
|
144
|
+
return normalized && normalized.length > 0 ? normalized : null;
|
|
145
|
+
}
|
|
146
|
+
function getJwtClaims(token) {
|
|
147
|
+
if (!token) return null;
|
|
148
|
+
try {
|
|
149
|
+
const [, payload] = token.split(".");
|
|
150
|
+
if (!payload) return null;
|
|
151
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function getJwtIssuer(token) {
|
|
157
|
+
const issuer = getJwtClaims(token)?.iss;
|
|
158
|
+
return typeof issuer === "string" ? normalizeIssuer(issuer) : null;
|
|
159
|
+
}
|
|
160
|
+
function canUseIdTokenHint(idToken, expectedIssuer) {
|
|
161
|
+
if (!idToken) return false;
|
|
162
|
+
const tokenIssuer = getJwtIssuer(idToken);
|
|
163
|
+
return tokenIssuer !== null && tokenIssuer === normalizeIssuer(expectedIssuer);
|
|
164
|
+
}
|
|
165
|
+
function sanitizeLogoutReturnUrl(returnUrl, request) {
|
|
166
|
+
if (!returnUrl) return null;
|
|
167
|
+
try {
|
|
168
|
+
const requestUrl = new URL(request.url);
|
|
169
|
+
const decodedReturnUrl = decodeURIComponent(returnUrl);
|
|
170
|
+
if (decodedReturnUrl.startsWith("/") && !decodedReturnUrl.startsWith("//")) return isAuthLoopPath(decodedReturnUrl) ? null : decodedReturnUrl;
|
|
171
|
+
const candidateUrl = new URL(decodedReturnUrl);
|
|
172
|
+
if (candidateUrl.origin !== requestUrl.origin) return null;
|
|
173
|
+
const sameOriginPath = `${candidateUrl.pathname}${candidateUrl.search}${candidateUrl.hash}`;
|
|
174
|
+
return isAuthLoopPath(sameOriginPath) ? null : sameOriginPath;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function appendApplicationInitiatedActionResult(returnUrl, action, status) {
|
|
180
|
+
if (!action && !status) return returnUrl;
|
|
181
|
+
try {
|
|
182
|
+
const isRelative = returnUrl.startsWith("/") && !returnUrl.startsWith("//");
|
|
183
|
+
const url = new URL(returnUrl, isRelative ? "http://spine.local" : void 0);
|
|
184
|
+
if (action) url.searchParams.set("kc_action", action);
|
|
185
|
+
if (status) url.searchParams.set("kc_action_status", status);
|
|
186
|
+
return isRelative ? `${url.pathname}${url.search}${url.hash}` : url.toString();
|
|
187
|
+
} catch {
|
|
188
|
+
const params = new URLSearchParams();
|
|
189
|
+
if (action) params.set("kc_action", action);
|
|
190
|
+
if (status) params.set("kc_action_status", status);
|
|
191
|
+
const separator = returnUrl.includes("?") ? "&" : "?";
|
|
192
|
+
return `${returnUrl}${separator}${params.toString()}`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function isAuthLoopPath(path) {
|
|
196
|
+
const normalized = path.split("#")[0].split("?")[0].replace(/\/+$/, "") || "/";
|
|
197
|
+
return normalized === "/auth/logout" || normalized === "/auth/callback";
|
|
198
|
+
}
|
|
199
|
+
function isLocalInsecureIssuer(authority) {
|
|
200
|
+
try {
|
|
201
|
+
const issuer = new URL(authority);
|
|
202
|
+
return issuer.protocol === "http:" && [
|
|
203
|
+
"localhost",
|
|
204
|
+
"127.0.0.1",
|
|
205
|
+
"::1",
|
|
206
|
+
"[::1]"
|
|
207
|
+
].includes(issuer.hostname);
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function shouldAllowInsecureOidcRequests(authority) {
|
|
213
|
+
if (process.env.NODE_ENV === "production") return false;
|
|
214
|
+
if (process.env.OIDC_ALLOW_INSECURE_REQUESTS === "true") return true;
|
|
215
|
+
return isLocalInsecureIssuer(authority);
|
|
216
|
+
}
|
|
217
|
+
function getDiscoveryOptions() {
|
|
218
|
+
const config = getAuthConfig();
|
|
219
|
+
return shouldAllowInsecureOidcRequests(config.authority) ? {
|
|
220
|
+
algorithm: "oidc",
|
|
221
|
+
execute: [oidc.allowInsecureRequests]
|
|
222
|
+
} : { algorithm: "oidc" };
|
|
223
|
+
}
|
|
224
|
+
function getClientMetadata(config) {
|
|
225
|
+
return config.clientAuthMethod === "none" ? void 0 : config.clientSecret;
|
|
226
|
+
}
|
|
227
|
+
function getClientAuthentication(config) {
|
|
228
|
+
switch (config.clientAuthMethod) {
|
|
229
|
+
case "client_secret_basic":
|
|
230
|
+
if (!config.clientSecret) throw new Error("OIDC_CLIENT_SECRET is required for client_secret_basic authentication");
|
|
231
|
+
return oidc.ClientSecretBasic(config.clientSecret);
|
|
232
|
+
case "client_secret_post":
|
|
233
|
+
if (!config.clientSecret) throw new Error("OIDC_CLIENT_SECRET is required for client_secret_post authentication");
|
|
234
|
+
return oidc.ClientSecretPost(config.clientSecret);
|
|
235
|
+
case "none":
|
|
236
|
+
default: return oidc.None();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function getOAuthErrorMessage(error) {
|
|
240
|
+
if (error instanceof oidc.ResponseBodyError) return error.error_description || error.error || error.message;
|
|
241
|
+
if (error instanceof Error) return error.message;
|
|
242
|
+
return "Unknown error";
|
|
243
|
+
}
|
|
244
|
+
function shouldLogoutAfterOAuthError(error) {
|
|
245
|
+
if (error instanceof oidc.ResponseBodyError) return error.status === 400 || error.status === 401 || error.error === "invalid_grant" || error.error === "invalid_token";
|
|
246
|
+
const message = error instanceof Error ? error.message.toLowerCase() : "";
|
|
247
|
+
return message.includes("invalid_grant") || message.includes("invalid_token") || message.includes("unauthorized");
|
|
248
|
+
}
|
|
249
|
+
function getLogoutScope(url) {
|
|
250
|
+
const requestedScope = url.searchParams.get("logout") ?? url.searchParams.get("logout_scope") ?? url.searchParams.get("scope");
|
|
251
|
+
const normalized = requestedScope?.trim().toLowerCase();
|
|
252
|
+
if (normalized === "local" || normalized === "application" || normalized === "app" || normalized === "current-device") return "local";
|
|
253
|
+
if (normalized === "all" || normalized === "all-local" || normalized === "all-devices" || normalized === "everywhere") return "all";
|
|
254
|
+
if (url.searchParams.get("local_only") === "true" || url.searchParams.get("client_id_only") === "true") return "local";
|
|
255
|
+
return "identity";
|
|
256
|
+
}
|
|
257
|
+
function getAbsoluteRedirectUrl(request, redirectUrl) {
|
|
258
|
+
if (/^https?:\/\//i.test(redirectUrl)) return redirectUrl;
|
|
259
|
+
return new URL(redirectUrl, new URL(request.url).origin).toString();
|
|
260
|
+
}
|
|
261
|
+
async function revokeTokenBestEffort(configuration, token, tokenTypeHint) {
|
|
262
|
+
if (!token) return;
|
|
263
|
+
try {
|
|
264
|
+
await oidc.tokenRevocation(configuration, token, { token_type_hint: tokenTypeHint });
|
|
265
|
+
} catch (error) {
|
|
266
|
+
logger.warn("OIDC token revocation failed", {
|
|
267
|
+
tokenTypeHint,
|
|
268
|
+
error: getOAuthErrorMessage(error)
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async function revokeSessionTokensBestEffort(sessionData) {
|
|
273
|
+
if (!sessionData.refreshToken && !sessionData.accessToken) return;
|
|
274
|
+
try {
|
|
275
|
+
const configuration = await getOAuthConfig();
|
|
276
|
+
await revokeTokenBestEffort(configuration, sessionData.refreshToken, "refresh_token");
|
|
277
|
+
await revokeTokenBestEffort(configuration, sessionData.accessToken, "access_token");
|
|
278
|
+
} catch (error) {
|
|
279
|
+
logger.warn("Skipping OIDC token revocation because provider configuration is unavailable", { error: getOAuthErrorMessage(error) });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async function revokeAndDestroyAllUserSessionsBestEffort(userId) {
|
|
283
|
+
const sessions = await listAuthSessionDataForUser(userId);
|
|
284
|
+
try {
|
|
285
|
+
const configuration = await getOAuthConfig();
|
|
286
|
+
for (const { data } of sessions) {
|
|
287
|
+
await revokeTokenBestEffort(configuration, data.refreshToken, "refresh_token");
|
|
288
|
+
await revokeTokenBestEffort(configuration, data.accessToken, "access_token");
|
|
289
|
+
}
|
|
290
|
+
} catch (error) {
|
|
291
|
+
logger.warn("Skipping all-session token revocation because provider configuration is unavailable", { error: getOAuthErrorMessage(error) });
|
|
292
|
+
}
|
|
293
|
+
return await destroyAuthSessionsForUser(userId);
|
|
294
|
+
}
|
|
295
|
+
function getClaimsFromTokenResponse(tokenResult) {
|
|
296
|
+
const claims = tokenResult.claims();
|
|
297
|
+
if (claims) return claims;
|
|
298
|
+
return getJwtClaims(tokenResult.id_token) ?? {};
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Get OIDC configuration with caching.
|
|
302
|
+
*/
|
|
303
|
+
async function getOAuthConfig() {
|
|
304
|
+
const config = getAuthConfig();
|
|
305
|
+
const now = Date.now();
|
|
306
|
+
if (authServerCache && now - authServerCache.cachedAt < CACHE_TTL) return authServerCache.configuration;
|
|
307
|
+
try {
|
|
308
|
+
const configuration = await oidc.discovery(new URL(config.authority), config.clientId, getClientMetadata(config), getClientAuthentication(config), getDiscoveryOptions());
|
|
309
|
+
authServerCache = {
|
|
310
|
+
configuration,
|
|
311
|
+
cachedAt: now
|
|
312
|
+
};
|
|
313
|
+
logger.info("OIDC discovery completed successfully", {
|
|
314
|
+
issuer: configuration.serverMetadata().issuer,
|
|
315
|
+
clientId: config.clientId,
|
|
316
|
+
clientAuthMethod: config.clientAuthMethod
|
|
317
|
+
});
|
|
318
|
+
return configuration;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
logger.error("OIDC discovery failed", error instanceof Error ? error : void 0, {
|
|
321
|
+
authority: config.authority,
|
|
322
|
+
clientId: config.clientId
|
|
323
|
+
});
|
|
324
|
+
throw new Error(`OIDC discovery failed: ${getOAuthErrorMessage(error)}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function getJwks(jwksUri) {
|
|
328
|
+
const cachedJwks = jwksCache.get(jwksUri);
|
|
329
|
+
if (cachedJwks) return cachedJwks;
|
|
330
|
+
const jwks = createRemoteJWKSet(new URL(jwksUri));
|
|
331
|
+
jwksCache.set(jwksUri, jwks);
|
|
332
|
+
return jwks;
|
|
333
|
+
}
|
|
334
|
+
async function getLogoutTokenFromRequest(request) {
|
|
335
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
336
|
+
if (contentType.includes("application/json")) {
|
|
337
|
+
const body = await request.json().catch(() => null);
|
|
338
|
+
return typeof body?.logout_token === "string" ? body.logout_token : null;
|
|
339
|
+
}
|
|
340
|
+
const formData = await request.formData().catch(() => null);
|
|
341
|
+
const logoutToken = formData?.get("logout_token");
|
|
342
|
+
return typeof logoutToken === "string" ? logoutToken : null;
|
|
343
|
+
}
|
|
344
|
+
function validateBackChannelLogoutClaims(payload, clientId) {
|
|
345
|
+
const events = payload.events;
|
|
346
|
+
const hasBackChannelEvent = events && typeof events === "object" && !Array.isArray(events) && "http://schemas.openid.net/event/backchannel-logout" in events;
|
|
347
|
+
if (!hasBackChannelEvent) throw new Error("Invalid back-channel logout token: missing back-channel logout event");
|
|
348
|
+
if ("nonce" in payload) throw new Error("Invalid back-channel logout token: nonce is not allowed");
|
|
349
|
+
const audience = payload.aud;
|
|
350
|
+
const hasClientAudience = Array.isArray(audience) ? audience.includes(clientId) : audience === clientId;
|
|
351
|
+
if (!hasClientAudience) throw new Error("Invalid back-channel logout token: audience mismatch");
|
|
352
|
+
const subject = typeof payload.sub === "string" ? payload.sub : void 0;
|
|
353
|
+
const sid = typeof payload.sid === "string" ? payload.sid : void 0;
|
|
354
|
+
if (!subject && !sid) throw new Error("Invalid back-channel logout token: subject or sid is required");
|
|
355
|
+
return {
|
|
356
|
+
issuer: typeof payload.iss === "string" ? payload.iss : void 0,
|
|
357
|
+
subject,
|
|
358
|
+
sid
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const DEFAULT_AUTH_CLAIM_MAPPING = {
|
|
362
|
+
subject: ["sub"],
|
|
363
|
+
name: [
|
|
364
|
+
"name",
|
|
365
|
+
"preferred_username",
|
|
366
|
+
"email"
|
|
367
|
+
],
|
|
368
|
+
email: ["email"],
|
|
369
|
+
givenName: ["given_name", "givenName"],
|
|
370
|
+
familyName: ["family_name", "familyName"],
|
|
371
|
+
picture: ["picture", "avatar_url"],
|
|
372
|
+
locale: ["locale"],
|
|
373
|
+
zoneinfo: ["zoneinfo"],
|
|
374
|
+
updatedAt: ["updated_at"],
|
|
375
|
+
tenantIds: ["tenant_ids"],
|
|
376
|
+
tenantRoles: ["tenant_roles"],
|
|
377
|
+
permissions: [
|
|
378
|
+
"app_perms",
|
|
379
|
+
"permissions",
|
|
380
|
+
"scope"
|
|
381
|
+
],
|
|
382
|
+
isOnboarded: ["is_onboarded"]
|
|
383
|
+
};
|
|
384
|
+
let authClaimMapping = { ...DEFAULT_AUTH_CLAIM_MAPPING };
|
|
385
|
+
function normalizeClaimKeys(keys) {
|
|
386
|
+
if (!Array.isArray(keys)) return [];
|
|
387
|
+
return Array.from(new Set(keys.map((key) => key.trim()).filter((key) => key.length > 0)));
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Configure claim mapping so different providers/backends can project their
|
|
391
|
+
* own claim names onto the shared auth/session primitives.
|
|
392
|
+
*/
|
|
393
|
+
function configureAuthClaimMapping(mapping) {
|
|
394
|
+
authClaimMapping = {
|
|
395
|
+
subject: mapping.subject !== void 0 ? normalizeClaimKeys(mapping.subject) : DEFAULT_AUTH_CLAIM_MAPPING.subject,
|
|
396
|
+
name: mapping.name !== void 0 ? normalizeClaimKeys(mapping.name) : DEFAULT_AUTH_CLAIM_MAPPING.name,
|
|
397
|
+
email: mapping.email !== void 0 ? normalizeClaimKeys(mapping.email) : DEFAULT_AUTH_CLAIM_MAPPING.email,
|
|
398
|
+
givenName: mapping.givenName !== void 0 ? normalizeClaimKeys(mapping.givenName) : DEFAULT_AUTH_CLAIM_MAPPING.givenName,
|
|
399
|
+
familyName: mapping.familyName !== void 0 ? normalizeClaimKeys(mapping.familyName) : DEFAULT_AUTH_CLAIM_MAPPING.familyName,
|
|
400
|
+
picture: mapping.picture !== void 0 ? normalizeClaimKeys(mapping.picture) : DEFAULT_AUTH_CLAIM_MAPPING.picture,
|
|
401
|
+
locale: mapping.locale !== void 0 ? normalizeClaimKeys(mapping.locale) : DEFAULT_AUTH_CLAIM_MAPPING.locale,
|
|
402
|
+
zoneinfo: mapping.zoneinfo !== void 0 ? normalizeClaimKeys(mapping.zoneinfo) : DEFAULT_AUTH_CLAIM_MAPPING.zoneinfo,
|
|
403
|
+
updatedAt: mapping.updatedAt !== void 0 ? normalizeClaimKeys(mapping.updatedAt) : DEFAULT_AUTH_CLAIM_MAPPING.updatedAt,
|
|
404
|
+
tenantIds: mapping.tenantIds !== void 0 ? normalizeClaimKeys(mapping.tenantIds) : DEFAULT_AUTH_CLAIM_MAPPING.tenantIds,
|
|
405
|
+
tenantRoles: mapping.tenantRoles !== void 0 ? normalizeClaimKeys(mapping.tenantRoles) : DEFAULT_AUTH_CLAIM_MAPPING.tenantRoles,
|
|
406
|
+
permissions: mapping.permissions !== void 0 ? normalizeClaimKeys(mapping.permissions) : DEFAULT_AUTH_CLAIM_MAPPING.permissions,
|
|
407
|
+
isOnboarded: mapping.isOnboarded !== void 0 ? normalizeClaimKeys(mapping.isOnboarded) : DEFAULT_AUTH_CLAIM_MAPPING.isOnboarded
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Reset claim mapping to the built-in defaults.
|
|
412
|
+
*/
|
|
413
|
+
function resetAuthClaimMapping() {
|
|
414
|
+
authClaimMapping = { ...DEFAULT_AUTH_CLAIM_MAPPING };
|
|
415
|
+
}
|
|
416
|
+
function getConfiguredAuthClaimMapping() {
|
|
417
|
+
return authClaimMapping;
|
|
418
|
+
}
|
|
419
|
+
function getFirstClaimValue(claims, claimKeys) {
|
|
420
|
+
for (const claimKey of claimKeys) if (claimKey in claims) return {
|
|
421
|
+
key: claimKey,
|
|
422
|
+
value: claims[claimKey]
|
|
423
|
+
};
|
|
424
|
+
return {};
|
|
425
|
+
}
|
|
426
|
+
function getStringClaim(claims, claimKeys) {
|
|
427
|
+
const { value } = getFirstClaimValue(claims, claimKeys);
|
|
428
|
+
if (typeof value === "string") {
|
|
429
|
+
const trimmed = value.trim();
|
|
430
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
431
|
+
}
|
|
432
|
+
if (typeof value === "number" || typeof value === "boolean") return `${value}`;
|
|
433
|
+
return void 0;
|
|
434
|
+
}
|
|
435
|
+
function getNumberClaim(claims, claimKeys) {
|
|
436
|
+
const { value } = getFirstClaimValue(claims, claimKeys);
|
|
437
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
438
|
+
if (typeof value === "string") {
|
|
439
|
+
const parsed = Number(value);
|
|
440
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
441
|
+
}
|
|
442
|
+
return void 0;
|
|
443
|
+
}
|
|
444
|
+
function getBooleanClaim(claims, claimKeys, defaultValue = false) {
|
|
445
|
+
const { value } = getFirstClaimValue(claims, claimKeys);
|
|
446
|
+
if (typeof value === "boolean") return value;
|
|
447
|
+
if (typeof value === "string") return value.toLowerCase() === "true";
|
|
448
|
+
return defaultValue;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Parse JSON safely
|
|
452
|
+
*/
|
|
453
|
+
function parseJson(value) {
|
|
454
|
+
try {
|
|
455
|
+
return JSON.parse(value);
|
|
456
|
+
} catch {
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Convert value to string array
|
|
462
|
+
*/
|
|
463
|
+
function toStringArray(value) {
|
|
464
|
+
if (!value) return [];
|
|
465
|
+
if (Array.isArray(value)) return value.map((item) => typeof item === "string" ? item.trim() : `${item}`.trim()).filter((item) => item.length > 0);
|
|
466
|
+
if (typeof value === "string") {
|
|
467
|
+
const trimmed = value.trim();
|
|
468
|
+
if (!trimmed) return [];
|
|
469
|
+
if (trimmed.startsWith("[")) {
|
|
470
|
+
const parsed = parseJson(trimmed);
|
|
471
|
+
if (Array.isArray(parsed)) return parsed.map((item) => typeof item === "string" ? item.trim() : `${item}`.trim()).filter((item) => item.length > 0);
|
|
472
|
+
}
|
|
473
|
+
if (trimmed.includes(",")) return trimmed.split(",").map((item) => item.trim()).filter((item) => item.length > 0);
|
|
474
|
+
return [trimmed];
|
|
475
|
+
}
|
|
476
|
+
return [];
|
|
477
|
+
}
|
|
478
|
+
function toScopeArray(value) {
|
|
479
|
+
if (typeof value === "string") return value.trim().split(/\s+/).filter((item) => item.length > 0);
|
|
480
|
+
return toStringArray(value);
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Extract user info from ID token claims
|
|
484
|
+
*/
|
|
485
|
+
function extractUserInfo(claims) {
|
|
486
|
+
const claimMapping = getConfiguredAuthClaimMapping();
|
|
487
|
+
let tenants = [];
|
|
488
|
+
const { value: rawTenantIds } = getFirstClaimValue(claims, claimMapping.tenantIds);
|
|
489
|
+
if (rawTenantIds !== void 0) if (typeof rawTenantIds === "string" && rawTenantIds.trim().startsWith("[")) {
|
|
490
|
+
const parsed = parseJson(rawTenantIds.trim());
|
|
491
|
+
if (Array.isArray(parsed)) tenants = parsed.map((id) => typeof id === "string" ? id.trim() : `${id}`.trim()).filter((id) => id.length > 0);
|
|
492
|
+
} else tenants = toStringArray(rawTenantIds);
|
|
493
|
+
const tenantRoles = {};
|
|
494
|
+
const { value: rawRoles } = getFirstClaimValue(claims, claimMapping.tenantRoles);
|
|
495
|
+
if (rawRoles !== void 0) {
|
|
496
|
+
let parsedRoles = rawRoles;
|
|
497
|
+
if (typeof rawRoles === "string" && rawRoles.trim().startsWith("{")) parsedRoles = parseJson(rawRoles.trim()) ?? rawRoles;
|
|
498
|
+
if (parsedRoles && typeof parsedRoles === "object" && !Array.isArray(parsedRoles)) for (const [tenantId, roleValue] of Object.entries(parsedRoles)) {
|
|
499
|
+
const roleIds = toStringArray(roleValue);
|
|
500
|
+
if (roleIds.length > 0) tenantRoles[tenantId] = roleIds;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const permissionClaim = getFirstClaimValue(claims, claimMapping.permissions);
|
|
504
|
+
const permissions = permissionClaim.key === "scope" ? toScopeArray(permissionClaim.value) : toStringArray(permissionClaim.value);
|
|
505
|
+
const isOnboarded = getBooleanClaim(claims, claimMapping.isOnboarded);
|
|
506
|
+
return {
|
|
507
|
+
tenants,
|
|
508
|
+
isOnboarded,
|
|
509
|
+
tenantRoles,
|
|
510
|
+
permissions
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Create session data from token result
|
|
515
|
+
*/
|
|
516
|
+
async function createSessionData(tokenResult, claims) {
|
|
517
|
+
const claimMapping = getConfiguredAuthClaimMapping();
|
|
518
|
+
const baseUser = {
|
|
519
|
+
sub: getStringClaim(claims, claimMapping.subject) || "unknown",
|
|
520
|
+
name: getStringClaim(claims, claimMapping.name) || getStringClaim(claims, claimMapping.email) || "User",
|
|
521
|
+
email: getStringClaim(claims, claimMapping.email),
|
|
522
|
+
givenName: getStringClaim(claims, claimMapping.givenName),
|
|
523
|
+
familyName: getStringClaim(claims, claimMapping.familyName),
|
|
524
|
+
picture: getStringClaim(claims, claimMapping.picture),
|
|
525
|
+
locale: getStringClaim(claims, claimMapping.locale),
|
|
526
|
+
zoneinfo: getStringClaim(claims, claimMapping.zoneinfo),
|
|
527
|
+
updated_at: getNumberClaim(claims, claimMapping.updatedAt)
|
|
528
|
+
};
|
|
529
|
+
const expiresAt = tokenResult.expires_in ? Date.now() + tokenResult.expires_in * 1e3 : void 0;
|
|
530
|
+
const now = Date.now();
|
|
531
|
+
const sessionState = tokenResult.session_state;
|
|
532
|
+
return {
|
|
533
|
+
userId: baseUser.sub,
|
|
534
|
+
accessToken: tokenResult.access_token,
|
|
535
|
+
refreshToken: tokenResult.refresh_token,
|
|
536
|
+
idToken: tokenResult.id_token,
|
|
537
|
+
expiresAt,
|
|
538
|
+
issuer: getStringClaim(claims, ["iss"]),
|
|
539
|
+
sid: getStringClaim(claims, ["sid"]),
|
|
540
|
+
sessionState: typeof sessionState === "string" ? sessionState : void 0,
|
|
541
|
+
clientId: getAuthConfig().clientId,
|
|
542
|
+
user: baseUser,
|
|
543
|
+
createdAt: now,
|
|
544
|
+
lastActivity: now
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
const AUTH_ERROR_COOKIE = `${AUTH_ERROR_COOKIE_PREFIX}_auth_error`;
|
|
548
|
+
const AUTH_ERROR_DESC_COOKIE = `${AUTH_ERROR_COOKIE_PREFIX}_auth_error_desc`;
|
|
549
|
+
const OAUTH_STATE_COOKIE = `${AUTH_ERROR_COOKIE_PREFIX}_oauth_state_id`;
|
|
550
|
+
/**
|
|
551
|
+
* Check if there's an auth error in cookies
|
|
552
|
+
*
|
|
553
|
+
* This should be called by apps (especially those without landing pages like Dashboard)
|
|
554
|
+
* BEFORE starting OAuth flow to prevent redirect loops.
|
|
555
|
+
*
|
|
556
|
+
* @example
|
|
557
|
+
* ```ts
|
|
558
|
+
* // In dashboard's index route loader
|
|
559
|
+
* const authError = getAuthError(request);
|
|
560
|
+
* if (authError) {
|
|
561
|
+
* return { authError }; // Show error to user
|
|
562
|
+
* }
|
|
563
|
+
* // No error, proceed with session check
|
|
564
|
+
* const user = await getUser(request);
|
|
565
|
+
* if (!user) {
|
|
566
|
+
* return login(request);
|
|
567
|
+
* }
|
|
568
|
+
* ```
|
|
569
|
+
*/
|
|
570
|
+
function getAuthError(request) {
|
|
571
|
+
const cookies = request.headers.get("Cookie");
|
|
572
|
+
if (!cookies) return null;
|
|
573
|
+
const errorRegex = new RegExp(`${AUTH_ERROR_COOKIE}=([^;]+)`);
|
|
574
|
+
const errorMatch = cookies.match(errorRegex);
|
|
575
|
+
if (!errorMatch) return null;
|
|
576
|
+
const error = decodeURIComponent(errorMatch[1]);
|
|
577
|
+
if (!error) return null;
|
|
578
|
+
const descRegex = new RegExp(`${AUTH_ERROR_DESC_COOKIE}=([^;]+)`);
|
|
579
|
+
const descMatch = cookies.match(descRegex);
|
|
580
|
+
const description = descMatch ? decodeURIComponent(descMatch[1]) : void 0;
|
|
581
|
+
return {
|
|
582
|
+
error,
|
|
583
|
+
description
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Clear auth error cookies
|
|
588
|
+
*
|
|
589
|
+
* Call this after displaying the error to the user
|
|
590
|
+
*/
|
|
591
|
+
function clearAuthErrorHeaders() {
|
|
592
|
+
const headers = new Headers();
|
|
593
|
+
headers.append("Set-Cookie", `${AUTH_ERROR_COOKIE}=; Path=/; Max-Age=0`);
|
|
594
|
+
headers.append("Set-Cookie", `${AUTH_ERROR_DESC_COOKIE}=; Path=/; Max-Age=0`);
|
|
595
|
+
return headers;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Check if request has auth error (quick check without parsing)
|
|
599
|
+
*/
|
|
600
|
+
function hasAuthError(request) {
|
|
601
|
+
const cookies = request.headers.get("Cookie");
|
|
602
|
+
return cookies?.includes(`${AUTH_ERROR_COOKIE}=`) ?? false;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Initiate OAuth login flow
|
|
606
|
+
*
|
|
607
|
+
* @param request - The incoming request
|
|
608
|
+
* @param returnUrlOrOptions - Either a return URL string or LoginOptions object
|
|
609
|
+
*/
|
|
610
|
+
async function login(request, returnUrlOrOptions) {
|
|
611
|
+
try {
|
|
612
|
+
const config = getAuthConfig();
|
|
613
|
+
const options = typeof returnUrlOrOptions === "string" ? { returnUrl: returnUrlOrOptions } : returnUrlOrOptions || {};
|
|
614
|
+
const cookies = request.headers.get("Cookie");
|
|
615
|
+
const hasError = cookies?.includes(`${AUTH_ERROR_COOKIE}=`);
|
|
616
|
+
if (hasError) {
|
|
617
|
+
logger.warn("Auth error cookie detected, preventing redirect loop");
|
|
618
|
+
const headers$1 = new Headers();
|
|
619
|
+
headers$1.append("Set-Cookie", `${AUTH_ERROR_COOKIE}=; Path=/; Max-Age=0`);
|
|
620
|
+
const redirectUri = new URL(config.redirectUri);
|
|
621
|
+
const baseUrl = redirectUri.origin;
|
|
622
|
+
return createRedirectResponse(baseUrl, { headers: headers$1 });
|
|
623
|
+
}
|
|
624
|
+
const oidcConfig = await getOAuthConfig();
|
|
625
|
+
const codeVerifier = oidc.randomPKCECodeVerifier();
|
|
626
|
+
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
|
627
|
+
const extraAuthParams = sanitizeExtraAuthorizationParameters(options.extraAuthParams, options);
|
|
628
|
+
const state = buildAuthorizationState(oidc.randomState(), options.publicStateContext);
|
|
629
|
+
const nonce = oidc.randomNonce();
|
|
630
|
+
const oauthState = {
|
|
631
|
+
state,
|
|
632
|
+
codeVerifier,
|
|
633
|
+
nonce,
|
|
634
|
+
returnUrl: options.returnUrl,
|
|
635
|
+
kcAction: options.kcAction,
|
|
636
|
+
createdAt: Date.now()
|
|
637
|
+
};
|
|
638
|
+
logger.info("Creating OAuth state", {
|
|
639
|
+
returnUrl: options.returnUrl,
|
|
640
|
+
prompt: options.prompt,
|
|
641
|
+
kcAction: options.kcAction,
|
|
642
|
+
extraAuthParamKeys: Object.keys(extraAuthParams)
|
|
643
|
+
});
|
|
644
|
+
const stateId = await createOAuthState(oauthState);
|
|
645
|
+
const authorizationParameters = {
|
|
646
|
+
redirect_uri: config.redirectUri,
|
|
647
|
+
scope: config.scope,
|
|
648
|
+
state,
|
|
649
|
+
nonce,
|
|
650
|
+
code_challenge: codeChallenge,
|
|
651
|
+
code_challenge_method: "S256",
|
|
652
|
+
...extraAuthParams
|
|
653
|
+
};
|
|
654
|
+
if (options.prompt) authorizationParameters.prompt = options.prompt;
|
|
655
|
+
if (options.kcAction) authorizationParameters.kc_action = options.kcAction;
|
|
656
|
+
const authorizationUrl = oidc.buildAuthorizationUrl(oidcConfig, authorizationParameters);
|
|
657
|
+
const headers = new Headers();
|
|
658
|
+
headers.append("Set-Cookie", `${OAUTH_STATE_COOKIE}=${stateId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600`);
|
|
659
|
+
logger.info("OIDC authorization flow initiated", {
|
|
660
|
+
clientId: config.clientId,
|
|
661
|
+
redirectUri: config.redirectUri,
|
|
662
|
+
scope: config.scope,
|
|
663
|
+
authorizationEndpoint: oidcConfig.serverMetadata().authorization_endpoint,
|
|
664
|
+
stateId,
|
|
665
|
+
prompt: options.prompt,
|
|
666
|
+
kcAction: options.kcAction
|
|
667
|
+
});
|
|
668
|
+
return createRedirectResponse(authorizationUrl.toString(), { headers });
|
|
669
|
+
} catch (error) {
|
|
670
|
+
logger.error("Failed to initiate OAuth login", error instanceof Error ? error : void 0);
|
|
671
|
+
throw new Error("Login failed");
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Handle OAuth callback
|
|
676
|
+
*
|
|
677
|
+
* Handles OAuth authorization response including success and error cases.
|
|
678
|
+
*
|
|
679
|
+
* Error Handling:
|
|
680
|
+
* - `access_denied`: User doesn't have access to this application
|
|
681
|
+
* - For apps with landing page: Redirect home with error cookie
|
|
682
|
+
* - For apps without landing page: Redirect home with error cookie (must handle specially)
|
|
683
|
+
* - `login_required`: No active identity session
|
|
684
|
+
* - Redirect to home to start fresh login
|
|
685
|
+
*/
|
|
686
|
+
async function handleCallback(request) {
|
|
687
|
+
try {
|
|
688
|
+
const config = getAuthConfig();
|
|
689
|
+
const hasLandingPage = getEffectiveHasLandingPage(config);
|
|
690
|
+
const url = new URL(request.url);
|
|
691
|
+
const code = url.searchParams.get("code");
|
|
692
|
+
const state = url.searchParams.get("state");
|
|
693
|
+
const error = url.searchParams.get("error");
|
|
694
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
695
|
+
const kcAction = url.searchParams.get("kc_action");
|
|
696
|
+
const kcActionStatus = url.searchParams.get("kc_action_status");
|
|
697
|
+
logger.info("OIDC callback received", {
|
|
698
|
+
hasCode: !!code,
|
|
699
|
+
hasState: !!state,
|
|
700
|
+
error,
|
|
701
|
+
errorDescription,
|
|
702
|
+
kcAction,
|
|
703
|
+
kcActionStatus,
|
|
704
|
+
clientId: config.clientId,
|
|
705
|
+
redirectUri: config.redirectUri
|
|
706
|
+
});
|
|
707
|
+
if (error) {
|
|
708
|
+
if (kcActionStatus && state) {
|
|
709
|
+
const cookies$1 = request.headers.get("Cookie");
|
|
710
|
+
const oauthStateRegex$1 = new RegExp(`${OAUTH_STATE_COOKIE}=([^;]+)`);
|
|
711
|
+
const cookieMatch$1 = cookies$1?.match(oauthStateRegex$1);
|
|
712
|
+
const stateId$1 = cookieMatch$1?.[1];
|
|
713
|
+
if (stateId$1) {
|
|
714
|
+
const oauthState$1 = await getOAuthState(stateId$1);
|
|
715
|
+
const stateAge$1 = oauthState$1 ? Date.now() - oauthState$1.createdAt : Number.POSITIVE_INFINITY;
|
|
716
|
+
if (oauthState$1 && oauthState$1.state === state && stateAge$1 <= 10 * 60 * 1e3) {
|
|
717
|
+
await deleteOAuthState(stateId$1);
|
|
718
|
+
const headers$2 = new Headers();
|
|
719
|
+
headers$2.append("Set-Cookie", `${OAUTH_STATE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
|
720
|
+
const redirectUrl$1 = appendApplicationInitiatedActionResult(oauthState$1.returnUrl || "/", kcAction || oauthState$1.kcAction, kcActionStatus);
|
|
721
|
+
logger.info("Application initiated action returned with OAuth error", {
|
|
722
|
+
error,
|
|
723
|
+
errorDescription,
|
|
724
|
+
kcAction: kcAction || oauthState$1.kcAction,
|
|
725
|
+
kcActionStatus
|
|
726
|
+
});
|
|
727
|
+
return createRedirectResponse(redirectUrl$1, { headers: headers$2 });
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
logger.error("OAuth callback error", void 0, {
|
|
732
|
+
error,
|
|
733
|
+
errorDescription,
|
|
734
|
+
clientId: config.clientId,
|
|
735
|
+
applicationType: config.applicationType,
|
|
736
|
+
hasLandingPage
|
|
737
|
+
});
|
|
738
|
+
if (error === "access_denied") {
|
|
739
|
+
logger.warn("Access denied for this application", { clientId: config.clientId });
|
|
740
|
+
const headers$2 = await destroyAuthSession(request);
|
|
741
|
+
headers$2.append("Set-Cookie", `${AUTH_ERROR_COOKIE}=access_denied; Path=/; HttpOnly; SameSite=Lax; Max-Age=300`);
|
|
742
|
+
headers$2.append("Set-Cookie", `${AUTH_ERROR_DESC_COOKIE}=${encodeURIComponent(errorDescription || "You do not have access to this application.")}; Path=/; SameSite=Lax; Max-Age=300`);
|
|
743
|
+
const redirectUri$1 = new URL(config.redirectUri);
|
|
744
|
+
const baseUrl$1 = redirectUri$1.origin;
|
|
745
|
+
logger.info("Redirecting to /auth/login to show access_denied error", {
|
|
746
|
+
hasLandingPage,
|
|
747
|
+
baseUrl: baseUrl$1,
|
|
748
|
+
configRedirectUri: config.redirectUri
|
|
749
|
+
});
|
|
750
|
+
return createRedirectResponse(`${baseUrl$1}/auth/login`, { headers: headers$2 });
|
|
751
|
+
}
|
|
752
|
+
if (error === "login_required") {
|
|
753
|
+
logger.info("Login required, user session may have expired");
|
|
754
|
+
const headers$2 = await destroyAuthSession(request);
|
|
755
|
+
const redirectUri$1 = new URL(config.redirectUri);
|
|
756
|
+
const baseUrl$1 = redirectUri$1.origin;
|
|
757
|
+
return createRedirectResponse(baseUrl$1, { headers: headers$2 });
|
|
758
|
+
}
|
|
759
|
+
const headers$1 = await destroyAuthSession(request);
|
|
760
|
+
headers$1.append("Set-Cookie", `${AUTH_ERROR_COOKIE}=${encodeURIComponent(error)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=300`);
|
|
761
|
+
if (errorDescription) headers$1.append("Set-Cookie", `${AUTH_ERROR_DESC_COOKIE}=${encodeURIComponent(errorDescription)}; Path=/; SameSite=Lax; Max-Age=300`);
|
|
762
|
+
const redirectUri = new URL(config.redirectUri);
|
|
763
|
+
const baseUrl = redirectUri.origin;
|
|
764
|
+
return createRedirectResponse(baseUrl, { headers: headers$1 });
|
|
765
|
+
}
|
|
766
|
+
const cookies = request.headers.get("Cookie");
|
|
767
|
+
const oauthStateRegex = new RegExp(`${OAUTH_STATE_COOKIE}=([^;]+)`);
|
|
768
|
+
const cookieMatch = cookies?.match(oauthStateRegex);
|
|
769
|
+
const stateId = cookieMatch?.[1];
|
|
770
|
+
if (!stateId) throw new Error("OAuth state ID not found in cookie");
|
|
771
|
+
const oauthState = await getOAuthState(stateId);
|
|
772
|
+
if (!oauthState) throw new Error("No OAuth state found - state may have expired");
|
|
773
|
+
if (!state) throw new Error("Missing required callback state parameter");
|
|
774
|
+
if (oauthState.state !== state) throw new Error(`OAuth state mismatch`);
|
|
775
|
+
const stateAge = Date.now() - oauthState.createdAt;
|
|
776
|
+
if (stateAge > 10 * 60 * 1e3) throw new Error("OAuth state expired");
|
|
777
|
+
if (!code) {
|
|
778
|
+
if (kcActionStatus) {
|
|
779
|
+
await deleteOAuthState(stateId);
|
|
780
|
+
const headers$1 = new Headers();
|
|
781
|
+
headers$1.append("Set-Cookie", `${OAUTH_STATE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
|
782
|
+
const redirectUrl$1 = appendApplicationInitiatedActionResult(oauthState.returnUrl || "/", kcAction || oauthState.kcAction, kcActionStatus);
|
|
783
|
+
logger.info("Application initiated action returned without authorization code", {
|
|
784
|
+
kcAction: kcAction || oauthState.kcAction,
|
|
785
|
+
kcActionStatus
|
|
786
|
+
});
|
|
787
|
+
return createRedirectResponse(redirectUrl$1, { headers: headers$1 });
|
|
788
|
+
}
|
|
789
|
+
throw new Error("Missing required callback code parameter");
|
|
790
|
+
}
|
|
791
|
+
logger.info("Initiating token exchange");
|
|
792
|
+
const oidcConfig = await getOAuthConfig();
|
|
793
|
+
const tokenResult = await oidc.authorizationCodeGrant(oidcConfig, url, {
|
|
794
|
+
pkceCodeVerifier: oauthState.codeVerifier,
|
|
795
|
+
expectedState: oauthState.state,
|
|
796
|
+
expectedNonce: oauthState.nonce,
|
|
797
|
+
idTokenExpected: config.scope.split(/\s+/).includes("openid")
|
|
798
|
+
});
|
|
799
|
+
logger.info("Token exchange completed successfully");
|
|
800
|
+
const claims = getClaimsFromTokenResponse(tokenResult);
|
|
801
|
+
const newSessionData = await createSessionData(tokenResult, claims);
|
|
802
|
+
const sessionHeaders = await createAuthSession(request, newSessionData);
|
|
803
|
+
await deleteOAuthState(stateId);
|
|
804
|
+
const headers = new Headers(sessionHeaders);
|
|
805
|
+
headers.append("Set-Cookie", `${OAUTH_STATE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`);
|
|
806
|
+
logger.info("OAuth callback processed successfully");
|
|
807
|
+
const redirectUrl = appendApplicationInitiatedActionResult(oauthState.returnUrl || "/", kcAction || oauthState.kcAction, kcActionStatus);
|
|
808
|
+
return createRedirectResponse(redirectUrl, { headers });
|
|
809
|
+
} catch (error) {
|
|
810
|
+
logger.error("OAuth callback failed", error instanceof Error ? error : void 0);
|
|
811
|
+
try {
|
|
812
|
+
const cookies = request.headers.get("Cookie");
|
|
813
|
+
const oauthStateRegex = new RegExp(`${OAUTH_STATE_COOKIE}=([^;]+)`);
|
|
814
|
+
const cookieMatch = cookies?.match(oauthStateRegex);
|
|
815
|
+
const stateId = cookieMatch?.[1];
|
|
816
|
+
if (stateId) await deleteOAuthState(stateId);
|
|
817
|
+
} catch {}
|
|
818
|
+
await destroyAuthSession(request);
|
|
819
|
+
throw new Error("Authentication failed");
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Logout user.
|
|
824
|
+
*
|
|
825
|
+
* Default logout follows OIDC RP-Initiated Logout and ends the identity
|
|
826
|
+
* provider session. Automatic/session-expiry cleanup can request
|
|
827
|
+
* `/auth/logout?logout=local` to clear only this app's Redis session without
|
|
828
|
+
* bouncing the browser through the identity provider or revoking SSO tokens.
|
|
829
|
+
*/
|
|
830
|
+
async function logout(request) {
|
|
831
|
+
try {
|
|
832
|
+
const config = getAuthConfig();
|
|
833
|
+
const url = new URL(request.url);
|
|
834
|
+
const logoutScope = getLogoutScope(url);
|
|
835
|
+
const returnUrl = sanitizeLogoutReturnUrl(url.searchParams.get("returnUrl"), request);
|
|
836
|
+
logger.info("Logout initiated", {
|
|
837
|
+
clientId: config.clientId,
|
|
838
|
+
logoutScope
|
|
839
|
+
});
|
|
840
|
+
const sessionData = await getAuthSession(request);
|
|
841
|
+
const idToken = sessionData.idToken;
|
|
842
|
+
if (logoutScope === "local") {
|
|
843
|
+
const headers$1 = await destroyAuthSession(request);
|
|
844
|
+
const redirectUrl = returnUrl || "/";
|
|
845
|
+
const absoluteRedirectUrl = getAbsoluteRedirectUrl(request, redirectUrl);
|
|
846
|
+
headers$1.append("Set-Cookie", "logout_return_url=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
|
|
847
|
+
logger.info("Local application logout completed", { redirectUrl: absoluteRedirectUrl });
|
|
848
|
+
return createRedirectResponse(absoluteRedirectUrl, { headers: headers$1 });
|
|
849
|
+
}
|
|
850
|
+
const sessionHeaders = new Headers();
|
|
851
|
+
if (logoutScope === "all" && sessionData.userId) {
|
|
852
|
+
await revokeAndDestroyAllUserSessionsBestEffort(sessionData.userId);
|
|
853
|
+
sessionHeaders.append("Set-Cookie", "logout_return_url=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
|
|
854
|
+
const expiredHeaders = await destroyAuthSession(request);
|
|
855
|
+
expiredHeaders.forEach((value, key) => sessionHeaders.append(key, value));
|
|
856
|
+
} else {
|
|
857
|
+
await revokeSessionTokensBestEffort(sessionData);
|
|
858
|
+
const expiredHeaders = await destroyAuthSession(request);
|
|
859
|
+
expiredHeaders.forEach((value, key) => sessionHeaders.append(key, value));
|
|
860
|
+
}
|
|
861
|
+
const headers = new Headers(sessionHeaders);
|
|
862
|
+
if (returnUrl) headers.append("Set-Cookie", `logout_return_url=${encodeURIComponent(returnUrl)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=300`);
|
|
863
|
+
else headers.append("Set-Cookie", "logout_return_url=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0");
|
|
864
|
+
try {
|
|
865
|
+
const oidcConfig = await getOAuthConfig();
|
|
866
|
+
const serverMetadata = oidcConfig.serverMetadata();
|
|
867
|
+
const logoutParameters = {};
|
|
868
|
+
const expectedIssuer = serverMetadata.issuer ?? config.authority;
|
|
869
|
+
if (idToken && canUseIdTokenHint(idToken, expectedIssuer)) logoutParameters.id_token_hint = idToken;
|
|
870
|
+
else if (idToken) logger.warn("Skipping id_token_hint because the session token issuer does not match the active OIDC provider", {
|
|
871
|
+
tokenIssuer: getJwtIssuer(idToken),
|
|
872
|
+
expectedIssuer: normalizeIssuer(expectedIssuer),
|
|
873
|
+
clientId: config.clientId
|
|
874
|
+
});
|
|
875
|
+
if (config.postLogoutRedirectUri) logoutParameters.post_logout_redirect_uri = config.postLogoutRedirectUri;
|
|
876
|
+
const endSessionUrl = oidc.buildEndSessionUrl(oidcConfig, logoutParameters);
|
|
877
|
+
logger.info("RP-Initiated Logout redirect created", {
|
|
878
|
+
endSessionEndpoint: serverMetadata.end_session_endpoint,
|
|
879
|
+
hasIdTokenHint: Boolean(logoutParameters.id_token_hint)
|
|
880
|
+
});
|
|
881
|
+
return createRedirectResponse(endSessionUrl.toString(), { headers });
|
|
882
|
+
} catch (error) {
|
|
883
|
+
logger.warn("OIDC end_session endpoint unavailable, falling back to local redirect", { error: getOAuthErrorMessage(error) });
|
|
884
|
+
}
|
|
885
|
+
const registeredPath = config.postLogoutRedirectUri || "/";
|
|
886
|
+
const finalRedirectUrl = getAbsoluteRedirectUrl(request, registeredPath);
|
|
887
|
+
logger.info("Logout completed", { redirectUrl: finalRedirectUrl });
|
|
888
|
+
return createRedirectResponse(finalRedirectUrl, { headers });
|
|
889
|
+
} catch (error) {
|
|
890
|
+
logger.error("Logout failed", error instanceof Error ? error : void 0);
|
|
891
|
+
const headers = await destroyAuthSession(request);
|
|
892
|
+
return createRedirectResponse("/", { headers });
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Handle an OIDC Back-Channel Logout request from Keycloak or another OP.
|
|
897
|
+
*
|
|
898
|
+
* Configure the client Backchannel logout URL to this endpoint. The logout
|
|
899
|
+
* token is signature-verified against the provider JWKS, issuer/audience are
|
|
900
|
+
* checked, and all matching local Redis sessions are destroyed by `sid` first,
|
|
901
|
+
* falling back to `sub` when the OP omits a session id.
|
|
902
|
+
*/
|
|
903
|
+
async function handleBackChannelLogout(request) {
|
|
904
|
+
if (request.method !== "POST") return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
905
|
+
try {
|
|
906
|
+
const config = getAuthConfig();
|
|
907
|
+
const oidcConfig = await getOAuthConfig();
|
|
908
|
+
const serverMetadata = oidcConfig.serverMetadata();
|
|
909
|
+
const logoutToken = await getLogoutTokenFromRequest(request);
|
|
910
|
+
if (!logoutToken) return Response.json({ error: "missing_logout_token" }, { status: 400 });
|
|
911
|
+
if (!serverMetadata.jwks_uri) throw new Error("OIDC provider does not expose jwks_uri");
|
|
912
|
+
const { payload } = await jwtVerify(logoutToken, getJwks(serverMetadata.jwks_uri), {
|
|
913
|
+
issuer: serverMetadata.issuer ?? config.authority,
|
|
914
|
+
audience: config.clientId,
|
|
915
|
+
clockTolerance: 60
|
|
916
|
+
});
|
|
917
|
+
const claims = validateBackChannelLogoutClaims(payload, config.clientId);
|
|
918
|
+
const destroyedSessions = await destroyAuthSessionsByIdentitySession({
|
|
919
|
+
sid: claims.sid,
|
|
920
|
+
userId: claims.subject
|
|
921
|
+
});
|
|
922
|
+
const result = {
|
|
923
|
+
destroyedSessions,
|
|
924
|
+
issuer: claims.issuer,
|
|
925
|
+
subject: claims.subject,
|
|
926
|
+
sid: claims.sid
|
|
927
|
+
};
|
|
928
|
+
logger.info("Back-channel logout processed", { ...result });
|
|
929
|
+
return Response.json({
|
|
930
|
+
success: true,
|
|
931
|
+
...result
|
|
932
|
+
});
|
|
933
|
+
} catch (error) {
|
|
934
|
+
logger.error("Back-channel logout failed", error instanceof Error ? error : void 0);
|
|
935
|
+
return Response.json({
|
|
936
|
+
error: "invalid_logout_token",
|
|
937
|
+
error_description: getOAuthErrorMessage(error)
|
|
938
|
+
}, { status: 400 });
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Handle OIDC Front-Channel Logout iframe/browser requests.
|
|
943
|
+
*
|
|
944
|
+
* Front-channel logout cannot carry a signed logout token, so this validates the
|
|
945
|
+
* issuer and applies local cleanup only when a `sid` is present.
|
|
946
|
+
*/
|
|
947
|
+
async function handleFrontChannelLogout(request) {
|
|
948
|
+
try {
|
|
949
|
+
const config = getAuthConfig();
|
|
950
|
+
const oidcConfig = await getOAuthConfig();
|
|
951
|
+
const serverMetadata = oidcConfig.serverMetadata();
|
|
952
|
+
const url = new URL(request.url);
|
|
953
|
+
const issuer = url.searchParams.get("iss");
|
|
954
|
+
const sid = url.searchParams.get("sid");
|
|
955
|
+
const expectedIssuer = normalizeIssuer(serverMetadata.issuer ?? config.authority);
|
|
956
|
+
if (!issuer || normalizeIssuer(issuer) !== expectedIssuer) return new Response(null, { status: 204 });
|
|
957
|
+
const destroyedSessions = sid ? await destroyAuthSessionsBySid(sid) : 0;
|
|
958
|
+
const result = {
|
|
959
|
+
destroyedSessions,
|
|
960
|
+
issuer,
|
|
961
|
+
sid: sid ?? void 0
|
|
962
|
+
};
|
|
963
|
+
logger.info("Front-channel logout processed", { ...result });
|
|
964
|
+
return new Response(null, {
|
|
965
|
+
status: 204,
|
|
966
|
+
headers: { "Cache-Control": "no-store" }
|
|
967
|
+
});
|
|
968
|
+
} catch (error) {
|
|
969
|
+
logger.warn("Front-channel logout ignored", { error: getOAuthErrorMessage(error) });
|
|
970
|
+
return new Response(null, { status: 204 });
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Get current user from session
|
|
975
|
+
*/
|
|
976
|
+
async function getUser(request) {
|
|
977
|
+
try {
|
|
978
|
+
const sessionData = await getAuthSession(request);
|
|
979
|
+
return sessionData.user || null;
|
|
980
|
+
} catch (error) {
|
|
981
|
+
logger.error("Failed to get user", error instanceof Error ? error : void 0);
|
|
982
|
+
return null;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Require authenticated user (throws redirect to login if not authenticated)
|
|
987
|
+
*/
|
|
988
|
+
async function requireAuth(request) {
|
|
989
|
+
const user = await getUser(request);
|
|
990
|
+
if (!user) {
|
|
991
|
+
const url = new URL(request.url);
|
|
992
|
+
const returnUrl = `${url.pathname}${url.search}`;
|
|
993
|
+
logger.info("No user found, starting OAuth flow", { returnUrl });
|
|
994
|
+
throw await login(request, returnUrl);
|
|
995
|
+
}
|
|
996
|
+
return user;
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Get access token from session
|
|
1000
|
+
*/
|
|
1001
|
+
async function getAccessToken(request) {
|
|
1002
|
+
try {
|
|
1003
|
+
const sessionData = await getAuthSession(request);
|
|
1004
|
+
return sessionData.accessToken || null;
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
logger.error("Failed to get access token", error instanceof Error ? error : void 0);
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Refresh tokens
|
|
1012
|
+
*/
|
|
1013
|
+
async function refreshTokens(request, refreshToken) {
|
|
1014
|
+
try {
|
|
1015
|
+
const sessionData = await getAuthSession(request);
|
|
1016
|
+
const tokenToRefresh = refreshToken || sessionData.refreshToken;
|
|
1017
|
+
if (!tokenToRefresh) {
|
|
1018
|
+
logger.warn("No refresh token available");
|
|
1019
|
+
return {
|
|
1020
|
+
success: false,
|
|
1021
|
+
error: "No refresh token available",
|
|
1022
|
+
shouldLogout: true
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
logger.info("Initiating token refresh");
|
|
1026
|
+
const oidcConfig = await getOAuthConfig();
|
|
1027
|
+
const refreshResult = await oidc.refreshTokenGrant(oidcConfig, tokenToRefresh);
|
|
1028
|
+
logger.info("Token refresh successful");
|
|
1029
|
+
const newRefreshToken = refreshResult.refresh_token || tokenToRefresh;
|
|
1030
|
+
const expiresAt = refreshResult.expires_in ? Date.now() + refreshResult.expires_in * 1e3 : sessionData.expiresAt;
|
|
1031
|
+
await updateAuthSession(request, {
|
|
1032
|
+
accessToken: refreshResult.access_token,
|
|
1033
|
+
refreshToken: newRefreshToken,
|
|
1034
|
+
idToken: refreshResult.id_token,
|
|
1035
|
+
expiresAt,
|
|
1036
|
+
lastActivity: Date.now()
|
|
1037
|
+
});
|
|
1038
|
+
return {
|
|
1039
|
+
success: true,
|
|
1040
|
+
tokens: {
|
|
1041
|
+
access_token: refreshResult.access_token,
|
|
1042
|
+
refresh_token: newRefreshToken,
|
|
1043
|
+
id_token: refreshResult.id_token,
|
|
1044
|
+
expires_in: refreshResult.expires_in
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
logger.error("Token refresh failed with exception", error instanceof Error ? error : void 0);
|
|
1049
|
+
return {
|
|
1050
|
+
success: false,
|
|
1051
|
+
error: getOAuthErrorMessage(error),
|
|
1052
|
+
shouldLogout: shouldLogoutAfterOAuthError(error)
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Clear auth server cache (useful for testing)
|
|
1058
|
+
*/
|
|
1059
|
+
function clearAuthServerCache() {
|
|
1060
|
+
authServerCache = null;
|
|
1061
|
+
jwksCache.clear();
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
//#endregion
|
|
1065
|
+
export { clearAuthErrorHeaders, clearAuthServerCache, configureAuthClaimMapping, createRedirectResponse, extractUserInfo, getAccessToken, getAuthError, getUser, handleBackChannelLogout, handleCallback, handleFrontChannelLogout, hasAuthError, login, logout, refreshTokens, requireAuth, resetAuthClaimMapping };
|
|
1066
|
+
//# sourceMappingURL=auth.server-D5oiz9aF.js.map
|