@incorta/auth 1.6.0 → 1.7.1
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/README.md +40 -0
- package/dist/{config-DmeXKgtO.d.cts → app-access-BSsKtvbi.d.cts} +58 -1
- package/dist/{config-DmeXKgtO.d.ts → app-access-BSsKtvbi.d.ts} +58 -1
- package/dist/chunk-TMXOEIA2.js +102 -0
- package/dist/chunk-TMXOEIA2.js.map +1 -0
- package/dist/express.cjs +52 -2
- package/dist/express.cjs.map +1 -1
- package/dist/express.d.cts +20 -3
- package/dist/express.d.ts +20 -3
- package/dist/express.js +47 -2
- package/dist/express.js.map +1 -1
- package/dist/index.cjs +279 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -3
- package/dist/index.d.ts +34 -3
- package/dist/index.js +190 -3
- package/dist/index.js.map +1 -1
- package/dist/node.d.cts +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/react/index.d.cts +1 -1
- package/dist/react/index.d.ts +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -20,7 +20,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var src_exports = {};
|
|
22
22
|
__export(src_exports, {
|
|
23
|
+
AppAccessTokenRejectedError: () => AppAccessTokenRejectedError,
|
|
24
|
+
AppAccessUnavailableError: () => AppAccessUnavailableError,
|
|
25
|
+
EDIT_BIT: () => EDIT_BIT,
|
|
23
26
|
IncortaAuthError: () => IncortaAuthError,
|
|
27
|
+
SHARE_BIT: () => SHARE_BIT,
|
|
28
|
+
VIEW_BIT: () => VIEW_BIT,
|
|
24
29
|
createIncortaAuth: () => createIncortaAuth,
|
|
25
30
|
deleteClient: () => deleteClient,
|
|
26
31
|
getClient: () => getClient,
|
|
@@ -30,6 +35,16 @@ __export(src_exports, {
|
|
|
30
35
|
module.exports = __toCommonJS(src_exports);
|
|
31
36
|
|
|
32
37
|
// src/core/config.ts
|
|
38
|
+
function resolveAppAccess(input, envValue) {
|
|
39
|
+
if (input) return input;
|
|
40
|
+
const raw = envValue?.trim().toLowerCase();
|
|
41
|
+
if (!raw) return "authenticated";
|
|
42
|
+
if (raw === "public" || raw === "authenticated" || raw === "catalog") return raw;
|
|
43
|
+
console.warn(
|
|
44
|
+
`[incorta-auth] Unrecognized INCORTA_APP_ACCESS value "${envValue}" \u2014 using "authenticated".`
|
|
45
|
+
);
|
|
46
|
+
return "authenticated";
|
|
47
|
+
}
|
|
33
48
|
function required(value, name, envVar) {
|
|
34
49
|
if (!value) {
|
|
35
50
|
throw new Error(
|
|
@@ -103,6 +118,7 @@ function resolveConfig(input = {}) {
|
|
|
103
118
|
cookieSameSite,
|
|
104
119
|
cookiePartitioned,
|
|
105
120
|
exposeAccessToken: input.exposeAccessToken ?? false,
|
|
121
|
+
appAccess: resolveAppAccess(input.appAccess, env.INCORTA_APP_ACCESS),
|
|
106
122
|
embeddedSignIn: input.embeddedSignIn ?? (env.INCORTA_EMBEDDED_SIGN_IN?.toLowerCase() === "new-tab" ? "new-tab" : "redirect"),
|
|
107
123
|
issuer,
|
|
108
124
|
discoveryUrl: input.advanced?.discoveryUrl ?? `${issuer}/.well-known/openid-configuration`,
|
|
@@ -114,6 +130,99 @@ function resolveConfig(input = {}) {
|
|
|
114
130
|
// src/core/handler.ts
|
|
115
131
|
var import_node_crypto2 = require("crypto");
|
|
116
132
|
|
|
133
|
+
// src/core/app-access.ts
|
|
134
|
+
var VIEW_BIT = 1;
|
|
135
|
+
var SHARE_BIT = 2;
|
|
136
|
+
var EDIT_BIT = 4;
|
|
137
|
+
var AppAccessUnavailableError = class extends Error {
|
|
138
|
+
constructor(cause) {
|
|
139
|
+
super(`[incorta-auth] App access check unavailable: ${cause}`);
|
|
140
|
+
this.name = "AppAccessUnavailableError";
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
var AppAccessTokenRejectedError = class extends Error {
|
|
144
|
+
constructor() {
|
|
145
|
+
super("[incorta-auth] Incorta rejected the session token during the app access check");
|
|
146
|
+
this.name = "AppAccessTokenRejectedError";
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
var APP_ACCESS_CACHE_TTL_MS = 3e4;
|
|
150
|
+
var CHECK_TIMEOUT_MS = 5e3;
|
|
151
|
+
var CACHE_MAX_ENTRIES = 5e3;
|
|
152
|
+
function toVerdict(registered, access) {
|
|
153
|
+
const canView = (access & VIEW_BIT) === VIEW_BIT;
|
|
154
|
+
return {
|
|
155
|
+
registered,
|
|
156
|
+
access,
|
|
157
|
+
canView,
|
|
158
|
+
canEdit: (access & EDIT_BIT) === EDIT_BIT,
|
|
159
|
+
allowed: !registered || canView
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function createAppAccessChecker(config) {
|
|
163
|
+
const base = config.internalIncortaUrl ?? config.incortaUrl;
|
|
164
|
+
const checkUrl = `${base}/apps-catalog/check`;
|
|
165
|
+
const cache = /* @__PURE__ */ new Map();
|
|
166
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
167
|
+
function prune(now) {
|
|
168
|
+
if (cache.size < CACHE_MAX_ENTRIES) return;
|
|
169
|
+
for (const [key, entry] of cache) {
|
|
170
|
+
if (entry.expiresAt <= now) cache.delete(key);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function fetchVerdict(accessToken) {
|
|
174
|
+
let response;
|
|
175
|
+
try {
|
|
176
|
+
response = await config.fetch(checkUrl, {
|
|
177
|
+
headers: {
|
|
178
|
+
accept: "application/json",
|
|
179
|
+
authorization: `Bearer ${accessToken}`
|
|
180
|
+
},
|
|
181
|
+
signal: AbortSignal.timeout(CHECK_TIMEOUT_MS)
|
|
182
|
+
});
|
|
183
|
+
} catch (error) {
|
|
184
|
+
throw new AppAccessUnavailableError(error.message);
|
|
185
|
+
}
|
|
186
|
+
if (response.status === 401) throw new AppAccessTokenRejectedError();
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
throw new AppAccessUnavailableError(`HTTP ${response.status}`);
|
|
189
|
+
}
|
|
190
|
+
let body;
|
|
191
|
+
try {
|
|
192
|
+
body = await response.json();
|
|
193
|
+
} catch {
|
|
194
|
+
throw new AppAccessUnavailableError("invalid JSON response");
|
|
195
|
+
}
|
|
196
|
+
if (typeof body.registered !== "boolean" || typeof body.access !== "number") {
|
|
197
|
+
throw new AppAccessUnavailableError("malformed check response");
|
|
198
|
+
}
|
|
199
|
+
return toVerdict(body.registered, body.access);
|
|
200
|
+
}
|
|
201
|
+
async function check(accessToken, userKey) {
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
const cached = cache.get(userKey);
|
|
204
|
+
if (cached && cached.expiresAt > now) return cached.verdict;
|
|
205
|
+
const pending = inFlight.get(userKey);
|
|
206
|
+
if (pending) return pending;
|
|
207
|
+
const promise = fetchVerdict(accessToken).then((verdict) => {
|
|
208
|
+
prune(now);
|
|
209
|
+
cache.set(userKey, { verdict, expiresAt: Date.now() + APP_ACCESS_CACHE_TTL_MS });
|
|
210
|
+
return verdict;
|
|
211
|
+
}).finally(() => {
|
|
212
|
+
inFlight.delete(userKey);
|
|
213
|
+
});
|
|
214
|
+
inFlight.set(userKey, promise);
|
|
215
|
+
return promise;
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
check,
|
|
219
|
+
clearCache() {
|
|
220
|
+
cache.clear();
|
|
221
|
+
inFlight.clear();
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
117
226
|
// src/core/cookies.ts
|
|
118
227
|
function parseCookies(header) {
|
|
119
228
|
const out = {};
|
|
@@ -412,7 +521,17 @@ function withErrorParam(target, code) {
|
|
|
412
521
|
function randomToken() {
|
|
413
522
|
return (0, import_node_crypto2.randomBytes)(16).toString("base64url");
|
|
414
523
|
}
|
|
524
|
+
function escapeHtml(value) {
|
|
525
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
526
|
+
}
|
|
527
|
+
function wantsHtml(request) {
|
|
528
|
+
return request.method.toUpperCase() === "GET" && (request.headers.get("accept") ?? "").includes("text/html");
|
|
529
|
+
}
|
|
530
|
+
function requestPath(url) {
|
|
531
|
+
return `${url.pathname}${url.search}`;
|
|
532
|
+
}
|
|
415
533
|
function createHandler(config) {
|
|
534
|
+
const appAccessChecker = createAppAccessChecker(config);
|
|
416
535
|
const sessionCookieOptions = (secure) => ({
|
|
417
536
|
httpOnly: true,
|
|
418
537
|
// SameSite=None requires Secure — force the pair even on plain http
|
|
@@ -562,6 +681,18 @@ function createHandler(config) {
|
|
|
562
681
|
setCookies
|
|
563
682
|
);
|
|
564
683
|
}
|
|
684
|
+
async function refreshRoute(request, url) {
|
|
685
|
+
const origin = requestOrigin(config, request);
|
|
686
|
+
const returnTo = validateReturnTo(url.searchParams.get("redirect_to"), origin);
|
|
687
|
+
const { session, setCookies } = await loadSession(request);
|
|
688
|
+
if (!session) {
|
|
689
|
+
return redirect(
|
|
690
|
+
`${config.basePath}/login?redirect_to=${encodeURIComponent(returnTo)}`,
|
|
691
|
+
setCookies
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
return redirect(returnTo, setCookies);
|
|
695
|
+
}
|
|
565
696
|
async function logout(request, url) {
|
|
566
697
|
const secure = isSecureRequest(config, request);
|
|
567
698
|
const clearSession = deleteCookie(config.cookieName, sessionCookieOptions(secure));
|
|
@@ -662,6 +793,95 @@ function createHandler(config) {
|
|
|
662
793
|
for (const cookie of cookies) headers.append("set-cookie", cookie);
|
|
663
794
|
return new Response(body, { status: 200, headers });
|
|
664
795
|
}
|
|
796
|
+
function accessDeniedResponse(request, session) {
|
|
797
|
+
if (!wantsHtml(request)) {
|
|
798
|
+
return json(403, { error: "app_access_denied" });
|
|
799
|
+
}
|
|
800
|
+
const identity = escapeHtml(session.user.name?.trim() || session.user.sub);
|
|
801
|
+
const detail = session.user.name?.trim() && session.user.name.trim() !== session.user.sub ? `${identity} (${escapeHtml(session.user.sub)})` : identity;
|
|
802
|
+
const switchHref = `${config.basePath}/logout`;
|
|
803
|
+
return new Response(
|
|
804
|
+
`<!doctype html>
|
|
805
|
+
<html lang="en">
|
|
806
|
+
<head>
|
|
807
|
+
<meta charset="utf-8" />
|
|
808
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
809
|
+
<title>No access</title>
|
|
810
|
+
<style>
|
|
811
|
+
body { margin: 0; display: grid; place-items: center; min-height: 100vh;
|
|
812
|
+
background: #fafafa; color: #18181b;
|
|
813
|
+
font-family: ui-sans-serif, system-ui, sans-serif; }
|
|
814
|
+
main { max-width: 26rem; padding: 2rem; text-align: center; }
|
|
815
|
+
h1 { font-size: 1.25rem; margin: 0 0 .5rem; }
|
|
816
|
+
p { color: #52525b; font-size: .9rem; line-height: 1.5; margin: 0 0 1.25rem; }
|
|
817
|
+
strong { color: #18181b; font-weight: 600; }
|
|
818
|
+
a { display: inline-block; padding: .55rem 1.1rem; border-radius: .5rem;
|
|
819
|
+
background: #18181b; color: #fafafa; font-size: .9rem;
|
|
820
|
+
text-decoration: none; }
|
|
821
|
+
</style>
|
|
822
|
+
</head>
|
|
823
|
+
<body>
|
|
824
|
+
<main>
|
|
825
|
+
<h1>You don’t have access to this application</h1>
|
|
826
|
+
<p>
|
|
827
|
+
Ask the app’s owner to share it with you in Incorta.
|
|
828
|
+
You are signed in as <strong>${detail}</strong>.
|
|
829
|
+
</p>
|
|
830
|
+
<a href="${switchHref}">Sign in with a different account</a>
|
|
831
|
+
</main>
|
|
832
|
+
</body>
|
|
833
|
+
</html>`,
|
|
834
|
+
{
|
|
835
|
+
status: 403,
|
|
836
|
+
headers: {
|
|
837
|
+
"content-type": "text/html; charset=utf-8",
|
|
838
|
+
"cache-control": "no-store"
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
function accessUnavailableResponse(request) {
|
|
844
|
+
if (!wantsHtml(request)) {
|
|
845
|
+
return json(503, { error: "app_access_unavailable" });
|
|
846
|
+
}
|
|
847
|
+
return new Response(
|
|
848
|
+
`<!doctype html>
|
|
849
|
+
<html lang="en">
|
|
850
|
+
<head>
|
|
851
|
+
<meta charset="utf-8" />
|
|
852
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
853
|
+
<meta http-equiv="refresh" content="15" />
|
|
854
|
+
<title>Temporarily unavailable</title>
|
|
855
|
+
<style>
|
|
856
|
+
body { margin: 0; display: grid; place-items: center; min-height: 100vh;
|
|
857
|
+
background: #fafafa; color: #18181b;
|
|
858
|
+
font-family: ui-sans-serif, system-ui, sans-serif; }
|
|
859
|
+
main { max-width: 26rem; padding: 2rem; text-align: center; }
|
|
860
|
+
h1 { font-size: 1.25rem; margin: 0 0 .5rem; }
|
|
861
|
+
p { color: #52525b; font-size: .9rem; line-height: 1.5; margin: 0; }
|
|
862
|
+
</style>
|
|
863
|
+
</head>
|
|
864
|
+
<body>
|
|
865
|
+
<main>
|
|
866
|
+
<h1>This app is temporarily unavailable</h1>
|
|
867
|
+
<p>
|
|
868
|
+
Your access could not be verified because Incorta did not respond.
|
|
869
|
+
This page retries automatically — you can also refresh in a
|
|
870
|
+
moment.
|
|
871
|
+
</p>
|
|
872
|
+
</main>
|
|
873
|
+
</body>
|
|
874
|
+
</html>`,
|
|
875
|
+
{
|
|
876
|
+
status: 503,
|
|
877
|
+
headers: {
|
|
878
|
+
"content-type": "text/html; charset=utf-8",
|
|
879
|
+
"cache-control": "no-store",
|
|
880
|
+
"retry-after": "30"
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
);
|
|
884
|
+
}
|
|
665
885
|
async function handler(request) {
|
|
666
886
|
const url = new URL(request.url);
|
|
667
887
|
if (url.pathname !== config.basePath && !url.pathname.startsWith(`${config.basePath}/`)) {
|
|
@@ -674,6 +894,7 @@ function createHandler(config) {
|
|
|
674
894
|
if (route === "/callback" && method === "GET") return await callback(request, url);
|
|
675
895
|
if (route === "/session" && method === "GET") return await sessionRoute(request);
|
|
676
896
|
if (route === "/token" && method === "GET") return await tokenRoute(request);
|
|
897
|
+
if (route === "/refresh" && method === "GET") return await refreshRoute(request, url);
|
|
677
898
|
if (route === "/logout" && (method === "POST" || method === "GET")) {
|
|
678
899
|
return await logout(request, url);
|
|
679
900
|
}
|
|
@@ -699,7 +920,57 @@ function createHandler(config) {
|
|
|
699
920
|
expiresAt
|
|
700
921
|
};
|
|
701
922
|
}
|
|
702
|
-
|
|
923
|
+
function getAppAccess(session) {
|
|
924
|
+
return appAccessChecker.check(session.accessToken, session.user.sub);
|
|
925
|
+
}
|
|
926
|
+
async function gate(request) {
|
|
927
|
+
const url = new URL(request.url);
|
|
928
|
+
if (url.pathname === config.basePath || url.pathname.startsWith(`${config.basePath}/`)) {
|
|
929
|
+
return handler(request);
|
|
930
|
+
}
|
|
931
|
+
if (config.appAccess === "public") return null;
|
|
932
|
+
const session = await getSession(request);
|
|
933
|
+
if (!session) {
|
|
934
|
+
if (wantsHtml(request)) {
|
|
935
|
+
return redirect(
|
|
936
|
+
`${config.basePath}/login?redirect_to=${encodeURIComponent(requestPath(url))}`
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
return json(401, { error: "unauthenticated" });
|
|
940
|
+
}
|
|
941
|
+
if (config.appAccess !== "catalog") return null;
|
|
942
|
+
if (session.accessTokenExpiresAt <= Date.now()) {
|
|
943
|
+
if (wantsHtml(request)) {
|
|
944
|
+
return redirect(
|
|
945
|
+
`${config.basePath}/refresh?redirect_to=${encodeURIComponent(requestPath(url))}`
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
return json(401, { error: "token_expired" });
|
|
949
|
+
}
|
|
950
|
+
try {
|
|
951
|
+
const verdict = await getAppAccess(session);
|
|
952
|
+
if (verdict.allowed) return null;
|
|
953
|
+
return accessDeniedResponse(request, session);
|
|
954
|
+
} catch (error) {
|
|
955
|
+
if (error instanceof AppAccessTokenRejectedError) {
|
|
956
|
+
const secure = isSecureRequest(config, request);
|
|
957
|
+
const clearSession = deleteCookie(config.cookieName, sessionCookieOptions(secure));
|
|
958
|
+
if (wantsHtml(request)) {
|
|
959
|
+
return redirect(
|
|
960
|
+
`${config.basePath}/login?redirect_to=${encodeURIComponent(requestPath(url))}`,
|
|
961
|
+
[clearSession]
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
return json(401, { error: "unauthenticated" }, [clearSession]);
|
|
965
|
+
}
|
|
966
|
+
if (!(error instanceof AppAccessUnavailableError)) {
|
|
967
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
968
|
+
console.error("[incorta-auth] app access check failed unexpectedly:", message);
|
|
969
|
+
}
|
|
970
|
+
return accessUnavailableResponse(request);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
return { handler, getSession, gate, getAppAccess };
|
|
703
974
|
}
|
|
704
975
|
|
|
705
976
|
// src/core/register.ts
|
|
@@ -834,12 +1105,17 @@ async function deleteClient(options) {
|
|
|
834
1105
|
// src/index.ts
|
|
835
1106
|
function createIncortaAuth(config = {}) {
|
|
836
1107
|
const resolved = resolveConfig(config);
|
|
837
|
-
const { handler, getSession } = createHandler(resolved);
|
|
838
|
-
return { handler, getSession, config: resolved };
|
|
1108
|
+
const { handler, getSession, gate, getAppAccess } = createHandler(resolved);
|
|
1109
|
+
return { handler, getSession, gate, getAppAccess, config: resolved };
|
|
839
1110
|
}
|
|
840
1111
|
// Annotate the CommonJS export names for ESM import in node:
|
|
841
1112
|
0 && (module.exports = {
|
|
1113
|
+
AppAccessTokenRejectedError,
|
|
1114
|
+
AppAccessUnavailableError,
|
|
1115
|
+
EDIT_BIT,
|
|
842
1116
|
IncortaAuthError,
|
|
1117
|
+
SHARE_BIT,
|
|
1118
|
+
VIEW_BIT,
|
|
843
1119
|
createIncortaAuth,
|
|
844
1120
|
deleteClient,
|
|
845
1121
|
getClient,
|