@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/README.md
CHANGED
|
@@ -45,6 +45,46 @@ use it when the public `incortaUrl` is not routable from the app — e.g.
|
|
|
45
45
|
`http://host.docker.internal:8080/incorta` from a local container; the
|
|
46
46
|
authorize redirect and issuer validation stay on the public URL).
|
|
47
47
|
|
|
48
|
+
## App access policy (`appAccess` / `INCORTA_APP_ACCESS`)
|
|
49
|
+
|
|
50
|
+
Who may use the app, enforced by the whole-app gate:
|
|
51
|
+
|
|
52
|
+
- `public` — anyone with the URL, signed in or not. The gate is inert for app
|
|
53
|
+
routes; the auth routes stay mounted, so signing in remains possible and
|
|
54
|
+
sessions become optional (handle `session == null`).
|
|
55
|
+
- `authenticated` (default) — any signed-in Incorta user. Exactly the
|
|
56
|
+
pre-policy behavior, and what an absent/unknown `INCORTA_APP_ACCESS` means
|
|
57
|
+
(unknown values warn and fall back here — never to `public`).
|
|
58
|
+
- `catalog` — signed in AND at least VIEW access to this app in the Incorta
|
|
59
|
+
catalog. The SDK asks `GET {incortaUrl}/apps-catalog/check` with the
|
|
60
|
+
session's own access token; Incorta resolves which app the token belongs to
|
|
61
|
+
from the OAuth client registration's `(incorta-app:<id>)` name suffix
|
|
62
|
+
(written once, server-side, at registration), so the endpoint can
|
|
63
|
+
only ever answer about this app. Verdicts (allow and deny) are cached 30s
|
|
64
|
+
per user; apps not registered in the catalog stay open to authenticated
|
|
65
|
+
users (legacy-open). When Incorta cannot answer, the gate fails CLOSED with
|
|
66
|
+
a distinct 503 "temporarily unavailable" page (failures are not cached).
|
|
67
|
+
|
|
68
|
+
Enforcement surfaces:
|
|
69
|
+
|
|
70
|
+
- `auth.gate(request): Promise<Response | null>` — the whole-app gate: serve
|
|
71
|
+
the `Response` when non-null (auth routes, login redirects, the 403
|
|
72
|
+
access-denied page — which names the signed-in user and offers a
|
|
73
|
+
switch-account logout — or the 503 page), continue to your app on `null`.
|
|
74
|
+
- `@incorta/auth/express`: `appGate(auth)` middleware is `auth.gate` for
|
|
75
|
+
Express/Connect. `requireAuth(auth)` always demands a session regardless of
|
|
76
|
+
policy (an explicit route guard is the author's promise), and under
|
|
77
|
+
`catalog` additionally requires VIEW, attaching the verdict as
|
|
78
|
+
`req.incortaAuth.appAccess`.
|
|
79
|
+
- `auth.getAppAccess(session): Promise<AppAccess>` — the cached verdict
|
|
80
|
+
(`registered`, `access` bitmask, `canView`, `canEdit`, `allowed`) for
|
|
81
|
+
permission-aware UX in any mode.
|
|
82
|
+
|
|
83
|
+
`GET {basePath}/refresh?redirect_to=` supports the catalog gate: it refreshes
|
|
84
|
+
an expired access token through a browser round trip (persisting the rotated
|
|
85
|
+
refresh-token cookie) and bounces back — the gate sends navigations there
|
|
86
|
+
instead of calling Incorta with a token it knows is expired.
|
|
87
|
+
|
|
48
88
|
## Session model
|
|
49
89
|
|
|
50
90
|
The cookie is an encrypted JWT (JWE `dir`/`A256GCM`, key derived from
|
|
@@ -112,6 +112,23 @@ interface IncortaAuthConfig {
|
|
|
112
112
|
* @default false
|
|
113
113
|
*/
|
|
114
114
|
exposeAccessToken?: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Who may use the app — enforced by the whole-app gate (`auth.gate`, and
|
|
117
|
+
* the framework middlewares built on it):
|
|
118
|
+
*
|
|
119
|
+
* - `"public"`: anyone with the URL, signed in or not (login stays
|
|
120
|
+
* available, sessions become optional);
|
|
121
|
+
* - `"authenticated"` (default): any signed-in Incorta user;
|
|
122
|
+
* - `"catalog"`: signed-in AND at least VIEW access to this app in the
|
|
123
|
+
* Incorta catalog, verified against `/apps-catalog/check` with a 30s
|
|
124
|
+
* per-user cache. Unregistered apps stay open to authenticated users
|
|
125
|
+
* (legacy-open); an unanswerable check fails CLOSED (503).
|
|
126
|
+
*
|
|
127
|
+
* Normally stamped by the Incorta Apps platform via `INCORTA_APP_ACCESS`.
|
|
128
|
+
* An unrecognized env value falls back to `"authenticated"` (never to
|
|
129
|
+
* `"public"` — a typo must not open the app) with a console warning.
|
|
130
|
+
*/
|
|
131
|
+
appAccess?: AppAccessPolicy;
|
|
115
132
|
/**
|
|
116
133
|
* How to sign in when the app runs inside a cross-site iframe (the Incorta
|
|
117
134
|
* builder preview). `"redirect"` (default) bounces the frame to Incorta —
|
|
@@ -152,6 +169,7 @@ interface ResolvedConfig {
|
|
|
152
169
|
cookieSameSite: "lax" | "none";
|
|
153
170
|
cookiePartitioned: boolean;
|
|
154
171
|
exposeAccessToken: boolean;
|
|
172
|
+
appAccess: AppAccessPolicy;
|
|
155
173
|
embeddedSignIn: "redirect" | "new-tab";
|
|
156
174
|
/** `{incortaUrl}/oauth/{tenant}` — the OIDC issuer for this tenant. */
|
|
157
175
|
issuer: string;
|
|
@@ -160,4 +178,43 @@ interface ResolvedConfig {
|
|
|
160
178
|
fetch: typeof fetch;
|
|
161
179
|
}
|
|
162
180
|
|
|
163
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Platform-stamped access policy for the app (`INCORTA_APP_ACCESS`):
|
|
183
|
+
*
|
|
184
|
+
* - `"public"` — anyone with the URL; the whole-app gate is inert
|
|
185
|
+
* (login routes stay mounted, sessions are optional);
|
|
186
|
+
* - `"authenticated"`— any signed-in Incorta user (the pre-policy behavior,
|
|
187
|
+
* and the default when the platform stamped nothing);
|
|
188
|
+
* - `"catalog"` — signed-in AND at least VIEW access to this app in the
|
|
189
|
+
* Incorta catalog (`/apps-catalog/check`).
|
|
190
|
+
*/
|
|
191
|
+
type AppAccessPolicy = "public" | "authenticated" | "catalog";
|
|
192
|
+
/** Incorta permission bits (com.incorta.api.Action). */
|
|
193
|
+
declare const VIEW_BIT = 1;
|
|
194
|
+
declare const SHARE_BIT = 2;
|
|
195
|
+
declare const EDIT_BIT = 4;
|
|
196
|
+
/** The catalog's verdict about the session user's access to THIS app. */
|
|
197
|
+
interface AppAccess {
|
|
198
|
+
/**
|
|
199
|
+
* The app is registered in the Incorta catalog. `false` means it predates
|
|
200
|
+
* the catalog integration — the gate treats it as legacy-open (any
|
|
201
|
+
* authenticated tenant user may enter).
|
|
202
|
+
*/
|
|
203
|
+
registered: boolean;
|
|
204
|
+
/** Effective Incorta permission bitmask (VIEW=1, SHARE=2, EDIT=4). */
|
|
205
|
+
access: number;
|
|
206
|
+
canView: boolean;
|
|
207
|
+
canEdit: boolean;
|
|
208
|
+
/** What the runtime gate enforces: unregistered (legacy-open) or ≥ VIEW. */
|
|
209
|
+
allowed: boolean;
|
|
210
|
+
}
|
|
211
|
+
/** Incorta could not answer (unreachable, 5xx, disabled) — gates fail CLOSED. */
|
|
212
|
+
declare class AppAccessUnavailableError extends Error {
|
|
213
|
+
constructor(cause: string);
|
|
214
|
+
}
|
|
215
|
+
/** Incorta rejected the session's access token (expired/revoked) — re-login. */
|
|
216
|
+
declare class AppAccessTokenRejectedError extends Error {
|
|
217
|
+
constructor();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export { type AppAccess as A, EDIT_BIT as E, type IncortaAuthConfig as I, type ResolvedConfig as R, type SessionInfo as S, VIEW_BIT as V, type AppAccessPolicy as a, AppAccessTokenRejectedError as b, AppAccessUnavailableError as c, type IncortaUser as d, SHARE_BIT as e, type SessionData as f };
|
|
@@ -112,6 +112,23 @@ interface IncortaAuthConfig {
|
|
|
112
112
|
* @default false
|
|
113
113
|
*/
|
|
114
114
|
exposeAccessToken?: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Who may use the app — enforced by the whole-app gate (`auth.gate`, and
|
|
117
|
+
* the framework middlewares built on it):
|
|
118
|
+
*
|
|
119
|
+
* - `"public"`: anyone with the URL, signed in or not (login stays
|
|
120
|
+
* available, sessions become optional);
|
|
121
|
+
* - `"authenticated"` (default): any signed-in Incorta user;
|
|
122
|
+
* - `"catalog"`: signed-in AND at least VIEW access to this app in the
|
|
123
|
+
* Incorta catalog, verified against `/apps-catalog/check` with a 30s
|
|
124
|
+
* per-user cache. Unregistered apps stay open to authenticated users
|
|
125
|
+
* (legacy-open); an unanswerable check fails CLOSED (503).
|
|
126
|
+
*
|
|
127
|
+
* Normally stamped by the Incorta Apps platform via `INCORTA_APP_ACCESS`.
|
|
128
|
+
* An unrecognized env value falls back to `"authenticated"` (never to
|
|
129
|
+
* `"public"` — a typo must not open the app) with a console warning.
|
|
130
|
+
*/
|
|
131
|
+
appAccess?: AppAccessPolicy;
|
|
115
132
|
/**
|
|
116
133
|
* How to sign in when the app runs inside a cross-site iframe (the Incorta
|
|
117
134
|
* builder preview). `"redirect"` (default) bounces the frame to Incorta —
|
|
@@ -152,6 +169,7 @@ interface ResolvedConfig {
|
|
|
152
169
|
cookieSameSite: "lax" | "none";
|
|
153
170
|
cookiePartitioned: boolean;
|
|
154
171
|
exposeAccessToken: boolean;
|
|
172
|
+
appAccess: AppAccessPolicy;
|
|
155
173
|
embeddedSignIn: "redirect" | "new-tab";
|
|
156
174
|
/** `{incortaUrl}/oauth/{tenant}` — the OIDC issuer for this tenant. */
|
|
157
175
|
issuer: string;
|
|
@@ -160,4 +178,43 @@ interface ResolvedConfig {
|
|
|
160
178
|
fetch: typeof fetch;
|
|
161
179
|
}
|
|
162
180
|
|
|
163
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Platform-stamped access policy for the app (`INCORTA_APP_ACCESS`):
|
|
183
|
+
*
|
|
184
|
+
* - `"public"` — anyone with the URL; the whole-app gate is inert
|
|
185
|
+
* (login routes stay mounted, sessions are optional);
|
|
186
|
+
* - `"authenticated"`— any signed-in Incorta user (the pre-policy behavior,
|
|
187
|
+
* and the default when the platform stamped nothing);
|
|
188
|
+
* - `"catalog"` — signed-in AND at least VIEW access to this app in the
|
|
189
|
+
* Incorta catalog (`/apps-catalog/check`).
|
|
190
|
+
*/
|
|
191
|
+
type AppAccessPolicy = "public" | "authenticated" | "catalog";
|
|
192
|
+
/** Incorta permission bits (com.incorta.api.Action). */
|
|
193
|
+
declare const VIEW_BIT = 1;
|
|
194
|
+
declare const SHARE_BIT = 2;
|
|
195
|
+
declare const EDIT_BIT = 4;
|
|
196
|
+
/** The catalog's verdict about the session user's access to THIS app. */
|
|
197
|
+
interface AppAccess {
|
|
198
|
+
/**
|
|
199
|
+
* The app is registered in the Incorta catalog. `false` means it predates
|
|
200
|
+
* the catalog integration — the gate treats it as legacy-open (any
|
|
201
|
+
* authenticated tenant user may enter).
|
|
202
|
+
*/
|
|
203
|
+
registered: boolean;
|
|
204
|
+
/** Effective Incorta permission bitmask (VIEW=1, SHARE=2, EDIT=4). */
|
|
205
|
+
access: number;
|
|
206
|
+
canView: boolean;
|
|
207
|
+
canEdit: boolean;
|
|
208
|
+
/** What the runtime gate enforces: unregistered (legacy-open) or ≥ VIEW. */
|
|
209
|
+
allowed: boolean;
|
|
210
|
+
}
|
|
211
|
+
/** Incorta could not answer (unreachable, 5xx, disabled) — gates fail CLOSED. */
|
|
212
|
+
declare class AppAccessUnavailableError extends Error {
|
|
213
|
+
constructor(cause: string);
|
|
214
|
+
}
|
|
215
|
+
/** Incorta rejected the session's access token (expired/revoked) — re-login. */
|
|
216
|
+
declare class AppAccessTokenRejectedError extends Error {
|
|
217
|
+
constructor();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export { type AppAccess as A, EDIT_BIT as E, type IncortaAuthConfig as I, type ResolvedConfig as R, type SessionInfo as S, VIEW_BIT as V, type AppAccessPolicy as a, AppAccessTokenRejectedError as b, AppAccessUnavailableError as c, type IncortaUser as d, SHARE_BIT as e, type SessionData as f };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// src/core/app-access.ts
|
|
2
|
+
var VIEW_BIT = 1;
|
|
3
|
+
var SHARE_BIT = 2;
|
|
4
|
+
var EDIT_BIT = 4;
|
|
5
|
+
var AppAccessUnavailableError = class extends Error {
|
|
6
|
+
constructor(cause) {
|
|
7
|
+
super(`[incorta-auth] App access check unavailable: ${cause}`);
|
|
8
|
+
this.name = "AppAccessUnavailableError";
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
var AppAccessTokenRejectedError = class extends Error {
|
|
12
|
+
constructor() {
|
|
13
|
+
super("[incorta-auth] Incorta rejected the session token during the app access check");
|
|
14
|
+
this.name = "AppAccessTokenRejectedError";
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
var APP_ACCESS_CACHE_TTL_MS = 3e4;
|
|
18
|
+
var CHECK_TIMEOUT_MS = 5e3;
|
|
19
|
+
var CACHE_MAX_ENTRIES = 5e3;
|
|
20
|
+
function toVerdict(registered, access) {
|
|
21
|
+
const canView = (access & VIEW_BIT) === VIEW_BIT;
|
|
22
|
+
return {
|
|
23
|
+
registered,
|
|
24
|
+
access,
|
|
25
|
+
canView,
|
|
26
|
+
canEdit: (access & EDIT_BIT) === EDIT_BIT,
|
|
27
|
+
allowed: !registered || canView
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function createAppAccessChecker(config) {
|
|
31
|
+
const base = config.internalIncortaUrl ?? config.incortaUrl;
|
|
32
|
+
const checkUrl = `${base}/apps-catalog/check`;
|
|
33
|
+
const cache = /* @__PURE__ */ new Map();
|
|
34
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
35
|
+
function prune(now) {
|
|
36
|
+
if (cache.size < CACHE_MAX_ENTRIES) return;
|
|
37
|
+
for (const [key, entry] of cache) {
|
|
38
|
+
if (entry.expiresAt <= now) cache.delete(key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async function fetchVerdict(accessToken) {
|
|
42
|
+
let response;
|
|
43
|
+
try {
|
|
44
|
+
response = await config.fetch(checkUrl, {
|
|
45
|
+
headers: {
|
|
46
|
+
accept: "application/json",
|
|
47
|
+
authorization: `Bearer ${accessToken}`
|
|
48
|
+
},
|
|
49
|
+
signal: AbortSignal.timeout(CHECK_TIMEOUT_MS)
|
|
50
|
+
});
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new AppAccessUnavailableError(error.message);
|
|
53
|
+
}
|
|
54
|
+
if (response.status === 401) throw new AppAccessTokenRejectedError();
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
throw new AppAccessUnavailableError(`HTTP ${response.status}`);
|
|
57
|
+
}
|
|
58
|
+
let body;
|
|
59
|
+
try {
|
|
60
|
+
body = await response.json();
|
|
61
|
+
} catch {
|
|
62
|
+
throw new AppAccessUnavailableError("invalid JSON response");
|
|
63
|
+
}
|
|
64
|
+
if (typeof body.registered !== "boolean" || typeof body.access !== "number") {
|
|
65
|
+
throw new AppAccessUnavailableError("malformed check response");
|
|
66
|
+
}
|
|
67
|
+
return toVerdict(body.registered, body.access);
|
|
68
|
+
}
|
|
69
|
+
async function check(accessToken, userKey) {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const cached = cache.get(userKey);
|
|
72
|
+
if (cached && cached.expiresAt > now) return cached.verdict;
|
|
73
|
+
const pending = inFlight.get(userKey);
|
|
74
|
+
if (pending) return pending;
|
|
75
|
+
const promise = fetchVerdict(accessToken).then((verdict) => {
|
|
76
|
+
prune(now);
|
|
77
|
+
cache.set(userKey, { verdict, expiresAt: Date.now() + APP_ACCESS_CACHE_TTL_MS });
|
|
78
|
+
return verdict;
|
|
79
|
+
}).finally(() => {
|
|
80
|
+
inFlight.delete(userKey);
|
|
81
|
+
});
|
|
82
|
+
inFlight.set(userKey, promise);
|
|
83
|
+
return promise;
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
check,
|
|
87
|
+
clearCache() {
|
|
88
|
+
cache.clear();
|
|
89
|
+
inFlight.clear();
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export {
|
|
95
|
+
VIEW_BIT,
|
|
96
|
+
SHARE_BIT,
|
|
97
|
+
EDIT_BIT,
|
|
98
|
+
AppAccessUnavailableError,
|
|
99
|
+
AppAccessTokenRejectedError,
|
|
100
|
+
createAppAccessChecker
|
|
101
|
+
};
|
|
102
|
+
//# sourceMappingURL=chunk-TMXOEIA2.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/core/app-access.ts"],"sourcesContent":["import type { ResolvedConfig } from \"./config.js\";\n\n/**\n * Platform-stamped access policy for the app (`INCORTA_APP_ACCESS`):\n *\n * - `\"public\"` — anyone with the URL; the whole-app gate is inert\n * (login routes stay mounted, sessions are optional);\n * - `\"authenticated\"`— any signed-in Incorta user (the pre-policy behavior,\n * and the default when the platform stamped nothing);\n * - `\"catalog\"` — signed-in AND at least VIEW access to this app in the\n * Incorta catalog (`/apps-catalog/check`).\n */\nexport type AppAccessPolicy = \"public\" | \"authenticated\" | \"catalog\";\n\n/** Incorta permission bits (com.incorta.api.Action). */\nexport const VIEW_BIT = 1;\nexport const SHARE_BIT = 2;\nexport const EDIT_BIT = 4;\n\n/** The catalog's verdict about the session user's access to THIS app. */\nexport interface AppAccess {\n /**\n * The app is registered in the Incorta catalog. `false` means it predates\n * the catalog integration — the gate treats it as legacy-open (any\n * authenticated tenant user may enter).\n */\n registered: boolean;\n /** Effective Incorta permission bitmask (VIEW=1, SHARE=2, EDIT=4). */\n access: number;\n canView: boolean;\n canEdit: boolean;\n /** What the runtime gate enforces: unregistered (legacy-open) or ≥ VIEW. */\n allowed: boolean;\n}\n\n/** Incorta could not answer (unreachable, 5xx, disabled) — gates fail CLOSED. */\nexport class AppAccessUnavailableError extends Error {\n constructor(cause: string) {\n super(`[incorta-auth] App access check unavailable: ${cause}`);\n this.name = \"AppAccessUnavailableError\";\n }\n}\n\n/** Incorta rejected the session's access token (expired/revoked) — re-login. */\nexport class AppAccessTokenRejectedError extends Error {\n constructor() {\n super(\"[incorta-auth] Incorta rejected the session token during the app access check\");\n this.name = \"AppAccessTokenRejectedError\";\n }\n}\n\n/** Verdicts are cached (allow AND deny) this long per user. */\nexport const APP_ACCESS_CACHE_TTL_MS = 30_000;\nconst CHECK_TIMEOUT_MS = 5_000;\n/** Lazy prune threshold — entries are (user × 30s), so this is generous. */\nconst CACHE_MAX_ENTRIES = 5_000;\n\ninterface CacheEntry {\n verdict: AppAccess;\n expiresAt: number;\n}\n\nexport interface AppAccessChecker {\n /**\n * The catalog verdict for the user behind `accessToken`, served from the\n * per-user cache when fresh. Throws {@link AppAccessUnavailableError} when\n * Incorta cannot answer and {@link AppAccessTokenRejectedError} when it\n * rejects the token itself.\n */\n check(accessToken: string, userKey: string): Promise<AppAccess>;\n /** Test hook: drops all cached verdicts. */\n clearCache(): void;\n}\n\nfunction toVerdict(registered: boolean, access: number): AppAccess {\n const canView = (access & VIEW_BIT) === VIEW_BIT;\n return {\n registered,\n access,\n canView,\n canEdit: (access & EDIT_BIT) === EDIT_BIT,\n allowed: !registered || canView,\n };\n}\n\n/**\n * Client for `GET {incortaUrl}/apps-catalog/check` — the backend resolves\n * which app the token belongs to from its OAuth client (`software_id`), so\n * the request carries nothing but the user's own Bearer token. Server-side\n * call, so the split-horizon `internalIncortaUrl` wins when set.\n *\n * Verdicts (allow and deny alike) are cached per user for\n * {@link APP_ACCESS_CACHE_TTL_MS}; failures are NOT cached — the next\n * request retries, and callers fail closed in the meantime. Concurrent\n * checks for the same user share one in-flight request.\n */\nexport function createAppAccessChecker(config: ResolvedConfig): AppAccessChecker {\n const base = config.internalIncortaUrl ?? config.incortaUrl;\n const checkUrl = `${base}/apps-catalog/check`;\n const cache = new Map<string, CacheEntry>();\n const inFlight = new Map<string, Promise<AppAccess>>();\n\n function prune(now: number): void {\n if (cache.size < CACHE_MAX_ENTRIES) return;\n for (const [key, entry] of cache) {\n if (entry.expiresAt <= now) cache.delete(key);\n }\n }\n\n async function fetchVerdict(accessToken: string): Promise<AppAccess> {\n let response: Response;\n try {\n response = await config.fetch(checkUrl, {\n headers: {\n accept: \"application/json\",\n authorization: `Bearer ${accessToken}`,\n },\n signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),\n });\n } catch (error) {\n throw new AppAccessUnavailableError((error as Error).message);\n }\n\n if (response.status === 401) throw new AppAccessTokenRejectedError();\n if (!response.ok) {\n // 404 covers both \"Incorta Apps disabled for the tenant\" and an older\n // Incorta without the /check command — verdicts are unavailable either\n // way, and gates never fail open.\n throw new AppAccessUnavailableError(`HTTP ${response.status}`);\n }\n\n let body: { registered?: unknown; access?: unknown };\n try {\n body = (await response.json()) as { registered?: unknown; access?: unknown };\n } catch {\n throw new AppAccessUnavailableError(\"invalid JSON response\");\n }\n if (typeof body.registered !== \"boolean\" || typeof body.access !== \"number\") {\n throw new AppAccessUnavailableError(\"malformed check response\");\n }\n return toVerdict(body.registered, body.access);\n }\n\n async function check(accessToken: string, userKey: string): Promise<AppAccess> {\n const now = Date.now();\n const cached = cache.get(userKey);\n if (cached && cached.expiresAt > now) return cached.verdict;\n\n const pending = inFlight.get(userKey);\n if (pending) return pending;\n\n const promise = fetchVerdict(accessToken)\n .then((verdict) => {\n prune(now);\n cache.set(userKey, { verdict, expiresAt: Date.now() + APP_ACCESS_CACHE_TTL_MS });\n return verdict;\n })\n .finally(() => {\n inFlight.delete(userKey);\n });\n inFlight.set(userKey, promise);\n return promise;\n }\n\n return {\n check,\n clearCache() {\n cache.clear();\n inFlight.clear();\n },\n };\n}\n"],"mappings":";AAeO,IAAM,WAAW;AACjB,IAAM,YAAY;AAClB,IAAM,WAAW;AAmBjB,IAAM,4BAAN,cAAwC,MAAM;AAAA,EACnD,YAAY,OAAe;AACzB,UAAM,gDAAgD,KAAK,EAAE;AAC7D,SAAK,OAAO;AAAA,EACd;AACF;AAGO,IAAM,8BAAN,cAA0C,MAAM;AAAA,EACrD,cAAc;AACZ,UAAM,+EAA+E;AACrF,SAAK,OAAO;AAAA,EACd;AACF;AAGO,IAAM,0BAA0B;AACvC,IAAM,mBAAmB;AAEzB,IAAM,oBAAoB;AAmB1B,SAAS,UAAU,YAAqB,QAA2B;AACjE,QAAM,WAAW,SAAS,cAAc;AACxC,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA,UAAU,SAAS,cAAc;AAAA,IACjC,SAAS,CAAC,cAAc;AAAA,EAC1B;AACF;AAaO,SAAS,uBAAuB,QAA0C;AAC/E,QAAM,OAAO,OAAO,sBAAsB,OAAO;AACjD,QAAM,WAAW,GAAG,IAAI;AACxB,QAAM,QAAQ,oBAAI,IAAwB;AAC1C,QAAM,WAAW,oBAAI,IAAgC;AAErD,WAAS,MAAM,KAAmB;AAChC,QAAI,MAAM,OAAO,kBAAmB;AACpC,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO;AAChC,UAAI,MAAM,aAAa,IAAK,OAAM,OAAO,GAAG;AAAA,IAC9C;AAAA,EACF;AAEA,iBAAe,aAAa,aAAyC;AACnE,QAAI;AACJ,QAAI;AACF,iBAAW,MAAM,OAAO,MAAM,UAAU;AAAA,QACtC,SAAS;AAAA,UACP,QAAQ;AAAA,UACR,eAAe,UAAU,WAAW;AAAA,QACtC;AAAA,QACA,QAAQ,YAAY,QAAQ,gBAAgB;AAAA,MAC9C,CAAC;AAAA,IACH,SAAS,OAAO;AACd,YAAM,IAAI,0BAA2B,MAAgB,OAAO;AAAA,IAC9D;AAEA,QAAI,SAAS,WAAW,IAAK,OAAM,IAAI,4BAA4B;AACnE,QAAI,CAAC,SAAS,IAAI;AAIhB,YAAM,IAAI,0BAA0B,QAAQ,SAAS,MAAM,EAAE;AAAA,IAC/D;AAEA,QAAI;AACJ,QAAI;AACF,aAAQ,MAAM,SAAS,KAAK;AAAA,IAC9B,QAAQ;AACN,YAAM,IAAI,0BAA0B,uBAAuB;AAAA,IAC7D;AACA,QAAI,OAAO,KAAK,eAAe,aAAa,OAAO,KAAK,WAAW,UAAU;AAC3E,YAAM,IAAI,0BAA0B,0BAA0B;AAAA,IAChE;AACA,WAAO,UAAU,KAAK,YAAY,KAAK,MAAM;AAAA,EAC/C;AAEA,iBAAe,MAAM,aAAqB,SAAqC;AAC7E,UAAM,MAAM,KAAK,IAAI;AACrB,UAAM,SAAS,MAAM,IAAI,OAAO;AAChC,QAAI,UAAU,OAAO,YAAY,IAAK,QAAO,OAAO;AAEpD,UAAM,UAAU,SAAS,IAAI,OAAO;AACpC,QAAI,QAAS,QAAO;AAEpB,UAAM,UAAU,aAAa,WAAW,EACrC,KAAK,CAAC,YAAY;AACjB,YAAM,GAAG;AACT,YAAM,IAAI,SAAS,EAAE,SAAS,WAAW,KAAK,IAAI,IAAI,wBAAwB,CAAC;AAC/E,aAAO;AAAA,IACT,CAAC,EACA,QAAQ,MAAM;AACb,eAAS,OAAO,OAAO;AAAA,IACzB,CAAC;AACH,aAAS,IAAI,SAAS,OAAO;AAC7B,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL;AAAA,IACA,aAAa;AACX,YAAM,MAAM;AACZ,eAAS,MAAM;AAAA,IACjB;AAAA,EACF;AACF;","names":[]}
|
package/dist/express.cjs
CHANGED
|
@@ -20,11 +20,20 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/express.ts
|
|
21
21
|
var express_exports = {};
|
|
22
22
|
__export(express_exports, {
|
|
23
|
+
appGate: () => appGate,
|
|
23
24
|
incortaAuth: () => incortaAuth,
|
|
24
25
|
requireAuth: () => requireAuth
|
|
25
26
|
});
|
|
26
27
|
module.exports = __toCommonJS(express_exports);
|
|
27
28
|
|
|
29
|
+
// src/core/app-access.ts
|
|
30
|
+
var AppAccessTokenRejectedError = class extends Error {
|
|
31
|
+
constructor() {
|
|
32
|
+
super("[incorta-auth] Incorta rejected the session token during the app access check");
|
|
33
|
+
this.name = "AppAccessTokenRejectedError";
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
28
37
|
// src/node.ts
|
|
29
38
|
async function toWebRequest(req) {
|
|
30
39
|
const proto = req.headers["x-forwarded-proto"]?.split(",")[0]?.trim() ?? (req.socket.encrypted ? "https" : "http");
|
|
@@ -104,8 +113,32 @@ function requireAuth(auth, options = {}) {
|
|
|
104
113
|
req.incortaAuth = await auth.getSession(await toWebRequest(req));
|
|
105
114
|
}
|
|
106
115
|
if (req.incortaAuth) {
|
|
107
|
-
|
|
108
|
-
|
|
116
|
+
if (auth.config.appAccess !== "catalog") {
|
|
117
|
+
next();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const session = req.incortaAuth;
|
|
121
|
+
try {
|
|
122
|
+
const verdict = await auth.getAppAccess(session);
|
|
123
|
+
if (verdict.allowed) {
|
|
124
|
+
session.appAccess = verdict;
|
|
125
|
+
next();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
res.statusCode = 403;
|
|
129
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
130
|
+
res.end(JSON.stringify({ error: "app_access_denied" }));
|
|
131
|
+
return;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!(error instanceof AppAccessTokenRejectedError)) {
|
|
134
|
+
res.statusCode = 503;
|
|
135
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
136
|
+
res.setHeader("retry-after", "30");
|
|
137
|
+
res.end(JSON.stringify({ error: "app_access_unavailable" }));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
req.incortaAuth = null;
|
|
141
|
+
}
|
|
109
142
|
}
|
|
110
143
|
const wantsHtml = (req.headers.accept ?? "").includes("text/html");
|
|
111
144
|
if (mode === "redirect" || mode === "auto" && wantsHtml) {
|
|
@@ -126,8 +159,25 @@ function requireAuth(auth, options = {}) {
|
|
|
126
159
|
}
|
|
127
160
|
};
|
|
128
161
|
}
|
|
162
|
+
function appGate(auth) {
|
|
163
|
+
return async (req, res, next) => {
|
|
164
|
+
try {
|
|
165
|
+
const request = await toWebRequest(req);
|
|
166
|
+
const response = await auth.gate(request);
|
|
167
|
+
if (response) {
|
|
168
|
+
await sendWebResponse(res, response);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
req.incortaAuth = await auth.getSession(request);
|
|
172
|
+
next();
|
|
173
|
+
} catch (error) {
|
|
174
|
+
next(error);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
}
|
|
129
178
|
// Annotate the CommonJS export names for ESM import in node:
|
|
130
179
|
0 && (module.exports = {
|
|
180
|
+
appGate,
|
|
131
181
|
incortaAuth,
|
|
132
182
|
requireAuth
|
|
133
183
|
});
|
package/dist/express.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/express.ts","../src/node.ts"],"sourcesContent":["import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { AuthSession, IncortaAuth } from \"./index.js\";\nimport { toNodeHandler, toWebRequest } from \"./node.js\";\n\nexport type { AuthSession, IncortaAuth } from \"./index.js\";\n\n/**\n * Minimal structural types so this adapter works with Express 4/5 (and\n * anything Connect-compatible) without depending on Express itself.\n */\nexport interface AuthedRequest extends IncomingMessage {\n originalUrl?: string;\n /** Populated by `incortaAuth()`: the current session, or null when signed out. */\n incortaAuth?: AuthSession | null;\n}\ntype NextFunction = (err?: unknown) => void;\ntype Middleware = (\n req: AuthedRequest,\n res: ServerResponse,\n next: NextFunction,\n) => void | Promise<void>;\n\n/**\n * Express/Connect middleware that serves the auth routes (under\n * `auth.config.basePath`) and attaches the current session to every other\n * request as `req.incortaAuth`.\n *\n * ```ts\n * const auth = createIncortaAuth();\n * app.use(incortaAuth(auth));\n * app.get(\"/api/me\", requireAuth(auth), (req, res) => res.json(req.incortaAuth.user));\n * ```\n */\nexport function incortaAuth(auth: IncortaAuth): Middleware {\n const nodeHandler = toNodeHandler(auth);\n const { basePath } = auth.config;\n\n return async (req, res, next) => {\n try {\n const path = (req.originalUrl ?? req.url ?? \"/\").split(\"?\")[0];\n if (path === basePath || path.startsWith(`${basePath}/`)) {\n await nodeHandler(req, res);\n return;\n }\n req.incortaAuth = await auth.getSession(await toWebRequest(req));\n next();\n } catch (error) {\n next(error);\n }\n };\n}\n\nexport interface RequireAuthOptions {\n /**\n * How to reject unauthenticated requests:\n * - `\"redirect\"`: 302 to the login route (which bounces through Incorta login\n * and back to the original URL) — right for page routes.\n * - `\"json\"`: 401 `{ error: \"unauthenticated\" }` — right for API routes.\n * - `\"auto\"` (default): redirect when the request accepts text/html, 401 otherwise.\n */\n unauthenticated?: \"redirect\" | \"json\" | \"auto\";\n}\n\n/** Guards a route: only signed-in Incorta users get through. */\nexport function requireAuth(auth: IncortaAuth, options: RequireAuthOptions = {}): Middleware {\n const mode = options.unauthenticated ?? \"auto\";\n const { basePath } = auth.config;\n\n return async (req, res, next) => {\n try {\n if (req.incortaAuth === undefined) {\n req.incortaAuth = await auth.getSession(await toWebRequest(req));\n }\n if (req.incortaAuth) {\n next();\n return;\n }\n\n const wantsHtml = (req.headers.accept ?? \"\").includes(\"text/html\");\n if (mode === \"redirect\" || (mode === \"auto\" && wantsHtml)) {\n const returnTo = req.originalUrl ?? req.url ?? \"/\";\n res.statusCode = 302;\n res.setHeader(\n \"location\",\n `${basePath}/login?redirect_to=${encodeURIComponent(returnTo)}`,\n );\n res.end();\n return;\n }\n\n res.statusCode = 401;\n res.setHeader(\"content-type\", \"application/json; charset=utf-8\");\n res.end(JSON.stringify({ error: \"unauthenticated\" }));\n } catch (error) {\n next(error);\n }\n };\n}\n","import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { IncortaAuth } from \"./index.js\";\n\nexport type { IncortaAuth } from \"./index.js\";\n\n/**\n * Builds a web-standard `Request` from a Node request. Buffers the body (the\n * auth routes are GET/POST-without-body, so this stays tiny).\n */\nexport async function toWebRequest(req: IncomingMessage): Promise<Request> {\n const proto =\n (req.headers[\"x-forwarded-proto\"] as string | undefined)?.split(\",\")[0]?.trim() ??\n ((req.socket as { encrypted?: boolean }).encrypted ? \"https\" : \"http\");\n const host = req.headers.host ?? \"localhost\";\n // Express rewrites req.url when routers are mounted — originalUrl keeps the full path.\n const path = (req as { originalUrl?: string }).originalUrl ?? req.url ?? \"/\";\n const url = `${proto}://${host}${path}`;\n\n const headers = new Headers();\n for (let i = 0; i < req.rawHeaders.length; i += 2) {\n headers.append(req.rawHeaders[i], req.rawHeaders[i + 1]);\n }\n\n const method = req.method ?? \"GET\";\n let body: BodyInit | undefined;\n if (method !== \"GET\" && method !== \"HEAD\") {\n const chunks: Buffer[] = [];\n for await (const chunk of req) {\n chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));\n }\n if (chunks.length) {\n const bytes = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.length;\n }\n body = bytes;\n }\n }\n\n return new Request(url, { method, headers, body });\n}\n\nexport async function sendWebResponse(\n res: ServerResponse,\n response: Response,\n): Promise<void> {\n res.statusCode = response.status;\n response.headers.forEach((value, name) => {\n if (name.toLowerCase() === \"set-cookie\") return; // handled below, needs the un-joined list\n res.setHeader(name, value);\n });\n const setCookies = response.headers.getSetCookie();\n if (setCookies.length) res.setHeader(\"set-cookie\", setCookies);\n\n if (response.body) {\n const buffer = Buffer.from(await response.arrayBuffer());\n res.end(buffer);\n } else {\n res.end();\n }\n}\n\n/**\n * Adapts the auth handler to a plain Node `(req, res)` listener:\n *\n * ```ts\n * import http from \"node:http\";\n * const authListener = toNodeHandler(auth);\n * http.createServer((req, res) => {\n * if (req.url?.startsWith(\"/auth\")) return authListener(req, res);\n * // ... your app\n * });\n * ```\n */\nexport function toNodeHandler(auth: IncortaAuth) {\n return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {\n const request = await toWebRequest(req);\n const response = await auth.handler(request);\n await sendWebResponse(res, response);\n };\n}\n\n/** Reads the current session from a Node request (returns null when signed out). */\nexport async function getNodeSession(auth: IncortaAuth, req: IncomingMessage) {\n return auth.getSession(await toWebRequest(req));\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/express.ts","../src/core/app-access.ts","../src/node.ts"],"sourcesContent":["import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { AppAccessTokenRejectedError } from \"./core/app-access.js\";\nimport type { AuthSession, IncortaAuth } from \"./index.js\";\nimport { sendWebResponse, toNodeHandler, toWebRequest } from \"./node.js\";\n\nexport type { AppAccess, AppAccessPolicy, AuthSession, IncortaAuth } from \"./index.js\";\n\n/**\n * Minimal structural types so this adapter works with Express 4/5 (and\n * anything Connect-compatible) without depending on Express itself.\n */\nexport interface AuthedRequest extends IncomingMessage {\n originalUrl?: string;\n /** Populated by `incortaAuth()`: the current session, or null when signed out. */\n incortaAuth?: AuthSession | null;\n}\ntype NextFunction = (err?: unknown) => void;\ntype Middleware = (\n req: AuthedRequest,\n res: ServerResponse,\n next: NextFunction,\n) => void | Promise<void>;\n\n/**\n * Express/Connect middleware that serves the auth routes (under\n * `auth.config.basePath`) and attaches the current session to every other\n * request as `req.incortaAuth`.\n *\n * ```ts\n * const auth = createIncortaAuth();\n * app.use(incortaAuth(auth));\n * app.get(\"/api/me\", requireAuth(auth), (req, res) => res.json(req.incortaAuth.user));\n * ```\n */\nexport function incortaAuth(auth: IncortaAuth): Middleware {\n const nodeHandler = toNodeHandler(auth);\n const { basePath } = auth.config;\n\n return async (req, res, next) => {\n try {\n const path = (req.originalUrl ?? req.url ?? \"/\").split(\"?\")[0];\n if (path === basePath || path.startsWith(`${basePath}/`)) {\n await nodeHandler(req, res);\n return;\n }\n req.incortaAuth = await auth.getSession(await toWebRequest(req));\n next();\n } catch (error) {\n next(error);\n }\n };\n}\n\nexport interface RequireAuthOptions {\n /**\n * How to reject unauthenticated requests:\n * - `\"redirect\"`: 302 to the login route (which bounces through Incorta login\n * and back to the original URL) — right for page routes.\n * - `\"json\"`: 401 `{ error: \"unauthenticated\" }` — right for API routes.\n * - `\"auto\"` (default): redirect when the request accepts text/html, 401 otherwise.\n */\n unauthenticated?: \"redirect\" | \"json\" | \"auto\";\n}\n\n/**\n * Guards a route: only signed-in Incorta users get through — regardless of\n * the app's `appAccess` policy (an explicit `requireAuth` is the author's\n * promise that this route needs a user, so `\"public\"` mode does not neuter\n * it). Under the `\"catalog\"` policy the signed-in user must additionally\n * hold at least VIEW access to this app; the resolved verdict is attached as\n * `req.incortaAuth.appAccess`.\n */\nexport function requireAuth(auth: IncortaAuth, options: RequireAuthOptions = {}): Middleware {\n const mode = options.unauthenticated ?? \"auto\";\n const { basePath } = auth.config;\n\n return async (req, res, next) => {\n try {\n if (req.incortaAuth === undefined) {\n req.incortaAuth = await auth.getSession(await toWebRequest(req));\n }\n if (req.incortaAuth) {\n if (auth.config.appAccess !== \"catalog\") {\n next();\n return;\n }\n const session = req.incortaAuth;\n try {\n const verdict = await auth.getAppAccess(session);\n if (verdict.allowed) {\n session.appAccess = verdict;\n next();\n return;\n }\n res.statusCode = 403;\n res.setHeader(\"content-type\", \"application/json; charset=utf-8\");\n res.end(JSON.stringify({ error: \"app_access_denied\" }));\n return;\n } catch (error) {\n if (!(error instanceof AppAccessTokenRejectedError)) {\n // Verdicts unavailable — NEVER fail open.\n res.statusCode = 503;\n res.setHeader(\"content-type\", \"application/json; charset=utf-8\");\n res.setHeader(\"retry-after\", \"30\");\n res.end(JSON.stringify({ error: \"app_access_unavailable\" }));\n return;\n }\n // Incorta rejected the session's token — treat as signed out.\n req.incortaAuth = null;\n }\n }\n\n const wantsHtml = (req.headers.accept ?? \"\").includes(\"text/html\");\n if (mode === \"redirect\" || (mode === \"auto\" && wantsHtml)) {\n const returnTo = req.originalUrl ?? req.url ?? \"/\";\n res.statusCode = 302;\n res.setHeader(\n \"location\",\n `${basePath}/login?redirect_to=${encodeURIComponent(returnTo)}`,\n );\n res.end();\n return;\n }\n\n res.statusCode = 401;\n res.setHeader(\"content-type\", \"application/json; charset=utf-8\");\n res.end(JSON.stringify({ error: \"unauthenticated\" }));\n } catch (error) {\n next(error);\n }\n };\n}\n\n/**\n * Whole-app gate middleware: `auth.gate` as Express/Connect middleware.\n * Mount it FIRST (before your routes, instead of `incortaAuth()` — it also\n * serves the auth routes) to give the entire app the platform-stamped\n * `appAccess` policy: `\"public\"` apps let everyone through, `\"authenticated\"`\n * apps require login, `\"catalog\"` apps require catalog VIEW access (with the\n * SDK's deny/unavailable pages). Requests that pass get `req.incortaAuth`\n * populated like `incortaAuth()` does.\n */\nexport function appGate(auth: IncortaAuth): Middleware {\n return async (req, res, next) => {\n try {\n const request = await toWebRequest(req);\n const response = await auth.gate(request);\n if (response) {\n await sendWebResponse(res, response);\n return;\n }\n req.incortaAuth = await auth.getSession(request);\n next();\n } catch (error) {\n next(error);\n }\n };\n}\n","import type { ResolvedConfig } from \"./config.js\";\n\n/**\n * Platform-stamped access policy for the app (`INCORTA_APP_ACCESS`):\n *\n * - `\"public\"` — anyone with the URL; the whole-app gate is inert\n * (login routes stay mounted, sessions are optional);\n * - `\"authenticated\"`— any signed-in Incorta user (the pre-policy behavior,\n * and the default when the platform stamped nothing);\n * - `\"catalog\"` — signed-in AND at least VIEW access to this app in the\n * Incorta catalog (`/apps-catalog/check`).\n */\nexport type AppAccessPolicy = \"public\" | \"authenticated\" | \"catalog\";\n\n/** Incorta permission bits (com.incorta.api.Action). */\nexport const VIEW_BIT = 1;\nexport const SHARE_BIT = 2;\nexport const EDIT_BIT = 4;\n\n/** The catalog's verdict about the session user's access to THIS app. */\nexport interface AppAccess {\n /**\n * The app is registered in the Incorta catalog. `false` means it predates\n * the catalog integration — the gate treats it as legacy-open (any\n * authenticated tenant user may enter).\n */\n registered: boolean;\n /** Effective Incorta permission bitmask (VIEW=1, SHARE=2, EDIT=4). */\n access: number;\n canView: boolean;\n canEdit: boolean;\n /** What the runtime gate enforces: unregistered (legacy-open) or ≥ VIEW. */\n allowed: boolean;\n}\n\n/** Incorta could not answer (unreachable, 5xx, disabled) — gates fail CLOSED. */\nexport class AppAccessUnavailableError extends Error {\n constructor(cause: string) {\n super(`[incorta-auth] App access check unavailable: ${cause}`);\n this.name = \"AppAccessUnavailableError\";\n }\n}\n\n/** Incorta rejected the session's access token (expired/revoked) — re-login. */\nexport class AppAccessTokenRejectedError extends Error {\n constructor() {\n super(\"[incorta-auth] Incorta rejected the session token during the app access check\");\n this.name = \"AppAccessTokenRejectedError\";\n }\n}\n\n/** Verdicts are cached (allow AND deny) this long per user. */\nexport const APP_ACCESS_CACHE_TTL_MS = 30_000;\nconst CHECK_TIMEOUT_MS = 5_000;\n/** Lazy prune threshold — entries are (user × 30s), so this is generous. */\nconst CACHE_MAX_ENTRIES = 5_000;\n\ninterface CacheEntry {\n verdict: AppAccess;\n expiresAt: number;\n}\n\nexport interface AppAccessChecker {\n /**\n * The catalog verdict for the user behind `accessToken`, served from the\n * per-user cache when fresh. Throws {@link AppAccessUnavailableError} when\n * Incorta cannot answer and {@link AppAccessTokenRejectedError} when it\n * rejects the token itself.\n */\n check(accessToken: string, userKey: string): Promise<AppAccess>;\n /** Test hook: drops all cached verdicts. */\n clearCache(): void;\n}\n\nfunction toVerdict(registered: boolean, access: number): AppAccess {\n const canView = (access & VIEW_BIT) === VIEW_BIT;\n return {\n registered,\n access,\n canView,\n canEdit: (access & EDIT_BIT) === EDIT_BIT,\n allowed: !registered || canView,\n };\n}\n\n/**\n * Client for `GET {incortaUrl}/apps-catalog/check` — the backend resolves\n * which app the token belongs to from its OAuth client (`software_id`), so\n * the request carries nothing but the user's own Bearer token. Server-side\n * call, so the split-horizon `internalIncortaUrl` wins when set.\n *\n * Verdicts (allow and deny alike) are cached per user for\n * {@link APP_ACCESS_CACHE_TTL_MS}; failures are NOT cached — the next\n * request retries, and callers fail closed in the meantime. Concurrent\n * checks for the same user share one in-flight request.\n */\nexport function createAppAccessChecker(config: ResolvedConfig): AppAccessChecker {\n const base = config.internalIncortaUrl ?? config.incortaUrl;\n const checkUrl = `${base}/apps-catalog/check`;\n const cache = new Map<string, CacheEntry>();\n const inFlight = new Map<string, Promise<AppAccess>>();\n\n function prune(now: number): void {\n if (cache.size < CACHE_MAX_ENTRIES) return;\n for (const [key, entry] of cache) {\n if (entry.expiresAt <= now) cache.delete(key);\n }\n }\n\n async function fetchVerdict(accessToken: string): Promise<AppAccess> {\n let response: Response;\n try {\n response = await config.fetch(checkUrl, {\n headers: {\n accept: \"application/json\",\n authorization: `Bearer ${accessToken}`,\n },\n signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),\n });\n } catch (error) {\n throw new AppAccessUnavailableError((error as Error).message);\n }\n\n if (response.status === 401) throw new AppAccessTokenRejectedError();\n if (!response.ok) {\n // 404 covers both \"Incorta Apps disabled for the tenant\" and an older\n // Incorta without the /check command — verdicts are unavailable either\n // way, and gates never fail open.\n throw new AppAccessUnavailableError(`HTTP ${response.status}`);\n }\n\n let body: { registered?: unknown; access?: unknown };\n try {\n body = (await response.json()) as { registered?: unknown; access?: unknown };\n } catch {\n throw new AppAccessUnavailableError(\"invalid JSON response\");\n }\n if (typeof body.registered !== \"boolean\" || typeof body.access !== \"number\") {\n throw new AppAccessUnavailableError(\"malformed check response\");\n }\n return toVerdict(body.registered, body.access);\n }\n\n async function check(accessToken: string, userKey: string): Promise<AppAccess> {\n const now = Date.now();\n const cached = cache.get(userKey);\n if (cached && cached.expiresAt > now) return cached.verdict;\n\n const pending = inFlight.get(userKey);\n if (pending) return pending;\n\n const promise = fetchVerdict(accessToken)\n .then((verdict) => {\n prune(now);\n cache.set(userKey, { verdict, expiresAt: Date.now() + APP_ACCESS_CACHE_TTL_MS });\n return verdict;\n })\n .finally(() => {\n inFlight.delete(userKey);\n });\n inFlight.set(userKey, promise);\n return promise;\n }\n\n return {\n check,\n clearCache() {\n cache.clear();\n inFlight.clear();\n },\n };\n}\n","import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { IncortaAuth } from \"./index.js\";\n\nexport type { IncortaAuth } from \"./index.js\";\n\n/**\n * Builds a web-standard `Request` from a Node request. Buffers the body (the\n * auth routes are GET/POST-without-body, so this stays tiny).\n */\nexport async function toWebRequest(req: IncomingMessage): Promise<Request> {\n const proto =\n (req.headers[\"x-forwarded-proto\"] as string | undefined)?.split(\",\")[0]?.trim() ??\n ((req.socket as { encrypted?: boolean }).encrypted ? \"https\" : \"http\");\n const host = req.headers.host ?? \"localhost\";\n // Express rewrites req.url when routers are mounted — originalUrl keeps the full path.\n const path = (req as { originalUrl?: string }).originalUrl ?? req.url ?? \"/\";\n const url = `${proto}://${host}${path}`;\n\n const headers = new Headers();\n for (let i = 0; i < req.rawHeaders.length; i += 2) {\n headers.append(req.rawHeaders[i], req.rawHeaders[i + 1]);\n }\n\n const method = req.method ?? \"GET\";\n let body: BodyInit | undefined;\n if (method !== \"GET\" && method !== \"HEAD\") {\n const chunks: Buffer[] = [];\n for await (const chunk of req) {\n chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));\n }\n if (chunks.length) {\n const bytes = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.length;\n }\n body = bytes;\n }\n }\n\n return new Request(url, { method, headers, body });\n}\n\nexport async function sendWebResponse(\n res: ServerResponse,\n response: Response,\n): Promise<void> {\n res.statusCode = response.status;\n response.headers.forEach((value, name) => {\n if (name.toLowerCase() === \"set-cookie\") return; // handled below, needs the un-joined list\n res.setHeader(name, value);\n });\n const setCookies = response.headers.getSetCookie();\n if (setCookies.length) res.setHeader(\"set-cookie\", setCookies);\n\n if (response.body) {\n const buffer = Buffer.from(await response.arrayBuffer());\n res.end(buffer);\n } else {\n res.end();\n }\n}\n\n/**\n * Adapts the auth handler to a plain Node `(req, res)` listener:\n *\n * ```ts\n * import http from \"node:http\";\n * const authListener = toNodeHandler(auth);\n * http.createServer((req, res) => {\n * if (req.url?.startsWith(\"/auth\")) return authListener(req, res);\n * // ... your app\n * });\n * ```\n */\nexport function toNodeHandler(auth: IncortaAuth) {\n return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {\n const request = await toWebRequest(req);\n const response = await auth.handler(request);\n await sendWebResponse(res, response);\n };\n}\n\n/** Reads the current session from a Node request (returns null when signed out). */\nexport async function getNodeSession(auth: IncortaAuth, req: IncomingMessage) {\n return auth.getSession(await toWebRequest(req));\n}\n"],"mappings":"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","names":[]}
|
package/dist/express.d.cts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { AuthSession, IncortaAuth } from './index.cjs';
|
|
3
|
-
|
|
3
|
+
export { A as AppAccess, a as AppAccessPolicy } from './app-access-BSsKtvbi.cjs';
|
|
4
4
|
import 'jose';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -36,7 +36,24 @@ interface RequireAuthOptions {
|
|
|
36
36
|
*/
|
|
37
37
|
unauthenticated?: "redirect" | "json" | "auto";
|
|
38
38
|
}
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Guards a route: only signed-in Incorta users get through — regardless of
|
|
41
|
+
* the app's `appAccess` policy (an explicit `requireAuth` is the author's
|
|
42
|
+
* promise that this route needs a user, so `"public"` mode does not neuter
|
|
43
|
+
* it). Under the `"catalog"` policy the signed-in user must additionally
|
|
44
|
+
* hold at least VIEW access to this app; the resolved verdict is attached as
|
|
45
|
+
* `req.incortaAuth.appAccess`.
|
|
46
|
+
*/
|
|
40
47
|
declare function requireAuth(auth: IncortaAuth, options?: RequireAuthOptions): Middleware;
|
|
48
|
+
/**
|
|
49
|
+
* Whole-app gate middleware: `auth.gate` as Express/Connect middleware.
|
|
50
|
+
* Mount it FIRST (before your routes, instead of `incortaAuth()` — it also
|
|
51
|
+
* serves the auth routes) to give the entire app the platform-stamped
|
|
52
|
+
* `appAccess` policy: `"public"` apps let everyone through, `"authenticated"`
|
|
53
|
+
* apps require login, `"catalog"` apps require catalog VIEW access (with the
|
|
54
|
+
* SDK's deny/unavailable pages). Requests that pass get `req.incortaAuth`
|
|
55
|
+
* populated like `incortaAuth()` does.
|
|
56
|
+
*/
|
|
57
|
+
declare function appGate(auth: IncortaAuth): Middleware;
|
|
41
58
|
|
|
42
|
-
export { AuthSession, type AuthedRequest, IncortaAuth, type RequireAuthOptions, incortaAuth, requireAuth };
|
|
59
|
+
export { AuthSession, type AuthedRequest, IncortaAuth, type RequireAuthOptions, appGate, incortaAuth, requireAuth };
|
package/dist/express.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { AuthSession, IncortaAuth } from './index.js';
|
|
3
|
-
|
|
3
|
+
export { A as AppAccess, a as AppAccessPolicy } from './app-access-BSsKtvbi.js';
|
|
4
4
|
import 'jose';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -36,7 +36,24 @@ interface RequireAuthOptions {
|
|
|
36
36
|
*/
|
|
37
37
|
unauthenticated?: "redirect" | "json" | "auto";
|
|
38
38
|
}
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Guards a route: only signed-in Incorta users get through — regardless of
|
|
41
|
+
* the app's `appAccess` policy (an explicit `requireAuth` is the author's
|
|
42
|
+
* promise that this route needs a user, so `"public"` mode does not neuter
|
|
43
|
+
* it). Under the `"catalog"` policy the signed-in user must additionally
|
|
44
|
+
* hold at least VIEW access to this app; the resolved verdict is attached as
|
|
45
|
+
* `req.incortaAuth.appAccess`.
|
|
46
|
+
*/
|
|
40
47
|
declare function requireAuth(auth: IncortaAuth, options?: RequireAuthOptions): Middleware;
|
|
48
|
+
/**
|
|
49
|
+
* Whole-app gate middleware: `auth.gate` as Express/Connect middleware.
|
|
50
|
+
* Mount it FIRST (before your routes, instead of `incortaAuth()` — it also
|
|
51
|
+
* serves the auth routes) to give the entire app the platform-stamped
|
|
52
|
+
* `appAccess` policy: `"public"` apps let everyone through, `"authenticated"`
|
|
53
|
+
* apps require login, `"catalog"` apps require catalog VIEW access (with the
|
|
54
|
+
* SDK's deny/unavailable pages). Requests that pass get `req.incortaAuth`
|
|
55
|
+
* populated like `incortaAuth()` does.
|
|
56
|
+
*/
|
|
57
|
+
declare function appGate(auth: IncortaAuth): Middleware;
|
|
41
58
|
|
|
42
|
-
export { AuthSession, type AuthedRequest, IncortaAuth, type RequireAuthOptions, incortaAuth, requireAuth };
|
|
59
|
+
export { AuthSession, type AuthedRequest, IncortaAuth, type RequireAuthOptions, appGate, incortaAuth, requireAuth };
|
package/dist/express.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
+
AppAccessTokenRejectedError
|
|
3
|
+
} from "./chunk-TMXOEIA2.js";
|
|
4
|
+
import {
|
|
5
|
+
sendWebResponse,
|
|
2
6
|
toNodeHandler,
|
|
3
7
|
toWebRequest
|
|
4
8
|
} from "./chunk-LNPECE7W.js";
|
|
@@ -30,8 +34,32 @@ function requireAuth(auth, options = {}) {
|
|
|
30
34
|
req.incortaAuth = await auth.getSession(await toWebRequest(req));
|
|
31
35
|
}
|
|
32
36
|
if (req.incortaAuth) {
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
if (auth.config.appAccess !== "catalog") {
|
|
38
|
+
next();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const session = req.incortaAuth;
|
|
42
|
+
try {
|
|
43
|
+
const verdict = await auth.getAppAccess(session);
|
|
44
|
+
if (verdict.allowed) {
|
|
45
|
+
session.appAccess = verdict;
|
|
46
|
+
next();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
res.statusCode = 403;
|
|
50
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
51
|
+
res.end(JSON.stringify({ error: "app_access_denied" }));
|
|
52
|
+
return;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (!(error instanceof AppAccessTokenRejectedError)) {
|
|
55
|
+
res.statusCode = 503;
|
|
56
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
57
|
+
res.setHeader("retry-after", "30");
|
|
58
|
+
res.end(JSON.stringify({ error: "app_access_unavailable" }));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
req.incortaAuth = null;
|
|
62
|
+
}
|
|
35
63
|
}
|
|
36
64
|
const wantsHtml = (req.headers.accept ?? "").includes("text/html");
|
|
37
65
|
if (mode === "redirect" || mode === "auto" && wantsHtml) {
|
|
@@ -52,7 +80,24 @@ function requireAuth(auth, options = {}) {
|
|
|
52
80
|
}
|
|
53
81
|
};
|
|
54
82
|
}
|
|
83
|
+
function appGate(auth) {
|
|
84
|
+
return async (req, res, next) => {
|
|
85
|
+
try {
|
|
86
|
+
const request = await toWebRequest(req);
|
|
87
|
+
const response = await auth.gate(request);
|
|
88
|
+
if (response) {
|
|
89
|
+
await sendWebResponse(res, response);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
req.incortaAuth = await auth.getSession(request);
|
|
93
|
+
next();
|
|
94
|
+
} catch (error) {
|
|
95
|
+
next(error);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
55
99
|
export {
|
|
100
|
+
appGate,
|
|
56
101
|
incortaAuth,
|
|
57
102
|
requireAuth
|
|
58
103
|
};
|
package/dist/express.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/express.ts"],"sourcesContent":["import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { AuthSession, IncortaAuth } from \"./index.js\";\nimport { toNodeHandler, toWebRequest } from \"./node.js\";\n\nexport type { AuthSession, IncortaAuth } from \"./index.js\";\n\n/**\n * Minimal structural types so this adapter works with Express 4/5 (and\n * anything Connect-compatible) without depending on Express itself.\n */\nexport interface AuthedRequest extends IncomingMessage {\n originalUrl?: string;\n /** Populated by `incortaAuth()`: the current session, or null when signed out. */\n incortaAuth?: AuthSession | null;\n}\ntype NextFunction = (err?: unknown) => void;\ntype Middleware = (\n req: AuthedRequest,\n res: ServerResponse,\n next: NextFunction,\n) => void | Promise<void>;\n\n/**\n * Express/Connect middleware that serves the auth routes (under\n * `auth.config.basePath`) and attaches the current session to every other\n * request as `req.incortaAuth`.\n *\n * ```ts\n * const auth = createIncortaAuth();\n * app.use(incortaAuth(auth));\n * app.get(\"/api/me\", requireAuth(auth), (req, res) => res.json(req.incortaAuth.user));\n * ```\n */\nexport function incortaAuth(auth: IncortaAuth): Middleware {\n const nodeHandler = toNodeHandler(auth);\n const { basePath } = auth.config;\n\n return async (req, res, next) => {\n try {\n const path = (req.originalUrl ?? req.url ?? \"/\").split(\"?\")[0];\n if (path === basePath || path.startsWith(`${basePath}/`)) {\n await nodeHandler(req, res);\n return;\n }\n req.incortaAuth = await auth.getSession(await toWebRequest(req));\n next();\n } catch (error) {\n next(error);\n }\n };\n}\n\nexport interface RequireAuthOptions {\n /**\n * How to reject unauthenticated requests:\n * - `\"redirect\"`: 302 to the login route (which bounces through Incorta login\n * and back to the original URL) — right for page routes.\n * - `\"json\"`: 401 `{ error: \"unauthenticated\" }` — right for API routes.\n * - `\"auto\"` (default): redirect when the request accepts text/html, 401 otherwise.\n */\n unauthenticated?: \"redirect\" | \"json\" | \"auto\";\n}\n\n
|
|
1
|
+
{"version":3,"sources":["../src/express.ts"],"sourcesContent":["import type { IncomingMessage, ServerResponse } from \"node:http\";\nimport { AppAccessTokenRejectedError } from \"./core/app-access.js\";\nimport type { AuthSession, IncortaAuth } from \"./index.js\";\nimport { sendWebResponse, toNodeHandler, toWebRequest } from \"./node.js\";\n\nexport type { AppAccess, AppAccessPolicy, AuthSession, IncortaAuth } from \"./index.js\";\n\n/**\n * Minimal structural types so this adapter works with Express 4/5 (and\n * anything Connect-compatible) without depending on Express itself.\n */\nexport interface AuthedRequest extends IncomingMessage {\n originalUrl?: string;\n /** Populated by `incortaAuth()`: the current session, or null when signed out. */\n incortaAuth?: AuthSession | null;\n}\ntype NextFunction = (err?: unknown) => void;\ntype Middleware = (\n req: AuthedRequest,\n res: ServerResponse,\n next: NextFunction,\n) => void | Promise<void>;\n\n/**\n * Express/Connect middleware that serves the auth routes (under\n * `auth.config.basePath`) and attaches the current session to every other\n * request as `req.incortaAuth`.\n *\n * ```ts\n * const auth = createIncortaAuth();\n * app.use(incortaAuth(auth));\n * app.get(\"/api/me\", requireAuth(auth), (req, res) => res.json(req.incortaAuth.user));\n * ```\n */\nexport function incortaAuth(auth: IncortaAuth): Middleware {\n const nodeHandler = toNodeHandler(auth);\n const { basePath } = auth.config;\n\n return async (req, res, next) => {\n try {\n const path = (req.originalUrl ?? req.url ?? \"/\").split(\"?\")[0];\n if (path === basePath || path.startsWith(`${basePath}/`)) {\n await nodeHandler(req, res);\n return;\n }\n req.incortaAuth = await auth.getSession(await toWebRequest(req));\n next();\n } catch (error) {\n next(error);\n }\n };\n}\n\nexport interface RequireAuthOptions {\n /**\n * How to reject unauthenticated requests:\n * - `\"redirect\"`: 302 to the login route (which bounces through Incorta login\n * and back to the original URL) — right for page routes.\n * - `\"json\"`: 401 `{ error: \"unauthenticated\" }` — right for API routes.\n * - `\"auto\"` (default): redirect when the request accepts text/html, 401 otherwise.\n */\n unauthenticated?: \"redirect\" | \"json\" | \"auto\";\n}\n\n/**\n * Guards a route: only signed-in Incorta users get through — regardless of\n * the app's `appAccess` policy (an explicit `requireAuth` is the author's\n * promise that this route needs a user, so `\"public\"` mode does not neuter\n * it). Under the `\"catalog\"` policy the signed-in user must additionally\n * hold at least VIEW access to this app; the resolved verdict is attached as\n * `req.incortaAuth.appAccess`.\n */\nexport function requireAuth(auth: IncortaAuth, options: RequireAuthOptions = {}): Middleware {\n const mode = options.unauthenticated ?? \"auto\";\n const { basePath } = auth.config;\n\n return async (req, res, next) => {\n try {\n if (req.incortaAuth === undefined) {\n req.incortaAuth = await auth.getSession(await toWebRequest(req));\n }\n if (req.incortaAuth) {\n if (auth.config.appAccess !== \"catalog\") {\n next();\n return;\n }\n const session = req.incortaAuth;\n try {\n const verdict = await auth.getAppAccess(session);\n if (verdict.allowed) {\n session.appAccess = verdict;\n next();\n return;\n }\n res.statusCode = 403;\n res.setHeader(\"content-type\", \"application/json; charset=utf-8\");\n res.end(JSON.stringify({ error: \"app_access_denied\" }));\n return;\n } catch (error) {\n if (!(error instanceof AppAccessTokenRejectedError)) {\n // Verdicts unavailable — NEVER fail open.\n res.statusCode = 503;\n res.setHeader(\"content-type\", \"application/json; charset=utf-8\");\n res.setHeader(\"retry-after\", \"30\");\n res.end(JSON.stringify({ error: \"app_access_unavailable\" }));\n return;\n }\n // Incorta rejected the session's token — treat as signed out.\n req.incortaAuth = null;\n }\n }\n\n const wantsHtml = (req.headers.accept ?? \"\").includes(\"text/html\");\n if (mode === \"redirect\" || (mode === \"auto\" && wantsHtml)) {\n const returnTo = req.originalUrl ?? req.url ?? \"/\";\n res.statusCode = 302;\n res.setHeader(\n \"location\",\n `${basePath}/login?redirect_to=${encodeURIComponent(returnTo)}`,\n );\n res.end();\n return;\n }\n\n res.statusCode = 401;\n res.setHeader(\"content-type\", \"application/json; charset=utf-8\");\n res.end(JSON.stringify({ error: \"unauthenticated\" }));\n } catch (error) {\n next(error);\n }\n };\n}\n\n/**\n * Whole-app gate middleware: `auth.gate` as Express/Connect middleware.\n * Mount it FIRST (before your routes, instead of `incortaAuth()` — it also\n * serves the auth routes) to give the entire app the platform-stamped\n * `appAccess` policy: `\"public\"` apps let everyone through, `\"authenticated\"`\n * apps require login, `\"catalog\"` apps require catalog VIEW access (with the\n * SDK's deny/unavailable pages). Requests that pass get `req.incortaAuth`\n * populated like `incortaAuth()` does.\n */\nexport function appGate(auth: IncortaAuth): Middleware {\n return async (req, res, next) => {\n try {\n const request = await toWebRequest(req);\n const response = await auth.gate(request);\n if (response) {\n await sendWebResponse(res, response);\n return;\n }\n req.incortaAuth = await auth.getSession(request);\n next();\n } catch (error) {\n next(error);\n }\n };\n}\n"],"mappings":";;;;;;;;;;AAkCO,SAAS,YAAY,MAA+B;AACzD,QAAM,cAAc,cAAc,IAAI;AACtC,QAAM,EAAE,SAAS,IAAI,KAAK;AAE1B,SAAO,OAAO,KAAK,KAAK,SAAS;AAC/B,QAAI;AACF,YAAM,QAAQ,IAAI,eAAe,IAAI,OAAO,KAAK,MAAM,GAAG,EAAE,CAAC;AAC7D,UAAI,SAAS,YAAY,KAAK,WAAW,GAAG,QAAQ,GAAG,GAAG;AACxD,cAAM,YAAY,KAAK,GAAG;AAC1B;AAAA,MACF;AACA,UAAI,cAAc,MAAM,KAAK,WAAW,MAAM,aAAa,GAAG,CAAC;AAC/D,WAAK;AAAA,IACP,SAAS,OAAO;AACd,WAAK,KAAK;AAAA,IACZ;AAAA,EACF;AACF;AAqBO,SAAS,YAAY,MAAmB,UAA8B,CAAC,GAAe;AAC3F,QAAM,OAAO,QAAQ,mBAAmB;AACxC,QAAM,EAAE,SAAS,IAAI,KAAK;AAE1B,SAAO,OAAO,KAAK,KAAK,SAAS;AAC/B,QAAI;AACF,UAAI,IAAI,gBAAgB,QAAW;AACjC,YAAI,cAAc,MAAM,KAAK,WAAW,MAAM,aAAa,GAAG,CAAC;AAAA,MACjE;AACA,UAAI,IAAI,aAAa;AACnB,YAAI,KAAK,OAAO,cAAc,WAAW;AACvC,eAAK;AACL;AAAA,QACF;AACA,cAAM,UAAU,IAAI;AACpB,YAAI;AACF,gBAAM,UAAU,MAAM,KAAK,aAAa,OAAO;AAC/C,cAAI,QAAQ,SAAS;AACnB,oBAAQ,YAAY;AACpB,iBAAK;AACL;AAAA,UACF;AACA,cAAI,aAAa;AACjB,cAAI,UAAU,gBAAgB,iCAAiC;AAC/D,cAAI,IAAI,KAAK,UAAU,EAAE,OAAO,oBAAoB,CAAC,CAAC;AACtD;AAAA,QACF,SAAS,OAAO;AACd,cAAI,EAAE,iBAAiB,8BAA8B;AAEnD,gBAAI,aAAa;AACjB,gBAAI,UAAU,gBAAgB,iCAAiC;AAC/D,gBAAI,UAAU,eAAe,IAAI;AACjC,gBAAI,IAAI,KAAK,UAAU,EAAE,OAAO,yBAAyB,CAAC,CAAC;AAC3D;AAAA,UACF;AAEA,cAAI,cAAc;AAAA,QACpB;AAAA,MACF;AAEA,YAAM,aAAa,IAAI,QAAQ,UAAU,IAAI,SAAS,WAAW;AACjE,UAAI,SAAS,cAAe,SAAS,UAAU,WAAY;AACzD,cAAM,WAAW,IAAI,eAAe,IAAI,OAAO;AAC/C,YAAI,aAAa;AACjB,YAAI;AAAA,UACF;AAAA,UACA,GAAG,QAAQ,sBAAsB,mBAAmB,QAAQ,CAAC;AAAA,QAC/D;AACA,YAAI,IAAI;AACR;AAAA,MACF;AAEA,UAAI,aAAa;AACjB,UAAI,UAAU,gBAAgB,iCAAiC;AAC/D,UAAI,IAAI,KAAK,UAAU,EAAE,OAAO,kBAAkB,CAAC,CAAC;AAAA,IACtD,SAAS,OAAO;AACd,WAAK,KAAK;AAAA,IACZ;AAAA,EACF;AACF;AAWO,SAAS,QAAQ,MAA+B;AACrD,SAAO,OAAO,KAAK,KAAK,SAAS;AAC/B,QAAI;AACF,YAAM,UAAU,MAAM,aAAa,GAAG;AACtC,YAAM,WAAW,MAAM,KAAK,KAAK,OAAO;AACxC,UAAI,UAAU;AACZ,cAAM,gBAAgB,KAAK,QAAQ;AACnC;AAAA,MACF;AACA,UAAI,cAAc,MAAM,KAAK,WAAW,OAAO;AAC/C,WAAK;AAAA,IACP,SAAS,OAAO;AACd,WAAK,KAAK;AAAA,IACZ;AAAA,EACF;AACF;","names":[]}
|