@incorta/auth 1.9.2 → 1.9.4

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.
@@ -1,3 +1,81 @@
1
+ // src/core/discovery.ts
2
+ import { createRemoteJWKSet } from "jose";
3
+ var endpointsCache = /* @__PURE__ */ new Map();
4
+ function toInternal(config, url) {
5
+ const internal = config.internalIncortaUrl?.replace(/\/+$/, "");
6
+ if (!internal) return url;
7
+ let target;
8
+ let contextPath;
9
+ try {
10
+ target = new URL(url);
11
+ contextPath = new URL(config.incortaUrl).pathname.replace(/\/+$/, "");
12
+ } catch {
13
+ return url;
14
+ }
15
+ const path = target.pathname;
16
+ if (path !== contextPath && !path.startsWith(`${contextPath}/`)) return url;
17
+ return `${internal}${path.slice(contextPath.length)}${target.search}`;
18
+ }
19
+ function internalHeaders(config) {
20
+ if (!config.internalIncortaUrl) return {};
21
+ let target;
22
+ try {
23
+ target = new URL(config.incortaUrl);
24
+ } catch {
25
+ return {};
26
+ }
27
+ return {
28
+ "x-forwarded-host": target.host,
29
+ "x-forwarded-proto": target.protocol.replace(/:$/, "")
30
+ };
31
+ }
32
+ function getEndpoints(config) {
33
+ const cacheKey = `${config.issuer}|${config.internalIncortaUrl ?? ""}`;
34
+ const cached = endpointsCache.get(cacheKey);
35
+ if (cached) return cached;
36
+ const promise = resolve(config).catch((error) => {
37
+ endpointsCache.delete(cacheKey);
38
+ throw error;
39
+ });
40
+ endpointsCache.set(cacheKey, promise);
41
+ return promise;
42
+ }
43
+ async function resolve(config) {
44
+ const fallback = {
45
+ issuer: config.issuer,
46
+ authorization_endpoint: `${config.issuer}/authorize`,
47
+ token_endpoint: `${config.issuer}/token`,
48
+ jwks_uri: `${config.issuer}/.well-known/jwks.json`
49
+ };
50
+ let doc = fallback;
51
+ try {
52
+ const response = await config.fetch(toInternal(config, config.discoveryUrl), {
53
+ headers: { accept: "application/json", ...internalHeaders(config) }
54
+ });
55
+ if (response.ok) {
56
+ const body = await response.json();
57
+ doc = {
58
+ issuer: body.issuer ?? fallback.issuer,
59
+ authorization_endpoint: body.authorization_endpoint ?? fallback.authorization_endpoint,
60
+ token_endpoint: body.token_endpoint ?? fallback.token_endpoint,
61
+ jwks_uri: body.jwks_uri ?? fallback.jwks_uri
62
+ };
63
+ }
64
+ } catch {
65
+ }
66
+ const jwks = config.jwksOverride ?? createRemoteJWKSet(new URL(toInternal(config, doc.jwks_uri)), {
67
+ cacheMaxAge: 10 * 60 * 1e3,
68
+ cooldownDuration: 30 * 1e3,
69
+ headers: internalHeaders(config)
70
+ });
71
+ return {
72
+ issuer: doc.issuer,
73
+ authorizationEndpoint: doc.authorization_endpoint,
74
+ tokenEndpoint: toInternal(config, doc.token_endpoint),
75
+ jwks
76
+ };
77
+ }
78
+
1
79
  // src/core/app-access.ts
2
80
  var VIEW_BIT = 1;
3
81
  var SHARE_BIT = 2;
@@ -44,7 +122,8 @@ function createAppAccessChecker(config) {
44
122
  response = await config.fetch(checkUrl, {
45
123
  headers: {
46
124
  accept: "application/json",
47
- authorization: `Bearer ${accessToken}`
125
+ authorization: `Bearer ${accessToken}`,
126
+ ...internalHeaders(config)
48
127
  },
49
128
  signal: AbortSignal.timeout(CHECK_TIMEOUT_MS)
50
129
  });
@@ -92,6 +171,8 @@ function createAppAccessChecker(config) {
92
171
  }
93
172
 
94
173
  export {
174
+ internalHeaders,
175
+ getEndpoints,
95
176
  VIEW_BIT,
96
177
  SHARE_BIT,
97
178
  EDIT_BIT,
@@ -99,4 +180,4 @@ export {
99
180
  AppAccessTokenRejectedError,
100
181
  createAppAccessChecker
101
182
  };
102
- //# sourceMappingURL=chunk-TMXOEIA2.js.map
183
+ //# sourceMappingURL=chunk-NKIN6II5.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/discovery.ts","../src/core/app-access.ts"],"sourcesContent":["import { createRemoteJWKSet } from \"jose\";\nimport type { JWTVerifyGetKey } from \"jose\";\nimport type { ResolvedConfig } from \"./config.js\";\n\ninterface DiscoveryDocument {\n issuer: string;\n authorization_endpoint: string;\n token_endpoint: string;\n jwks_uri: string;\n}\n\nexport interface Endpoints {\n issuer: string;\n authorizationEndpoint: string;\n tokenEndpoint: string;\n jwks: JWTVerifyGetKey;\n}\n\nconst endpointsCache = new Map<string, Promise<Endpoints>>();\n\n/**\n * Split horizon: URLs the SERVER fetches (discovery, token endpoint, JWKS)\n * are moved onto `internalIncortaUrl` when set. Values the BROWSER navigates\n * to (authorization endpoint) and values used for VALIDATION (issuer — must\n * equal the token's `iss` claim) are never rewritten.\n *\n * The match is by PATH under the Incorta context path (`/incorta` of\n * `incortaUrl`), not by a literal prefix: the discovery document is fetched\n * from the internal Incorta itself, and Incorta spells its own URLs with the\n * explicit port (`https://host:443/...`), as `http://` when the request\n * reached it unproxied, or with an in-cluster host name. A prefix match\n * misses all three and sends the call to the public host — which, where the\n * public host allow-lists callers, fails the token exchange and loops the\n * sign-in. URLs outside the context path pass through untouched.\n */\nfunction toInternal(config: ResolvedConfig, url: string): string {\n const internal = config.internalIncortaUrl?.replace(/\\/+$/, \"\");\n if (!internal) return url;\n let target: URL;\n let contextPath: string;\n try {\n target = new URL(url);\n contextPath = new URL(config.incortaUrl).pathname.replace(/\\/+$/, \"\");\n } catch {\n return url;\n }\n const path = target.pathname;\n if (path !== contextPath && !path.startsWith(`${contextPath}/`)) return url;\n return `${internal}${path.slice(contextPath.length)}${target.search}`;\n}\n\n/**\n * Headers for every call sent to `internalIncortaUrl`. That route reaches\n * Incorta under a private host name, and Incorta builds its advertised URLs\n * (issuer, authorize endpoint) and the tokens' `iss` from the request host —\n * a private issuer fails validation and a private authorize URL can't be\n * opened by the browser. Naming the public host here keeps both public;\n * Incorta honors X-Forwarded-Host/Proto from the caller. Empty when no\n * internal URL is set.\n */\nexport function internalHeaders(config: ResolvedConfig): Record<string, string> {\n if (!config.internalIncortaUrl) return {};\n let target: URL;\n try {\n target = new URL(config.incortaUrl);\n } catch {\n return {};\n }\n return {\n \"x-forwarded-host\": target.host,\n \"x-forwarded-proto\": target.protocol.replace(/:$/, \"\"),\n };\n}\n\n/**\n * Resolves the tenant's OAuth endpoints, preferring the OIDC discovery document\n * and falling back to Incorta's fixed `/oauth/{tenant}/...` layout when the\n * document is unreachable. Cached per issuer (and internal-URL horizon) for\n * the process lifetime.\n */\nexport function getEndpoints(config: ResolvedConfig): Promise<Endpoints> {\n const cacheKey = `${config.issuer}|${config.internalIncortaUrl ?? \"\"}`;\n const cached = endpointsCache.get(cacheKey);\n if (cached) return cached;\n\n const promise = resolve(config).catch((error) => {\n // Do not cache failures — a temporarily unreachable Incorta should not\n // poison the process.\n endpointsCache.delete(cacheKey);\n throw error;\n });\n endpointsCache.set(cacheKey, promise);\n return promise;\n}\n\nasync function resolve(config: ResolvedConfig): Promise<Endpoints> {\n const fallback: DiscoveryDocument = {\n issuer: config.issuer,\n authorization_endpoint: `${config.issuer}/authorize`,\n token_endpoint: `${config.issuer}/token`,\n jwks_uri: `${config.issuer}/.well-known/jwks.json`,\n };\n\n let doc = fallback;\n try {\n const response = await config.fetch(toInternal(config, config.discoveryUrl), {\n headers: { accept: \"application/json\", ...internalHeaders(config) },\n });\n if (response.ok) {\n const body = (await response.json()) as Partial<DiscoveryDocument>;\n doc = {\n issuer: body.issuer ?? fallback.issuer,\n authorization_endpoint: body.authorization_endpoint ?? fallback.authorization_endpoint,\n token_endpoint: body.token_endpoint ?? fallback.token_endpoint,\n jwks_uri: body.jwks_uri ?? fallback.jwks_uri,\n };\n }\n } catch {\n // Fall through to the fixed layout.\n }\n\n const jwks =\n config.jwksOverride ??\n createRemoteJWKSet(new URL(toInternal(config, doc.jwks_uri)), {\n cacheMaxAge: 10 * 60 * 1000,\n cooldownDuration: 30 * 1000,\n headers: internalHeaders(config),\n });\n\n return {\n issuer: doc.issuer,\n authorizationEndpoint: doc.authorization_endpoint,\n tokenEndpoint: toInternal(config, doc.token_endpoint),\n jwks,\n };\n}\n\n/** Test hook: clears the per-process endpoint cache. */\nexport function clearEndpointsCache(): void {\n endpointsCache.clear();\n}\n","import type { ResolvedConfig } from \"./config.js\";\nimport { internalHeaders } from \"./discovery.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 ...internalHeaders(config),\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":";AAAA,SAAS,0BAA0B;AAkBnC,IAAM,iBAAiB,oBAAI,IAAgC;AAiB3D,SAAS,WAAW,QAAwB,KAAqB;AAC/D,QAAM,WAAW,OAAO,oBAAoB,QAAQ,QAAQ,EAAE;AAC9D,MAAI,CAAC,SAAU,QAAO;AACtB,MAAI;AACJ,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,GAAG;AACpB,kBAAc,IAAI,IAAI,OAAO,UAAU,EAAE,SAAS,QAAQ,QAAQ,EAAE;AAAA,EACtE,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,OAAO,OAAO;AACpB,MAAI,SAAS,eAAe,CAAC,KAAK,WAAW,GAAG,WAAW,GAAG,EAAG,QAAO;AACxE,SAAO,GAAG,QAAQ,GAAG,KAAK,MAAM,YAAY,MAAM,CAAC,GAAG,OAAO,MAAM;AACrE;AAWO,SAAS,gBAAgB,QAAgD;AAC9E,MAAI,CAAC,OAAO,mBAAoB,QAAO,CAAC;AACxC,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,OAAO,UAAU;AAAA,EACpC,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACA,SAAO;AAAA,IACL,oBAAoB,OAAO;AAAA,IAC3B,qBAAqB,OAAO,SAAS,QAAQ,MAAM,EAAE;AAAA,EACvD;AACF;AAQO,SAAS,aAAa,QAA4C;AACvE,QAAM,WAAW,GAAG,OAAO,MAAM,IAAI,OAAO,sBAAsB,EAAE;AACpE,QAAM,SAAS,eAAe,IAAI,QAAQ;AAC1C,MAAI,OAAQ,QAAO;AAEnB,QAAM,UAAU,QAAQ,MAAM,EAAE,MAAM,CAAC,UAAU;AAG/C,mBAAe,OAAO,QAAQ;AAC9B,UAAM;AAAA,EACR,CAAC;AACD,iBAAe,IAAI,UAAU,OAAO;AACpC,SAAO;AACT;AAEA,eAAe,QAAQ,QAA4C;AACjE,QAAM,WAA8B;AAAA,IAClC,QAAQ,OAAO;AAAA,IACf,wBAAwB,GAAG,OAAO,MAAM;AAAA,IACxC,gBAAgB,GAAG,OAAO,MAAM;AAAA,IAChC,UAAU,GAAG,OAAO,MAAM;AAAA,EAC5B;AAEA,MAAI,MAAM;AACV,MAAI;AACF,UAAM,WAAW,MAAM,OAAO,MAAM,WAAW,QAAQ,OAAO,YAAY,GAAG;AAAA,MAC3E,SAAS,EAAE,QAAQ,oBAAoB,GAAG,gBAAgB,MAAM,EAAE;AAAA,IACpE,CAAC;AACD,QAAI,SAAS,IAAI;AACf,YAAM,OAAQ,MAAM,SAAS,KAAK;AAClC,YAAM;AAAA,QACJ,QAAQ,KAAK,UAAU,SAAS;AAAA,QAChC,wBAAwB,KAAK,0BAA0B,SAAS;AAAA,QAChE,gBAAgB,KAAK,kBAAkB,SAAS;AAAA,QAChD,UAAU,KAAK,YAAY,SAAS;AAAA,MACtC;AAAA,IACF;AAAA,EACF,QAAQ;AAAA,EAER;AAEA,QAAM,OACJ,OAAO,gBACP,mBAAmB,IAAI,IAAI,WAAW,QAAQ,IAAI,QAAQ,CAAC,GAAG;AAAA,IAC5D,aAAa,KAAK,KAAK;AAAA,IACvB,kBAAkB,KAAK;AAAA,IACvB,SAAS,gBAAgB,MAAM;AAAA,EACjC,CAAC;AAEH,SAAO;AAAA,IACL,QAAQ,IAAI;AAAA,IACZ,uBAAuB,IAAI;AAAA,IAC3B,eAAe,WAAW,QAAQ,IAAI,cAAc;AAAA,IACpD;AAAA,EACF;AACF;;;ACvHO,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,UACpC,GAAG,gBAAgB,MAAM;AAAA,QAC3B;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":[]}
@@ -1 +1 @@
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":[]}
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\";\nimport { internalHeaders } from \"./discovery.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 ...internalHeaders(config),\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.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  AppAccessTokenRejectedError
3
- } from "./chunk-TMXOEIA2.js";
3
+ } from "./chunk-NKIN6II5.js";
4
4
  import {
5
5
  sendWebResponse,
6
6
  toNodeHandler,
package/dist/index.cjs CHANGED
@@ -126,6 +126,84 @@ function resolveConfig(input = {}) {
126
126
  // src/core/handler.ts
127
127
  var import_node_crypto3 = require("crypto");
128
128
 
129
+ // src/core/discovery.ts
130
+ var import_jose = require("jose");
131
+ var endpointsCache = /* @__PURE__ */ new Map();
132
+ function toInternal(config, url) {
133
+ const internal = config.internalIncortaUrl?.replace(/\/+$/, "");
134
+ if (!internal) return url;
135
+ let target;
136
+ let contextPath;
137
+ try {
138
+ target = new URL(url);
139
+ contextPath = new URL(config.incortaUrl).pathname.replace(/\/+$/, "");
140
+ } catch {
141
+ return url;
142
+ }
143
+ const path = target.pathname;
144
+ if (path !== contextPath && !path.startsWith(`${contextPath}/`)) return url;
145
+ return `${internal}${path.slice(contextPath.length)}${target.search}`;
146
+ }
147
+ function internalHeaders(config) {
148
+ if (!config.internalIncortaUrl) return {};
149
+ let target;
150
+ try {
151
+ target = new URL(config.incortaUrl);
152
+ } catch {
153
+ return {};
154
+ }
155
+ return {
156
+ "x-forwarded-host": target.host,
157
+ "x-forwarded-proto": target.protocol.replace(/:$/, "")
158
+ };
159
+ }
160
+ function getEndpoints(config) {
161
+ const cacheKey = `${config.issuer}|${config.internalIncortaUrl ?? ""}`;
162
+ const cached = endpointsCache.get(cacheKey);
163
+ if (cached) return cached;
164
+ const promise = resolve(config).catch((error) => {
165
+ endpointsCache.delete(cacheKey);
166
+ throw error;
167
+ });
168
+ endpointsCache.set(cacheKey, promise);
169
+ return promise;
170
+ }
171
+ async function resolve(config) {
172
+ const fallback = {
173
+ issuer: config.issuer,
174
+ authorization_endpoint: `${config.issuer}/authorize`,
175
+ token_endpoint: `${config.issuer}/token`,
176
+ jwks_uri: `${config.issuer}/.well-known/jwks.json`
177
+ };
178
+ let doc = fallback;
179
+ try {
180
+ const response = await config.fetch(toInternal(config, config.discoveryUrl), {
181
+ headers: { accept: "application/json", ...internalHeaders(config) }
182
+ });
183
+ if (response.ok) {
184
+ const body = await response.json();
185
+ doc = {
186
+ issuer: body.issuer ?? fallback.issuer,
187
+ authorization_endpoint: body.authorization_endpoint ?? fallback.authorization_endpoint,
188
+ token_endpoint: body.token_endpoint ?? fallback.token_endpoint,
189
+ jwks_uri: body.jwks_uri ?? fallback.jwks_uri
190
+ };
191
+ }
192
+ } catch {
193
+ }
194
+ const jwks = config.jwksOverride ?? (0, import_jose.createRemoteJWKSet)(new URL(toInternal(config, doc.jwks_uri)), {
195
+ cacheMaxAge: 10 * 60 * 1e3,
196
+ cooldownDuration: 30 * 1e3,
197
+ headers: internalHeaders(config)
198
+ });
199
+ return {
200
+ issuer: doc.issuer,
201
+ authorizationEndpoint: doc.authorization_endpoint,
202
+ tokenEndpoint: toInternal(config, doc.token_endpoint),
203
+ jwks
204
+ };
205
+ }
206
+
129
207
  // src/core/app-access.ts
130
208
  var VIEW_BIT = 1;
131
209
  var SHARE_BIT = 2;
@@ -172,7 +250,8 @@ function createAppAccessChecker(config) {
172
250
  response = await config.fetch(checkUrl, {
173
251
  headers: {
174
252
  accept: "application/json",
175
- authorization: `Bearer ${accessToken}`
253
+ authorization: `Bearer ${accessToken}`,
254
+ ...internalHeaders(config)
176
255
  },
177
256
  signal: AbortSignal.timeout(CHECK_TIMEOUT_MS)
178
257
  });
@@ -256,60 +335,6 @@ function deleteCookie(name, options = {}) {
256
335
  return serializeCookie(name, "", { ...options, maxAge: 0 });
257
336
  }
258
337
 
259
- // src/core/discovery.ts
260
- var import_jose = require("jose");
261
- var endpointsCache = /* @__PURE__ */ new Map();
262
- function toInternal(config, url) {
263
- const internal = config.internalIncortaUrl;
264
- if (!internal || !url.startsWith(config.incortaUrl)) return url;
265
- return internal + url.slice(config.incortaUrl.length);
266
- }
267
- function getEndpoints(config) {
268
- const cacheKey = `${config.issuer}|${config.internalIncortaUrl ?? ""}`;
269
- const cached = endpointsCache.get(cacheKey);
270
- if (cached) return cached;
271
- const promise = resolve(config).catch((error) => {
272
- endpointsCache.delete(cacheKey);
273
- throw error;
274
- });
275
- endpointsCache.set(cacheKey, promise);
276
- return promise;
277
- }
278
- async function resolve(config) {
279
- const fallback = {
280
- issuer: config.issuer,
281
- authorization_endpoint: `${config.issuer}/authorize`,
282
- token_endpoint: `${config.issuer}/token`,
283
- jwks_uri: `${config.issuer}/.well-known/jwks.json`
284
- };
285
- let doc = fallback;
286
- try {
287
- const response = await config.fetch(toInternal(config, config.discoveryUrl), {
288
- headers: { accept: "application/json" }
289
- });
290
- if (response.ok) {
291
- const body = await response.json();
292
- doc = {
293
- issuer: body.issuer ?? fallback.issuer,
294
- authorization_endpoint: body.authorization_endpoint ?? fallback.authorization_endpoint,
295
- token_endpoint: body.token_endpoint ?? fallback.token_endpoint,
296
- jwks_uri: body.jwks_uri ?? fallback.jwks_uri
297
- };
298
- }
299
- } catch {
300
- }
301
- const jwks = config.jwksOverride ?? (0, import_jose.createRemoteJWKSet)(new URL(toInternal(config, doc.jwks_uri)), {
302
- cacheMaxAge: 10 * 60 * 1e3,
303
- cooldownDuration: 30 * 1e3
304
- });
305
- return {
306
- issuer: doc.issuer,
307
- authorizationEndpoint: doc.authorization_endpoint,
308
- tokenEndpoint: toInternal(config, doc.token_endpoint),
309
- jwks
310
- };
311
- }
312
-
313
338
  // src/core/pkce.ts
314
339
  var import_node_crypto = require("crypto");
315
340
  function generateCodeVerifier() {
@@ -398,6 +423,7 @@ async function callTokenEndpoint(config, params) {
398
423
  headers: {
399
424
  "content-type": "application/x-www-form-urlencoded",
400
425
  accept: "application/json",
426
+ ...internalHeaders(config),
401
427
  ...auth.headers
402
428
  },
403
429
  body: new URLSearchParams(auth.params).toString()