@incorta/auth 1.0.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/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/chunk-LNPECE7W.js +62 -0
- package/dist/chunk-LNPECE7W.js.map +1 -0
- package/dist/cli.js +170 -0
- package/dist/config-B-2cbZDV.d.cts +116 -0
- package/dist/config-B-2cbZDV.d.ts +116 -0
- package/dist/express.cjs +134 -0
- package/dist/express.cjs.map +1 -0
- package/dist/express.d.cts +42 -0
- package/dist/express.d.ts +42 -0
- package/dist/express.js +59 -0
- package/dist/express.js.map +1 -0
- package/dist/index.cjs +642 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +75 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.js +613 -0
- package/dist/index.js.map +1 -0
- package/dist/node.cjs +89 -0
- package/dist/node.cjs.map +1 -0
- package/dist/node.d.cts +28 -0
- package/dist/node.d.ts +28 -0
- package/dist/node.js +13 -0
- package/dist/node.js.map +1 -0
- package/dist/react/index.cjs +179 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +81 -0
- package/dist/react/index.d.ts +81 -0
- package/dist/react/index.js +153 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +73 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
IncortaAuthError: () => IncortaAuthError,
|
|
24
|
+
createIncortaAuth: () => createIncortaAuth,
|
|
25
|
+
registerClient: () => registerClient
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(src_exports);
|
|
28
|
+
|
|
29
|
+
// src/core/config.ts
|
|
30
|
+
function required(value, name, envVar) {
|
|
31
|
+
if (!value) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`[incorta-auth] Missing required config "${name}" (or env var ${envVar}).`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
function stripTrailingSlash(url) {
|
|
39
|
+
return url.replace(/\/+$/, "");
|
|
40
|
+
}
|
|
41
|
+
function resolveConfig(input = {}) {
|
|
42
|
+
const env = globalThis.process?.env ?? {};
|
|
43
|
+
const incortaUrl = stripTrailingSlash(
|
|
44
|
+
required(input.incortaUrl ?? env.INCORTA_URL, "incortaUrl", "INCORTA_URL")
|
|
45
|
+
);
|
|
46
|
+
const tenant = required(input.tenant ?? env.INCORTA_TENANT, "tenant", "INCORTA_TENANT");
|
|
47
|
+
const clientId = required(
|
|
48
|
+
input.clientId ?? env.INCORTA_CLIENT_ID,
|
|
49
|
+
"clientId",
|
|
50
|
+
"INCORTA_CLIENT_ID"
|
|
51
|
+
);
|
|
52
|
+
const clientSecret = required(
|
|
53
|
+
input.clientSecret ?? env.INCORTA_CLIENT_SECRET,
|
|
54
|
+
"clientSecret",
|
|
55
|
+
"INCORTA_CLIENT_SECRET"
|
|
56
|
+
);
|
|
57
|
+
const secret = required(
|
|
58
|
+
input.secret ?? env.INCORTA_AUTH_SECRET,
|
|
59
|
+
"secret",
|
|
60
|
+
"INCORTA_AUTH_SECRET"
|
|
61
|
+
);
|
|
62
|
+
if (secret.length < 32) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
"[incorta-auth] `secret` must be at least 32 characters. Generate one with: openssl rand -base64 32"
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const basePath = input.basePath ?? "/auth";
|
|
68
|
+
if (!basePath.startsWith("/") || basePath.endsWith("/")) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`[incorta-auth] basePath must start with "/" and not end with "/" (got "${basePath}").`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const appUrl = input.appUrl ?? env.INCORTA_APP_URL;
|
|
74
|
+
const cookieName = input.session?.cookieName ?? "incorta_auth_session";
|
|
75
|
+
const issuer = `${incortaUrl}/oauth/${tenant}`;
|
|
76
|
+
return {
|
|
77
|
+
incortaUrl,
|
|
78
|
+
tenant,
|
|
79
|
+
clientId,
|
|
80
|
+
clientSecret,
|
|
81
|
+
secret,
|
|
82
|
+
basePath,
|
|
83
|
+
appUrl: appUrl ? stripTrailingSlash(appUrl) : void 0,
|
|
84
|
+
scope: input.scope ?? "openid profile email",
|
|
85
|
+
cookieName,
|
|
86
|
+
stateCookieName: `${cookieName}_state`,
|
|
87
|
+
sessionMaxAge: input.session?.maxAge ?? 12 * 60 * 60,
|
|
88
|
+
secureCookies: input.cookies?.secure ?? "auto",
|
|
89
|
+
exposeAccessToken: input.exposeAccessToken ?? false,
|
|
90
|
+
issuer,
|
|
91
|
+
discoveryUrl: input.advanced?.discoveryUrl ?? `${issuer}/.well-known/openid-configuration`,
|
|
92
|
+
jwksOverride: input.advanced?.jwks,
|
|
93
|
+
fetch: input.advanced?.fetch ?? fetch
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// src/core/handler.ts
|
|
98
|
+
var import_node_crypto2 = require("crypto");
|
|
99
|
+
|
|
100
|
+
// src/core/cookies.ts
|
|
101
|
+
function parseCookies(header) {
|
|
102
|
+
const out = {};
|
|
103
|
+
if (!header) return out;
|
|
104
|
+
for (const part of header.split(";")) {
|
|
105
|
+
const eq = part.indexOf("=");
|
|
106
|
+
if (eq === -1) continue;
|
|
107
|
+
const name = part.slice(0, eq).trim();
|
|
108
|
+
if (!name) continue;
|
|
109
|
+
let value = part.slice(eq + 1).trim();
|
|
110
|
+
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
|
|
111
|
+
try {
|
|
112
|
+
out[name] = decodeURIComponent(value);
|
|
113
|
+
} catch {
|
|
114
|
+
out[name] = value;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
function serializeCookie(name, value, options = {}) {
|
|
120
|
+
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
121
|
+
parts.push(`Path=${options.path ?? "/"}`);
|
|
122
|
+
if (options.maxAge !== void 0) {
|
|
123
|
+
parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
|
|
124
|
+
parts.push(`Expires=${new Date(Date.now() + options.maxAge * 1e3).toUTCString()}`);
|
|
125
|
+
}
|
|
126
|
+
if (options.httpOnly ?? true) parts.push("HttpOnly");
|
|
127
|
+
if (options.secure) parts.push("Secure");
|
|
128
|
+
const sameSite = options.sameSite ?? "lax";
|
|
129
|
+
parts.push(`SameSite=${sameSite.charAt(0).toUpperCase()}${sameSite.slice(1)}`);
|
|
130
|
+
return parts.join("; ");
|
|
131
|
+
}
|
|
132
|
+
function deleteCookie(name, options = {}) {
|
|
133
|
+
return serializeCookie(name, "", { ...options, maxAge: 0 });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// src/core/discovery.ts
|
|
137
|
+
var import_jose = require("jose");
|
|
138
|
+
var endpointsCache = /* @__PURE__ */ new Map();
|
|
139
|
+
function getEndpoints(config) {
|
|
140
|
+
const cached = endpointsCache.get(config.issuer);
|
|
141
|
+
if (cached) return cached;
|
|
142
|
+
const promise = resolve(config).catch((error) => {
|
|
143
|
+
endpointsCache.delete(config.issuer);
|
|
144
|
+
throw error;
|
|
145
|
+
});
|
|
146
|
+
endpointsCache.set(config.issuer, promise);
|
|
147
|
+
return promise;
|
|
148
|
+
}
|
|
149
|
+
async function resolve(config) {
|
|
150
|
+
const fallback = {
|
|
151
|
+
issuer: config.issuer,
|
|
152
|
+
authorization_endpoint: `${config.issuer}/authorize`,
|
|
153
|
+
token_endpoint: `${config.issuer}/token`,
|
|
154
|
+
jwks_uri: `${config.issuer}/.well-known/jwks.json`
|
|
155
|
+
};
|
|
156
|
+
let doc = fallback;
|
|
157
|
+
try {
|
|
158
|
+
const response = await config.fetch(config.discoveryUrl, {
|
|
159
|
+
headers: { accept: "application/json" }
|
|
160
|
+
});
|
|
161
|
+
if (response.ok) {
|
|
162
|
+
const body = await response.json();
|
|
163
|
+
doc = {
|
|
164
|
+
issuer: body.issuer ?? fallback.issuer,
|
|
165
|
+
authorization_endpoint: body.authorization_endpoint ?? fallback.authorization_endpoint,
|
|
166
|
+
token_endpoint: body.token_endpoint ?? fallback.token_endpoint,
|
|
167
|
+
jwks_uri: body.jwks_uri ?? fallback.jwks_uri
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
}
|
|
172
|
+
const jwks = config.jwksOverride ?? (0, import_jose.createRemoteJWKSet)(new URL(doc.jwks_uri), {
|
|
173
|
+
cacheMaxAge: 10 * 60 * 1e3,
|
|
174
|
+
cooldownDuration: 30 * 1e3
|
|
175
|
+
});
|
|
176
|
+
return {
|
|
177
|
+
issuer: doc.issuer,
|
|
178
|
+
authorizationEndpoint: doc.authorization_endpoint,
|
|
179
|
+
tokenEndpoint: doc.token_endpoint,
|
|
180
|
+
jwks
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/core/session.ts
|
|
185
|
+
var import_node_crypto = require("crypto");
|
|
186
|
+
var import_jose2 = require("jose");
|
|
187
|
+
function sealKey(config) {
|
|
188
|
+
return new Uint8Array(
|
|
189
|
+
(0, import_node_crypto.createHash)("sha256").update(`incorta-auth:${config.secret}`).digest()
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
async function seal(config, purpose, data, maxAgeSeconds) {
|
|
193
|
+
return new import_jose2.EncryptJWT({ purpose, data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${maxAgeSeconds}s`).encrypt(sealKey(config));
|
|
194
|
+
}
|
|
195
|
+
async function unseal(config, purpose, cookieValue) {
|
|
196
|
+
if (!cookieValue) return null;
|
|
197
|
+
try {
|
|
198
|
+
const { payload } = await (0, import_jose2.jwtDecrypt)(cookieValue, sealKey(config));
|
|
199
|
+
if (payload.purpose !== purpose) return null;
|
|
200
|
+
return {
|
|
201
|
+
data: payload.data,
|
|
202
|
+
issuedAt: (payload.iat ?? 0) * 1e3,
|
|
203
|
+
expiresAt: (payload.exp ?? 0) * 1e3
|
|
204
|
+
};
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function sealSession(config, session) {
|
|
210
|
+
return seal(config, "session", session, config.sessionMaxAge);
|
|
211
|
+
}
|
|
212
|
+
async function unsealSession(config, cookieValue) {
|
|
213
|
+
const result = await unseal(config, "session", cookieValue);
|
|
214
|
+
if (!result) return null;
|
|
215
|
+
const { data, issuedAt, expiresAt } = result;
|
|
216
|
+
if (!data || typeof data !== "object" || !data.user || !data.accessToken) return null;
|
|
217
|
+
return { session: data, issuedAt, expiresAt };
|
|
218
|
+
}
|
|
219
|
+
var STATE_MAX_AGE_SECONDS = 10 * 60;
|
|
220
|
+
function sealLoginState(config, state) {
|
|
221
|
+
return seal(config, "state", state, STATE_MAX_AGE_SECONDS);
|
|
222
|
+
}
|
|
223
|
+
async function unsealLoginState(config, cookieValue) {
|
|
224
|
+
const result = await unseal(config, "state", cookieValue);
|
|
225
|
+
if (!result) return null;
|
|
226
|
+
const { data } = result;
|
|
227
|
+
if (!data || typeof data.state !== "string" || typeof data.nonce !== "string") return null;
|
|
228
|
+
return data;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/core/tokens.ts
|
|
232
|
+
var import_jose3 = require("jose");
|
|
233
|
+
var IncortaAuthError = class extends Error {
|
|
234
|
+
constructor(code, message) {
|
|
235
|
+
super(`[incorta-auth] ${message}`);
|
|
236
|
+
this.code = code;
|
|
237
|
+
this.name = "IncortaAuthError";
|
|
238
|
+
}
|
|
239
|
+
code;
|
|
240
|
+
};
|
|
241
|
+
function basicAuth(config) {
|
|
242
|
+
const raw = `${config.clientId}:${config.clientSecret}`;
|
|
243
|
+
return `Basic ${Buffer.from(raw, "utf8").toString("base64")}`;
|
|
244
|
+
}
|
|
245
|
+
async function callTokenEndpoint(config, params) {
|
|
246
|
+
const { tokenEndpoint } = await getEndpoints(config);
|
|
247
|
+
let response;
|
|
248
|
+
try {
|
|
249
|
+
response = await config.fetch(tokenEndpoint, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: {
|
|
252
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
253
|
+
accept: "application/json",
|
|
254
|
+
authorization: basicAuth(config)
|
|
255
|
+
},
|
|
256
|
+
body: new URLSearchParams(params).toString()
|
|
257
|
+
});
|
|
258
|
+
} catch (error) {
|
|
259
|
+
throw new IncortaAuthError(
|
|
260
|
+
"incorta_unreachable",
|
|
261
|
+
`Could not reach the Incorta token endpoint (${tokenEndpoint}): ${error.message}`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
let body;
|
|
265
|
+
try {
|
|
266
|
+
body = await response.json();
|
|
267
|
+
} catch {
|
|
268
|
+
throw new IncortaAuthError(
|
|
269
|
+
"invalid_token_response",
|
|
270
|
+
`Token endpoint returned a non-JSON response (HTTP ${response.status}).`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (!response.ok || !body.access_token) {
|
|
274
|
+
throw new IncortaAuthError(
|
|
275
|
+
body.error ?? "token_exchange_failed",
|
|
276
|
+
body.error_description ?? `Token exchange failed (HTTP ${response.status}).`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const { exp } = (0, import_jose3.decodeJwt)(body.access_token);
|
|
280
|
+
return {
|
|
281
|
+
accessToken: body.access_token,
|
|
282
|
+
accessTokenExpiresAt: exp ? exp * 1e3 : Date.now() + 60 * 60 * 1e3,
|
|
283
|
+
idToken: body.id_token,
|
|
284
|
+
refreshToken: body.refresh_token
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function exchangeCode(config, code, redirectUri) {
|
|
288
|
+
return callTokenEndpoint(config, {
|
|
289
|
+
grant_type: "authorization_code",
|
|
290
|
+
code,
|
|
291
|
+
redirect_uri: redirectUri
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
function refreshTokens(config, refreshToken) {
|
|
295
|
+
return callTokenEndpoint(config, {
|
|
296
|
+
grant_type: "refresh_token",
|
|
297
|
+
refresh_token: refreshToken
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
async function verifyJwt(config, token) {
|
|
301
|
+
const { jwks, issuer } = await getEndpoints(config);
|
|
302
|
+
try {
|
|
303
|
+
const { payload } = await (0, import_jose3.jwtVerify)(token, jwks, {
|
|
304
|
+
issuer,
|
|
305
|
+
audience: config.clientId,
|
|
306
|
+
algorithms: ["RS256"]
|
|
307
|
+
});
|
|
308
|
+
return payload;
|
|
309
|
+
} catch (error) {
|
|
310
|
+
throw new IncortaAuthError(
|
|
311
|
+
"invalid_token",
|
|
312
|
+
`Token verification failed: ${error.message}`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async function verifyAccessToken(config, accessToken) {
|
|
317
|
+
return verifyJwt(config, accessToken);
|
|
318
|
+
}
|
|
319
|
+
async function verifyIdToken(config, idToken, expectedNonce) {
|
|
320
|
+
const payload = await verifyJwt(config, idToken);
|
|
321
|
+
if (payload.nonce !== expectedNonce) {
|
|
322
|
+
throw new IncortaAuthError("nonce_mismatch", "ID token nonce does not match the login request.");
|
|
323
|
+
}
|
|
324
|
+
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
325
|
+
throw new IncortaAuthError("invalid_token", "ID token is missing the sub claim.");
|
|
326
|
+
}
|
|
327
|
+
const roles = Array.isArray(payload.roles) ? payload.roles.filter((role) => typeof role === "string") : [];
|
|
328
|
+
return {
|
|
329
|
+
sub: payload.sub,
|
|
330
|
+
name: typeof payload.name === "string" ? payload.name : void 0,
|
|
331
|
+
email: typeof payload.email === "string" ? payload.email : void 0,
|
|
332
|
+
roles,
|
|
333
|
+
tenant: typeof payload.tenant === "string" ? payload.tenant : config.tenant
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
async function userFromAccessToken(config, accessToken) {
|
|
337
|
+
const payload = await verifyAccessToken(config, accessToken);
|
|
338
|
+
if (typeof payload.sub !== "string" || !payload.sub) {
|
|
339
|
+
throw new IncortaAuthError("invalid_token", "Access token is missing the sub claim.");
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
sub: payload.sub,
|
|
343
|
+
roles: [],
|
|
344
|
+
tenant: typeof payload.tenant === "string" ? payload.tenant : config.tenant
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/core/handler.ts
|
|
349
|
+
var ACCESS_TOKEN_REFRESH_LEEWAY_MS = 60 * 1e3;
|
|
350
|
+
function requestOrigin(config, request) {
|
|
351
|
+
if (config.appUrl) return config.appUrl;
|
|
352
|
+
const url = new URL(request.url);
|
|
353
|
+
const proto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ?? url.protocol.replace(":", "");
|
|
354
|
+
const host = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ?? request.headers.get("host") ?? url.host;
|
|
355
|
+
return `${proto}://${host}`;
|
|
356
|
+
}
|
|
357
|
+
function isSecureRequest(config, request) {
|
|
358
|
+
if (config.secureCookies !== "auto") return config.secureCookies;
|
|
359
|
+
return requestOrigin(config, request).startsWith("https://");
|
|
360
|
+
}
|
|
361
|
+
function validateReturnTo(raw, origin) {
|
|
362
|
+
if (!raw) return "/";
|
|
363
|
+
if (raw.startsWith("/") && !raw.startsWith("//")) return raw;
|
|
364
|
+
try {
|
|
365
|
+
const url = new URL(raw);
|
|
366
|
+
if (url.origin === origin) return `${url.pathname}${url.search}${url.hash}` || "/";
|
|
367
|
+
} catch {
|
|
368
|
+
}
|
|
369
|
+
return "/";
|
|
370
|
+
}
|
|
371
|
+
function json(status, body, cookies = []) {
|
|
372
|
+
const headers = new Headers({
|
|
373
|
+
"content-type": "application/json; charset=utf-8",
|
|
374
|
+
"cache-control": "no-store"
|
|
375
|
+
});
|
|
376
|
+
for (const cookie of cookies) headers.append("set-cookie", cookie);
|
|
377
|
+
return new Response(JSON.stringify(body), { status, headers });
|
|
378
|
+
}
|
|
379
|
+
function redirect(location, cookies = []) {
|
|
380
|
+
const headers = new Headers({ location, "cache-control": "no-store" });
|
|
381
|
+
for (const cookie of cookies) headers.append("set-cookie", cookie);
|
|
382
|
+
return new Response(null, { status: 302, headers });
|
|
383
|
+
}
|
|
384
|
+
function withErrorParam(target, code) {
|
|
385
|
+
const separator = target.includes("?") ? "&" : "?";
|
|
386
|
+
return `${target}${separator}incorta_auth_error=${encodeURIComponent(code)}`;
|
|
387
|
+
}
|
|
388
|
+
function randomToken() {
|
|
389
|
+
return (0, import_node_crypto2.randomBytes)(16).toString("base64url");
|
|
390
|
+
}
|
|
391
|
+
function createHandler(config) {
|
|
392
|
+
const sessionCookieOptions = (secure) => ({
|
|
393
|
+
httpOnly: true,
|
|
394
|
+
secure,
|
|
395
|
+
sameSite: "lax",
|
|
396
|
+
path: "/"
|
|
397
|
+
});
|
|
398
|
+
async function login(request, url) {
|
|
399
|
+
const origin = requestOrigin(config, request);
|
|
400
|
+
const secure = isSecureRequest(config, request);
|
|
401
|
+
const returnTo = validateReturnTo(url.searchParams.get("redirect_to"), origin);
|
|
402
|
+
const cookies = parseCookies(request.headers.get("cookie"));
|
|
403
|
+
const existing = await unsealSession(config, cookies[config.cookieName]);
|
|
404
|
+
if (existing) return redirect(returnTo);
|
|
405
|
+
const state = randomToken();
|
|
406
|
+
const nonce = randomToken();
|
|
407
|
+
const stateCookie = serializeCookie(
|
|
408
|
+
config.stateCookieName,
|
|
409
|
+
await sealLoginState(config, { state, nonce, returnTo }),
|
|
410
|
+
{ ...sessionCookieOptions(secure), maxAge: 600 }
|
|
411
|
+
);
|
|
412
|
+
const { authorizationEndpoint } = await getEndpoints(config);
|
|
413
|
+
const authorizeUrl = new URL(authorizationEndpoint);
|
|
414
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
415
|
+
authorizeUrl.searchParams.set("client_id", config.clientId);
|
|
416
|
+
authorizeUrl.searchParams.set("redirect_uri", `${origin}${config.basePath}/callback`);
|
|
417
|
+
authorizeUrl.searchParams.set("scope", config.scope);
|
|
418
|
+
authorizeUrl.searchParams.set("state", state);
|
|
419
|
+
authorizeUrl.searchParams.set("nonce", nonce);
|
|
420
|
+
return redirect(authorizeUrl.toString(), [stateCookie]);
|
|
421
|
+
}
|
|
422
|
+
async function callback(request, url) {
|
|
423
|
+
const origin = requestOrigin(config, request);
|
|
424
|
+
const secure = isSecureRequest(config, request);
|
|
425
|
+
const cookies = parseCookies(request.headers.get("cookie"));
|
|
426
|
+
const clearState = deleteCookie(config.stateCookieName, sessionCookieOptions(secure));
|
|
427
|
+
const loginState = await unsealLoginState(config, cookies[config.stateCookieName]);
|
|
428
|
+
const returnTo = validateReturnTo(loginState?.returnTo ?? null, origin);
|
|
429
|
+
const fail = (code2) => redirect(withErrorParam(returnTo, code2), [clearState]);
|
|
430
|
+
const oauthError = url.searchParams.get("error");
|
|
431
|
+
if (oauthError) return fail(oauthError);
|
|
432
|
+
const code = url.searchParams.get("code");
|
|
433
|
+
const state = url.searchParams.get("state");
|
|
434
|
+
if (!code || !state) return fail("missing_code");
|
|
435
|
+
if (!loginState) return fail("state_missing");
|
|
436
|
+
if (state !== loginState.state) return fail("state_mismatch");
|
|
437
|
+
try {
|
|
438
|
+
const tokens = await exchangeCode(config, code, `${origin}${config.basePath}/callback`);
|
|
439
|
+
const user = tokens.idToken ? await verifyIdToken(config, tokens.idToken, loginState.nonce) : await userFromAccessToken(config, tokens.accessToken);
|
|
440
|
+
const session = {
|
|
441
|
+
user,
|
|
442
|
+
accessToken: tokens.accessToken,
|
|
443
|
+
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
|
|
444
|
+
refreshToken: tokens.refreshToken,
|
|
445
|
+
createdAt: Date.now()
|
|
446
|
+
};
|
|
447
|
+
const sessionCookie = serializeCookie(
|
|
448
|
+
config.cookieName,
|
|
449
|
+
await sealSession(config, session),
|
|
450
|
+
{ ...sessionCookieOptions(secure), maxAge: config.sessionMaxAge }
|
|
451
|
+
);
|
|
452
|
+
return redirect(returnTo, [sessionCookie, clearState]);
|
|
453
|
+
} catch (error) {
|
|
454
|
+
const code2 = error instanceof IncortaAuthError ? error.code : "callback_failed";
|
|
455
|
+
return fail(code2);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
async function loadSession(request) {
|
|
459
|
+
const secure = isSecureRequest(config, request);
|
|
460
|
+
const cookies = parseCookies(request.headers.get("cookie"));
|
|
461
|
+
const unsealed = await unsealSession(config, cookies[config.cookieName]);
|
|
462
|
+
if (!unsealed) return { session: null, sessionExpiresAt: 0, setCookies: [] };
|
|
463
|
+
let { session } = unsealed;
|
|
464
|
+
let mustReseal = false;
|
|
465
|
+
const now = Date.now();
|
|
466
|
+
if (session.accessTokenExpiresAt - now < ACCESS_TOKEN_REFRESH_LEEWAY_MS) {
|
|
467
|
+
if (session.refreshToken) {
|
|
468
|
+
try {
|
|
469
|
+
const tokens = await refreshTokens(config, session.refreshToken);
|
|
470
|
+
session = {
|
|
471
|
+
...session,
|
|
472
|
+
accessToken: tokens.accessToken,
|
|
473
|
+
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
|
|
474
|
+
refreshToken: tokens.refreshToken ?? session.refreshToken
|
|
475
|
+
};
|
|
476
|
+
mustReseal = true;
|
|
477
|
+
} catch {
|
|
478
|
+
if (session.accessTokenExpiresAt <= now) {
|
|
479
|
+
return {
|
|
480
|
+
session: null,
|
|
481
|
+
sessionExpiresAt: 0,
|
|
482
|
+
setCookies: [deleteCookie(config.cookieName, sessionCookieOptions(secure))]
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
} else if (session.accessTokenExpiresAt <= now) {
|
|
487
|
+
return {
|
|
488
|
+
session: null,
|
|
489
|
+
sessionExpiresAt: 0,
|
|
490
|
+
setCookies: [deleteCookie(config.cookieName, sessionCookieOptions(secure))]
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const halfLifeMs = config.sessionMaxAge * 1e3 / 2;
|
|
495
|
+
if (now - unsealed.issuedAt > halfLifeMs) mustReseal = true;
|
|
496
|
+
if (!mustReseal) {
|
|
497
|
+
return { session, sessionExpiresAt: unsealed.expiresAt, setCookies: [] };
|
|
498
|
+
}
|
|
499
|
+
const resealed = serializeCookie(config.cookieName, await sealSession(config, session), {
|
|
500
|
+
...sessionCookieOptions(secure),
|
|
501
|
+
maxAge: config.sessionMaxAge
|
|
502
|
+
});
|
|
503
|
+
return {
|
|
504
|
+
session,
|
|
505
|
+
sessionExpiresAt: now + config.sessionMaxAge * 1e3,
|
|
506
|
+
setCookies: [resealed]
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
async function sessionRoute(request) {
|
|
510
|
+
const { session, sessionExpiresAt, setCookies } = await loadSession(request);
|
|
511
|
+
if (!session) return json(200, { user: null }, setCookies);
|
|
512
|
+
return json(
|
|
513
|
+
200,
|
|
514
|
+
{
|
|
515
|
+
user: session.user,
|
|
516
|
+
session: {
|
|
517
|
+
expiresAt: sessionExpiresAt,
|
|
518
|
+
accessTokenExpiresAt: session.accessTokenExpiresAt
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
setCookies
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
async function tokenRoute(request) {
|
|
525
|
+
if (!config.exposeAccessToken) return json(404, { error: "not_found" });
|
|
526
|
+
const { session, setCookies } = await loadSession(request);
|
|
527
|
+
if (!session) return json(401, { error: "unauthenticated" }, setCookies);
|
|
528
|
+
return json(
|
|
529
|
+
200,
|
|
530
|
+
{ accessToken: session.accessToken, expiresAt: session.accessTokenExpiresAt },
|
|
531
|
+
setCookies
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
async function logout(request, url) {
|
|
535
|
+
const secure = isSecureRequest(config, request);
|
|
536
|
+
const clearSession = deleteCookie(config.cookieName, sessionCookieOptions(secure));
|
|
537
|
+
if (request.method === "GET") {
|
|
538
|
+
const origin = requestOrigin(config, request);
|
|
539
|
+
const returnTo = validateReturnTo(url.searchParams.get("redirect_to"), origin);
|
|
540
|
+
return redirect(returnTo, [clearSession]);
|
|
541
|
+
}
|
|
542
|
+
return json(200, { ok: true }, [clearSession]);
|
|
543
|
+
}
|
|
544
|
+
async function handler(request) {
|
|
545
|
+
const url = new URL(request.url);
|
|
546
|
+
if (url.pathname !== config.basePath && !url.pathname.startsWith(`${config.basePath}/`)) {
|
|
547
|
+
return json(404, { error: "not_found" });
|
|
548
|
+
}
|
|
549
|
+
const route = url.pathname.slice(config.basePath.length) || "/";
|
|
550
|
+
const method = request.method.toUpperCase();
|
|
551
|
+
try {
|
|
552
|
+
if (route === "/login" && method === "GET") return await login(request, url);
|
|
553
|
+
if (route === "/callback" && method === "GET") return await callback(request, url);
|
|
554
|
+
if (route === "/session" && method === "GET") return await sessionRoute(request);
|
|
555
|
+
if (route === "/token" && method === "GET") return await tokenRoute(request);
|
|
556
|
+
if (route === "/logout" && (method === "POST" || method === "GET")) {
|
|
557
|
+
return await logout(request, url);
|
|
558
|
+
}
|
|
559
|
+
return json(404, { error: "not_found" });
|
|
560
|
+
} catch (error) {
|
|
561
|
+
const code = error instanceof IncortaAuthError ? error.code : "internal_error";
|
|
562
|
+
const message = error instanceof Error ? error.message : "Unexpected incorta-auth handler error";
|
|
563
|
+
console.error(`[incorta-auth] ${route} failed:`, message);
|
|
564
|
+
return json(500, { error: code });
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
async function getSession(request) {
|
|
568
|
+
const cookies = parseCookies(request.headers.get("cookie"));
|
|
569
|
+
const unsealed = await unsealSession(config, cookies[config.cookieName]);
|
|
570
|
+
if (!unsealed) return null;
|
|
571
|
+
const { session, expiresAt } = unsealed;
|
|
572
|
+
if (session.accessTokenExpiresAt <= Date.now() && !session.refreshToken) return null;
|
|
573
|
+
return {
|
|
574
|
+
user: session.user,
|
|
575
|
+
accessToken: session.accessToken,
|
|
576
|
+
accessTokenExpiresAt: session.accessTokenExpiresAt,
|
|
577
|
+
expiresAt
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
return { handler, getSession };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/core/register.ts
|
|
584
|
+
async function registerClient(options) {
|
|
585
|
+
const {
|
|
586
|
+
incortaUrl,
|
|
587
|
+
tenant,
|
|
588
|
+
name,
|
|
589
|
+
redirectUris,
|
|
590
|
+
scope = "openid profile email",
|
|
591
|
+
grantTypes = ["authorization_code", "refresh_token"],
|
|
592
|
+
fetchImpl = fetch
|
|
593
|
+
} = options;
|
|
594
|
+
if (!redirectUris.length) {
|
|
595
|
+
throw new Error("[incorta-auth] registerClient requires at least one redirect URI.");
|
|
596
|
+
}
|
|
597
|
+
const endpoint = `${incortaUrl.replace(/\/+$/, "")}/oauth/${tenant}/register`;
|
|
598
|
+
const response = await fetchImpl(endpoint, {
|
|
599
|
+
method: "POST",
|
|
600
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
601
|
+
body: JSON.stringify({
|
|
602
|
+
client_name: name,
|
|
603
|
+
redirect_uris: redirectUris,
|
|
604
|
+
grant_types: grantTypes,
|
|
605
|
+
scope,
|
|
606
|
+
token_endpoint_auth_method: "client_secret_basic"
|
|
607
|
+
})
|
|
608
|
+
});
|
|
609
|
+
let body;
|
|
610
|
+
try {
|
|
611
|
+
body = await response.json();
|
|
612
|
+
} catch {
|
|
613
|
+
throw new Error(
|
|
614
|
+
`[incorta-auth] Client registration returned a non-JSON response (HTTP ${response.status}). Is the OAuth authorization server enabled on this cluster?`
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
if (!response.ok || !body.client_id || !body.client_secret) {
|
|
618
|
+
throw new Error(
|
|
619
|
+
`[incorta-auth] Client registration failed (HTTP ${response.status}): ${body.error ?? "unknown_error"} \u2014 ${body.error_description ?? "no description"}`
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
clientId: body.client_id,
|
|
624
|
+
clientSecret: body.client_secret,
|
|
625
|
+
registrationAccessToken: body.registration_access_token,
|
|
626
|
+
registrationClientUri: body.registration_client_uri
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// src/index.ts
|
|
631
|
+
function createIncortaAuth(config = {}) {
|
|
632
|
+
const resolved = resolveConfig(config);
|
|
633
|
+
const { handler, getSession } = createHandler(resolved);
|
|
634
|
+
return { handler, getSession, config: resolved };
|
|
635
|
+
}
|
|
636
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
637
|
+
0 && (module.exports = {
|
|
638
|
+
IncortaAuthError,
|
|
639
|
+
createIncortaAuth,
|
|
640
|
+
registerClient
|
|
641
|
+
});
|
|
642
|
+
//# sourceMappingURL=index.cjs.map
|