@incorta/auth 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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/** 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"],"mappings":";;;;;;AAiCO,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;AAcO,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,aAAK;AACL;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;","names":[]}
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":[]}
package/dist/index.cjs CHANGED
@@ -20,7 +20,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var src_exports = {};
22
22
  __export(src_exports, {
23
+ AppAccessTokenRejectedError: () => AppAccessTokenRejectedError,
24
+ AppAccessUnavailableError: () => AppAccessUnavailableError,
25
+ EDIT_BIT: () => EDIT_BIT,
23
26
  IncortaAuthError: () => IncortaAuthError,
27
+ SHARE_BIT: () => SHARE_BIT,
28
+ VIEW_BIT: () => VIEW_BIT,
24
29
  createIncortaAuth: () => createIncortaAuth,
25
30
  deleteClient: () => deleteClient,
26
31
  getClient: () => getClient,
@@ -30,6 +35,16 @@ __export(src_exports, {
30
35
  module.exports = __toCommonJS(src_exports);
31
36
 
32
37
  // src/core/config.ts
38
+ function resolveAppAccess(input, envValue) {
39
+ if (input) return input;
40
+ const raw = envValue?.trim().toLowerCase();
41
+ if (!raw) return "authenticated";
42
+ if (raw === "public" || raw === "authenticated" || raw === "catalog") return raw;
43
+ console.warn(
44
+ `[incorta-auth] Unrecognized INCORTA_APP_ACCESS value "${envValue}" \u2014 using "authenticated".`
45
+ );
46
+ return "authenticated";
47
+ }
33
48
  function required(value, name, envVar) {
34
49
  if (!value) {
35
50
  throw new Error(
@@ -103,6 +118,8 @@ function resolveConfig(input = {}) {
103
118
  cookieSameSite,
104
119
  cookiePartitioned,
105
120
  exposeAccessToken: input.exposeAccessToken ?? false,
121
+ appAccess: resolveAppAccess(input.appAccess, env.INCORTA_APP_ACCESS),
122
+ embeddedSignIn: input.embeddedSignIn ?? (env.INCORTA_EMBEDDED_SIGN_IN?.toLowerCase() === "new-tab" ? "new-tab" : "redirect"),
106
123
  issuer,
107
124
  discoveryUrl: input.advanced?.discoveryUrl ?? `${issuer}/.well-known/openid-configuration`,
108
125
  jwksOverride: input.advanced?.jwks,
@@ -113,6 +130,99 @@ function resolveConfig(input = {}) {
113
130
  // src/core/handler.ts
114
131
  var import_node_crypto2 = require("crypto");
115
132
 
133
+ // src/core/app-access.ts
134
+ var VIEW_BIT = 1;
135
+ var SHARE_BIT = 2;
136
+ var EDIT_BIT = 4;
137
+ var AppAccessUnavailableError = class extends Error {
138
+ constructor(cause) {
139
+ super(`[incorta-auth] App access check unavailable: ${cause}`);
140
+ this.name = "AppAccessUnavailableError";
141
+ }
142
+ };
143
+ var AppAccessTokenRejectedError = class extends Error {
144
+ constructor() {
145
+ super("[incorta-auth] Incorta rejected the session token during the app access check");
146
+ this.name = "AppAccessTokenRejectedError";
147
+ }
148
+ };
149
+ var APP_ACCESS_CACHE_TTL_MS = 3e4;
150
+ var CHECK_TIMEOUT_MS = 5e3;
151
+ var CACHE_MAX_ENTRIES = 5e3;
152
+ function toVerdict(registered, access) {
153
+ const canView = (access & VIEW_BIT) === VIEW_BIT;
154
+ return {
155
+ registered,
156
+ access,
157
+ canView,
158
+ canEdit: (access & EDIT_BIT) === EDIT_BIT,
159
+ allowed: !registered || canView
160
+ };
161
+ }
162
+ function createAppAccessChecker(config) {
163
+ const base = config.internalIncortaUrl ?? config.incortaUrl;
164
+ const checkUrl = `${base}/apps-catalog/check`;
165
+ const cache = /* @__PURE__ */ new Map();
166
+ const inFlight = /* @__PURE__ */ new Map();
167
+ function prune(now) {
168
+ if (cache.size < CACHE_MAX_ENTRIES) return;
169
+ for (const [key, entry] of cache) {
170
+ if (entry.expiresAt <= now) cache.delete(key);
171
+ }
172
+ }
173
+ async function fetchVerdict(accessToken) {
174
+ let response;
175
+ try {
176
+ response = await config.fetch(checkUrl, {
177
+ headers: {
178
+ accept: "application/json",
179
+ authorization: `Bearer ${accessToken}`
180
+ },
181
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS)
182
+ });
183
+ } catch (error) {
184
+ throw new AppAccessUnavailableError(error.message);
185
+ }
186
+ if (response.status === 401) throw new AppAccessTokenRejectedError();
187
+ if (!response.ok) {
188
+ throw new AppAccessUnavailableError(`HTTP ${response.status}`);
189
+ }
190
+ let body;
191
+ try {
192
+ body = await response.json();
193
+ } catch {
194
+ throw new AppAccessUnavailableError("invalid JSON response");
195
+ }
196
+ if (typeof body.registered !== "boolean" || typeof body.access !== "number") {
197
+ throw new AppAccessUnavailableError("malformed check response");
198
+ }
199
+ return toVerdict(body.registered, body.access);
200
+ }
201
+ async function check(accessToken, userKey) {
202
+ const now = Date.now();
203
+ const cached = cache.get(userKey);
204
+ if (cached && cached.expiresAt > now) return cached.verdict;
205
+ const pending = inFlight.get(userKey);
206
+ if (pending) return pending;
207
+ const promise = fetchVerdict(accessToken).then((verdict) => {
208
+ prune(now);
209
+ cache.set(userKey, { verdict, expiresAt: Date.now() + APP_ACCESS_CACHE_TTL_MS });
210
+ return verdict;
211
+ }).finally(() => {
212
+ inFlight.delete(userKey);
213
+ });
214
+ inFlight.set(userKey, promise);
215
+ return promise;
216
+ }
217
+ return {
218
+ check,
219
+ clearCache() {
220
+ cache.clear();
221
+ inFlight.clear();
222
+ }
223
+ };
224
+ }
225
+
116
226
  // src/core/cookies.ts
117
227
  function parseCookies(header) {
118
228
  const out = {};
@@ -411,7 +521,17 @@ function withErrorParam(target, code) {
411
521
  function randomToken() {
412
522
  return (0, import_node_crypto2.randomBytes)(16).toString("base64url");
413
523
  }
524
+ function escapeHtml(value) {
525
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
526
+ }
527
+ function wantsHtml(request) {
528
+ return request.method.toUpperCase() === "GET" && (request.headers.get("accept") ?? "").includes("text/html");
529
+ }
530
+ function requestPath(url) {
531
+ return `${url.pathname}${url.search}`;
532
+ }
414
533
  function createHandler(config) {
534
+ const appAccessChecker = createAppAccessChecker(config);
415
535
  const sessionCookieOptions = (secure) => ({
416
536
  httpOnly: true,
417
537
  // SameSite=None requires Secure — force the pair even on plain http
@@ -444,6 +564,9 @@ function createHandler(config) {
444
564
  authorizeUrl.searchParams.set("scope", config.scope);
445
565
  authorizeUrl.searchParams.set("state", state);
446
566
  authorizeUrl.searchParams.set("nonce", nonce);
567
+ if (config.embeddedSignIn === "new-tab") {
568
+ return newTabSignInPage(authorizeUrl.toString(), `${origin}/`, [stateCookie]);
569
+ }
447
570
  return redirect(authorizeUrl.toString(), [stateCookie]);
448
571
  }
449
572
  async function callback(request, url) {
@@ -558,6 +681,18 @@ function createHandler(config) {
558
681
  setCookies
559
682
  );
560
683
  }
684
+ async function refreshRoute(request, url) {
685
+ const origin = requestOrigin(config, request);
686
+ const returnTo = validateReturnTo(url.searchParams.get("redirect_to"), origin);
687
+ const { session, setCookies } = await loadSession(request);
688
+ if (!session) {
689
+ return redirect(
690
+ `${config.basePath}/login?redirect_to=${encodeURIComponent(returnTo)}`,
691
+ setCookies
692
+ );
693
+ }
694
+ return redirect(returnTo, setCookies);
695
+ }
561
696
  async function logout(request, url) {
562
697
  const secure = isSecureRequest(config, request);
563
698
  const clearSession = deleteCookie(config.cookieName, sessionCookieOptions(secure));
@@ -610,6 +745,143 @@ function createHandler(config) {
610
745
  }
611
746
  );
612
747
  }
748
+ function newTabSignInPage(authorizeUrl, appRoot, cookies) {
749
+ const body = `<!doctype html>
750
+ <html lang="en">
751
+ <head>
752
+ <meta charset="utf-8" />
753
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
754
+ <title>Sign in with Incorta</title>
755
+ <style>
756
+ body { margin: 0; display: grid; place-items: center; min-height: 100vh;
757
+ background: #fafafa; color: #18181b;
758
+ font-family: ui-sans-serif, system-ui, sans-serif; }
759
+ main { max-width: 26rem; padding: 2rem; text-align: center; }
760
+ h1 { font-size: 1.2rem; margin: 0 0 .5rem; }
761
+ p { color: #52525b; font-size: .9rem; line-height: 1.5; margin: 0 0 1.25rem; }
762
+ a.btn { display: inline-block; padding: .55rem 1.1rem; border-radius: .5rem;
763
+ background: #18181b; color: #fafafa; text-decoration: none;
764
+ font-weight: 600; }
765
+ </style>
766
+ </head>
767
+ <body>
768
+ <main id="frame" hidden>
769
+ <h1>Sign in with Incorta</h1>
770
+ <p>
771
+ This app signs you in with your Incorta account. The embedded preview
772
+ can&rsquo;t complete sign-in here &mdash; continue in a new tab.
773
+ </p>
774
+ <a class="btn" href="${appRoot}" target="_blank" rel="noopener noreferrer">Sign in with Incorta &#8599;</a>
775
+ </main>
776
+ <script>
777
+ (function () {
778
+ var authorize = ${JSON.stringify(authorizeUrl)};
779
+ if (window.top === window.self) {
780
+ window.location.replace(authorize);
781
+ } else {
782
+ document.getElementById("frame").hidden = false;
783
+ }
784
+ })();
785
+ </script>
786
+ <noscript><a href="${appRoot}">Continue to sign in</a></noscript>
787
+ </body>
788
+ </html>`;
789
+ const headers = new Headers({
790
+ "content-type": "text/html; charset=utf-8",
791
+ "cache-control": "no-store"
792
+ });
793
+ for (const cookie of cookies) headers.append("set-cookie", cookie);
794
+ return new Response(body, { status: 200, headers });
795
+ }
796
+ function accessDeniedResponse(request, session) {
797
+ if (!wantsHtml(request)) {
798
+ return json(403, { error: "app_access_denied" });
799
+ }
800
+ const identity = escapeHtml(session.user.name?.trim() || session.user.sub);
801
+ const detail = session.user.name?.trim() && session.user.name.trim() !== session.user.sub ? `${identity} (${escapeHtml(session.user.sub)})` : identity;
802
+ const switchHref = `${config.basePath}/logout`;
803
+ return new Response(
804
+ `<!doctype html>
805
+ <html lang="en">
806
+ <head>
807
+ <meta charset="utf-8" />
808
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
809
+ <title>No access</title>
810
+ <style>
811
+ body { margin: 0; display: grid; place-items: center; min-height: 100vh;
812
+ background: #fafafa; color: #18181b;
813
+ font-family: ui-sans-serif, system-ui, sans-serif; }
814
+ main { max-width: 26rem; padding: 2rem; text-align: center; }
815
+ h1 { font-size: 1.25rem; margin: 0 0 .5rem; }
816
+ p { color: #52525b; font-size: .9rem; line-height: 1.5; margin: 0 0 1.25rem; }
817
+ strong { color: #18181b; font-weight: 600; }
818
+ a { display: inline-block; padding: .55rem 1.1rem; border-radius: .5rem;
819
+ background: #18181b; color: #fafafa; font-size: .9rem;
820
+ text-decoration: none; }
821
+ </style>
822
+ </head>
823
+ <body>
824
+ <main>
825
+ <h1>You don&rsquo;t have access to this application</h1>
826
+ <p>
827
+ Ask the app&rsquo;s owner to share it with you in Incorta.
828
+ You are signed in as <strong>${detail}</strong>.
829
+ </p>
830
+ <a href="${switchHref}">Sign in with a different account</a>
831
+ </main>
832
+ </body>
833
+ </html>`,
834
+ {
835
+ status: 403,
836
+ headers: {
837
+ "content-type": "text/html; charset=utf-8",
838
+ "cache-control": "no-store"
839
+ }
840
+ }
841
+ );
842
+ }
843
+ function accessUnavailableResponse(request) {
844
+ if (!wantsHtml(request)) {
845
+ return json(503, { error: "app_access_unavailable" });
846
+ }
847
+ return new Response(
848
+ `<!doctype html>
849
+ <html lang="en">
850
+ <head>
851
+ <meta charset="utf-8" />
852
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
853
+ <meta http-equiv="refresh" content="15" />
854
+ <title>Temporarily unavailable</title>
855
+ <style>
856
+ body { margin: 0; display: grid; place-items: center; min-height: 100vh;
857
+ background: #fafafa; color: #18181b;
858
+ font-family: ui-sans-serif, system-ui, sans-serif; }
859
+ main { max-width: 26rem; padding: 2rem; text-align: center; }
860
+ h1 { font-size: 1.25rem; margin: 0 0 .5rem; }
861
+ p { color: #52525b; font-size: .9rem; line-height: 1.5; margin: 0; }
862
+ </style>
863
+ </head>
864
+ <body>
865
+ <main>
866
+ <h1>This app is temporarily unavailable</h1>
867
+ <p>
868
+ Your access could not be verified because Incorta did not respond.
869
+ This page retries automatically &mdash; you can also refresh in a
870
+ moment.
871
+ </p>
872
+ </main>
873
+ </body>
874
+ </html>`,
875
+ {
876
+ status: 503,
877
+ headers: {
878
+ "content-type": "text/html; charset=utf-8",
879
+ "cache-control": "no-store",
880
+ "retry-after": "30"
881
+ }
882
+ }
883
+ );
884
+ }
613
885
  async function handler(request) {
614
886
  const url = new URL(request.url);
615
887
  if (url.pathname !== config.basePath && !url.pathname.startsWith(`${config.basePath}/`)) {
@@ -622,6 +894,7 @@ function createHandler(config) {
622
894
  if (route === "/callback" && method === "GET") return await callback(request, url);
623
895
  if (route === "/session" && method === "GET") return await sessionRoute(request);
624
896
  if (route === "/token" && method === "GET") return await tokenRoute(request);
897
+ if (route === "/refresh" && method === "GET") return await refreshRoute(request, url);
625
898
  if (route === "/logout" && (method === "POST" || method === "GET")) {
626
899
  return await logout(request, url);
627
900
  }
@@ -647,7 +920,57 @@ function createHandler(config) {
647
920
  expiresAt
648
921
  };
649
922
  }
650
- return { handler, getSession };
923
+ function getAppAccess(session) {
924
+ return appAccessChecker.check(session.accessToken, session.user.sub);
925
+ }
926
+ async function gate(request) {
927
+ const url = new URL(request.url);
928
+ if (url.pathname === config.basePath || url.pathname.startsWith(`${config.basePath}/`)) {
929
+ return handler(request);
930
+ }
931
+ if (config.appAccess === "public") return null;
932
+ const session = await getSession(request);
933
+ if (!session) {
934
+ if (wantsHtml(request)) {
935
+ return redirect(
936
+ `${config.basePath}/login?redirect_to=${encodeURIComponent(requestPath(url))}`
937
+ );
938
+ }
939
+ return json(401, { error: "unauthenticated" });
940
+ }
941
+ if (config.appAccess !== "catalog") return null;
942
+ if (session.accessTokenExpiresAt <= Date.now()) {
943
+ if (wantsHtml(request)) {
944
+ return redirect(
945
+ `${config.basePath}/refresh?redirect_to=${encodeURIComponent(requestPath(url))}`
946
+ );
947
+ }
948
+ return json(401, { error: "token_expired" });
949
+ }
950
+ try {
951
+ const verdict = await getAppAccess(session);
952
+ if (verdict.allowed) return null;
953
+ return accessDeniedResponse(request, session);
954
+ } catch (error) {
955
+ if (error instanceof AppAccessTokenRejectedError) {
956
+ const secure = isSecureRequest(config, request);
957
+ const clearSession = deleteCookie(config.cookieName, sessionCookieOptions(secure));
958
+ if (wantsHtml(request)) {
959
+ return redirect(
960
+ `${config.basePath}/login?redirect_to=${encodeURIComponent(requestPath(url))}`,
961
+ [clearSession]
962
+ );
963
+ }
964
+ return json(401, { error: "unauthenticated" }, [clearSession]);
965
+ }
966
+ if (!(error instanceof AppAccessUnavailableError)) {
967
+ const message = error instanceof Error ? error.message : String(error);
968
+ console.error("[incorta-auth] app access check failed unexpectedly:", message);
969
+ }
970
+ return accessUnavailableResponse(request);
971
+ }
972
+ }
973
+ return { handler, getSession, gate, getAppAccess };
651
974
  }
652
975
 
653
976
  // src/core/register.ts
@@ -782,12 +1105,17 @@ async function deleteClient(options) {
782
1105
  // src/index.ts
783
1106
  function createIncortaAuth(config = {}) {
784
1107
  const resolved = resolveConfig(config);
785
- const { handler, getSession } = createHandler(resolved);
786
- return { handler, getSession, config: resolved };
1108
+ const { handler, getSession, gate, getAppAccess } = createHandler(resolved);
1109
+ return { handler, getSession, gate, getAppAccess, config: resolved };
787
1110
  }
788
1111
  // Annotate the CommonJS export names for ESM import in node:
789
1112
  0 && (module.exports = {
1113
+ AppAccessTokenRejectedError,
1114
+ AppAccessUnavailableError,
1115
+ EDIT_BIT,
790
1116
  IncortaAuthError,
1117
+ SHARE_BIT,
1118
+ VIEW_BIT,
791
1119
  createIncortaAuth,
792
1120
  deleteClient,
793
1121
  getClient,