@incorta/auth 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ guide; this is the API surface.
8
8
 
9
9
  | Import | What's in it |
10
10
  | ----------------------- | ------------------------------------------------------------------- |
11
- | `@incorta/auth` | `createIncortaAuth`, `registerClient`, `getClient`, `updateClientRedirectUris`, types (`IncortaUser`, `AuthSession`, …) |
11
+ | `@incorta/auth` | `createIncortaAuth`, `registerClient`, `getClient`, `updateClientRedirectUris`, `deleteClient`, types (`IncortaUser`, `AuthSession`, …) |
12
12
  | `@incorta/auth/react` | `IncortaAuthProvider`, `useAuth`, `useUser`, `SignedIn`, `SignedOut`, `Protect`, `RedirectToLogin` |
13
13
  | `@incorta/auth/express` | `incortaAuth(auth)` middleware, `requireAuth(auth, options?)` |
14
14
  | `@incorta/auth/node` | `toNodeHandler(auth)`, `toWebRequest`, `sendWebResponse`, `getNodeSession` |
@@ -35,7 +35,11 @@ Returns `{ handler, getSession, config }`:
35
35
  for server code (`user`, `accessToken`, `accessTokenExpiresAt`, `expiresAt`).
36
36
 
37
37
  Config falls back to `INCORTA_URL`, `INCORTA_TENANT`, `INCORTA_CLIENT_ID`,
38
- `INCORTA_CLIENT_SECRET`, `INCORTA_AUTH_SECRET`, `INCORTA_APP_URL`.
38
+ `INCORTA_CLIENT_SECRET`, `INCORTA_AUTH_SECRET`, `INCORTA_APP_URL`, and
39
+ `INCORTA_INTERNAL_URL` (split horizon: server-side discovery/token/JWKS calls
40
+ use it when the public `incortaUrl` is not routable from the app — e.g.
41
+ `http://host.docker.internal:8080/incorta` from a local container; the
42
+ authorize redirect and issuer validation stay on the public URL).
39
43
 
40
44
  ## Session model
41
45
 
@@ -44,6 +48,15 @@ The cookie is an encrypted JWT (JWE `dir`/`A256GCM`, key derived from
44
48
  claims (`sub`, `name`, `email`, `roles`, `tenant`) + the Incorta access/refresh
45
49
  tokens. Nothing is stored server-side.
46
50
 
51
+ **Embedded (cross-site iframe) mode**: when the app runs inside an iframe on a
52
+ DIFFERENT site, `Lax` cookies are never sent — the login flow loops forever.
53
+ Set `cookies: { sameSite: "none", partitioned: true }` (or env
54
+ `INCORTA_COOKIE_SAMESITE=none` + `INCORTA_COOKIE_PARTITIONED=1`): the auth
55
+ cookies become `SameSite=None; Secure; Partitioned` (CHIPS), which modern
56
+ Chrome allows in third-party iframes with a per-embedder cookie jar. `none`
57
+ forces `Secure` (accepted on trustworthy plain-http origins like
58
+ `*.localhost`); top-level usage keeps working with the same setting.
59
+
47
60
  ## CLI
48
61
 
49
62
  ```bash
@@ -54,12 +67,16 @@ incorta-auth show --url ... --tenant ... --client <id> --rat <token> [--json]
54
67
 
55
68
  incorta-auth update-redirects --url ... --tenant ... --client <id> --rat <token> \
56
69
  --redirect <url> [--redirect ...] [--replace] [--json]
70
+
71
+ incorta-auth delete --url ... --tenant ... --client <id> --rat <token>
57
72
  ```
58
73
 
59
74
  `register` creates the OAuth client via RFC 7591 dynamic registration and
60
75
  prints the `.env` entries (keep the registration access token). `show` and
61
76
  `update-redirects` manage the registered client's allowed callback URLs —
62
- adding by default, replacing the whole set with `--replace`.
77
+ adding by default, replacing the whole set with `--replace`. `delete` removes
78
+ the registration (new logins stop immediately; issued sessions live until
79
+ they expire).
63
80
 
64
81
  ## Testing
65
82
 
package/dist/cli.js CHANGED
@@ -118,6 +118,19 @@ async function updateClientRedirectUris(options) {
118
118
  }
119
119
  return getClient(options);
120
120
  }
121
+ async function deleteClient(options) {
122
+ const { incortaUrl, tenant, clientId, registrationAccessToken, fetchImpl = fetch } = options;
123
+ const response = await fetchImpl(registrationEndpoint(incortaUrl, tenant, clientId), {
124
+ method: "DELETE",
125
+ headers: {
126
+ accept: "application/json",
127
+ authorization: `Bearer ${registrationAccessToken}`
128
+ }
129
+ });
130
+ if (!response.ok) {
131
+ await readJson(response, "Deleting the client");
132
+ }
133
+ }
121
134
 
122
135
  // src/cli.ts
123
136
  var HELP = `incorta-auth \u2014 IncortaAuth SDK helper
@@ -135,14 +148,19 @@ Usage:
135
148
  --redirect <callbackUrl> [--redirect ...] \\
136
149
  [--replace] [--json]
137
150
 
151
+ incorta-auth delete --url <incortaUrl> --tenant <tenant> \\
152
+ --client <clientId> --rat <registrationAccessToken>
153
+
138
154
  register Registers an OAuth client in Incorta (RFC 7591 dynamic
139
155
  registration) and prints the .env entries your app needs.
140
156
  Keep the printed registration access token \u2014 it is the key
141
- for show/update-redirects later.
157
+ for show/update-redirects/delete later.
142
158
  show Prints a registered client's current metadata.
143
159
  update-redirects Adds the given callback URLs to the client's allowed set
144
160
  (or replaces the whole set with --replace, which is also how
145
161
  URLs are removed).
162
+ delete Deletes the client registration. New logins stop
163
+ immediately; sessions already issued live until they expire.
146
164
 
147
165
  Example:
148
166
  incorta-auth register \\
@@ -263,6 +281,15 @@ async function runUpdateRedirects(args) {
263
281
  console.log(`Redirect URIs ${args.replace ? "replaced" : "updated"}. Current set:`);
264
282
  for (const uri of info.redirectUris) console.log(` - ${uri}`);
265
283
  }
284
+ async function runDelete(args) {
285
+ await deleteClient({
286
+ incortaUrl: args.url,
287
+ tenant: args.tenant,
288
+ clientId: args.client,
289
+ registrationAccessToken: args.rat
290
+ });
291
+ console.log(`Client ${args.client} deleted.`);
292
+ }
266
293
  var COMMANDS = {
267
294
  register: { required: ["url", "tenant", "name"], needsRedirect: true, run: runRegister },
268
295
  show: { required: ["url", "tenant", "client", "rat"], needsRedirect: false, run: runShow },
@@ -270,7 +297,8 @@ var COMMANDS = {
270
297
  required: ["url", "tenant", "client", "rat"],
271
298
  needsRedirect: true,
272
299
  run: runUpdateRedirects
273
- }
300
+ },
301
+ delete: { required: ["url", "tenant", "client", "rat"], needsRedirect: false, run: runDelete }
274
302
  };
275
303
  async function main() {
276
304
  const [command, ...rest] = process.argv.slice(2);
@@ -36,6 +36,16 @@ interface IncortaAuthConfig {
36
36
  * or `http://localhost:8080/incorta`. Defaults to `INCORTA_URL`.
37
37
  */
38
38
  incortaUrl?: string;
39
+ /**
40
+ * Split-horizon override: base URL this SERVER should use to reach Incorta
41
+ * when the public `incortaUrl` is not routable from where the app runs
42
+ * (containers, cluster-internal DNS) — e.g.
43
+ * `http://host.docker.internal:8080/incorta` from a local container. Only
44
+ * server-to-server calls (discovery, token exchange, JWKS) use it; browser
45
+ * redirects and token `iss` validation stay on the public `incortaUrl`.
46
+ * Defaults to `INCORTA_INTERNAL_URL`; unset means no rewrite.
47
+ */
48
+ internalIncortaUrl?: string;
39
49
  /** Incorta tenant name, e.g. `demo`. Defaults to `INCORTA_TENANT`. */
40
50
  tenant?: string;
41
51
  /** OAuth client id (see `incorta-auth register`). Defaults to `INCORTA_CLIENT_ID`. */
@@ -75,6 +85,25 @@ interface IncortaAuthConfig {
75
85
  * @default "auto"
76
86
  */
77
87
  secure?: boolean | "auto";
88
+ /**
89
+ * `SameSite` for the session/state cookies. The `"lax"` default is right
90
+ * for top-level apps. Set `"none"` when the app runs inside a CROSS-SITE
91
+ * iframe (e.g. an embedded preview) — Lax cookies are never sent there,
92
+ * which loops the login flow forever. `"none"` forces the `Secure`
93
+ * attribute (browsers require the pair; Chrome still accepts it on
94
+ * trustworthy plain-http origins like `*.localhost`). Defaults to
95
+ * `INCORTA_COOKIE_SAMESITE`.
96
+ * @default "lax"
97
+ */
98
+ sameSite?: "lax" | "none";
99
+ /**
100
+ * CHIPS: adds the `Partitioned` attribute, giving each embedding
101
+ * top-level site its own cookie jar — what modern Chrome requires for
102
+ * cookies in third-party iframes. Use together with `sameSite: "none"`.
103
+ * Defaults to `INCORTA_COOKIE_PARTITIONED` (`1`/`true`).
104
+ * @default false
105
+ */
106
+ partitioned?: boolean;
78
107
  };
79
108
  /**
80
109
  * Expose `GET {basePath}/token` returning the raw Incorta access token to the
@@ -94,6 +123,8 @@ interface IncortaAuthConfig {
94
123
  }
95
124
  interface ResolvedConfig {
96
125
  incortaUrl: string;
126
+ /** Server-side fetch base when set (split horizon) — see the config doc. */
127
+ internalIncortaUrl?: string;
97
128
  tenant: string;
98
129
  clientId: string;
99
130
  clientSecret: string;
@@ -105,6 +136,8 @@ interface ResolvedConfig {
105
136
  stateCookieName: string;
106
137
  sessionMaxAge: number;
107
138
  secureCookies: boolean | "auto";
139
+ cookieSameSite: "lax" | "none";
140
+ cookiePartitioned: boolean;
108
141
  exposeAccessToken: boolean;
109
142
  /** `{incortaUrl}/oauth/{tenant}` — the OIDC issuer for this tenant. */
110
143
  issuer: string;
@@ -36,6 +36,16 @@ interface IncortaAuthConfig {
36
36
  * or `http://localhost:8080/incorta`. Defaults to `INCORTA_URL`.
37
37
  */
38
38
  incortaUrl?: string;
39
+ /**
40
+ * Split-horizon override: base URL this SERVER should use to reach Incorta
41
+ * when the public `incortaUrl` is not routable from where the app runs
42
+ * (containers, cluster-internal DNS) — e.g.
43
+ * `http://host.docker.internal:8080/incorta` from a local container. Only
44
+ * server-to-server calls (discovery, token exchange, JWKS) use it; browser
45
+ * redirects and token `iss` validation stay on the public `incortaUrl`.
46
+ * Defaults to `INCORTA_INTERNAL_URL`; unset means no rewrite.
47
+ */
48
+ internalIncortaUrl?: string;
39
49
  /** Incorta tenant name, e.g. `demo`. Defaults to `INCORTA_TENANT`. */
40
50
  tenant?: string;
41
51
  /** OAuth client id (see `incorta-auth register`). Defaults to `INCORTA_CLIENT_ID`. */
@@ -75,6 +85,25 @@ interface IncortaAuthConfig {
75
85
  * @default "auto"
76
86
  */
77
87
  secure?: boolean | "auto";
88
+ /**
89
+ * `SameSite` for the session/state cookies. The `"lax"` default is right
90
+ * for top-level apps. Set `"none"` when the app runs inside a CROSS-SITE
91
+ * iframe (e.g. an embedded preview) — Lax cookies are never sent there,
92
+ * which loops the login flow forever. `"none"` forces the `Secure`
93
+ * attribute (browsers require the pair; Chrome still accepts it on
94
+ * trustworthy plain-http origins like `*.localhost`). Defaults to
95
+ * `INCORTA_COOKIE_SAMESITE`.
96
+ * @default "lax"
97
+ */
98
+ sameSite?: "lax" | "none";
99
+ /**
100
+ * CHIPS: adds the `Partitioned` attribute, giving each embedding
101
+ * top-level site its own cookie jar — what modern Chrome requires for
102
+ * cookies in third-party iframes. Use together with `sameSite: "none"`.
103
+ * Defaults to `INCORTA_COOKIE_PARTITIONED` (`1`/`true`).
104
+ * @default false
105
+ */
106
+ partitioned?: boolean;
78
107
  };
79
108
  /**
80
109
  * Expose `GET {basePath}/token` returning the raw Incorta access token to the
@@ -94,6 +123,8 @@ interface IncortaAuthConfig {
94
123
  }
95
124
  interface ResolvedConfig {
96
125
  incortaUrl: string;
126
+ /** Server-side fetch base when set (split horizon) — see the config doc. */
127
+ internalIncortaUrl?: string;
97
128
  tenant: string;
98
129
  clientId: string;
99
130
  clientSecret: string;
@@ -105,6 +136,8 @@ interface ResolvedConfig {
105
136
  stateCookieName: string;
106
137
  sessionMaxAge: number;
107
138
  secureCookies: boolean | "auto";
139
+ cookieSameSite: "lax" | "none";
140
+ cookiePartitioned: boolean;
108
141
  exposeAccessToken: boolean;
109
142
  /** `{incortaUrl}/oauth/{tenant}` — the OIDC issuer for this tenant. */
110
143
  issuer: string;
@@ -1,6 +1,6 @@
1
1
  import { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { AuthSession, IncortaAuth } from './index.cjs';
3
- import './config-B-2cbZDV.cjs';
3
+ import './config-CHNLJVau.cjs';
4
4
  import 'jose';
5
5
 
6
6
  /**
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
- import './config-B-2cbZDV.js';
3
+ import './config-CHNLJVau.js';
4
4
  import 'jose';
5
5
 
6
6
  /**
package/dist/index.cjs CHANGED
@@ -22,6 +22,7 @@ var src_exports = {};
22
22
  __export(src_exports, {
23
23
  IncortaAuthError: () => IncortaAuthError,
24
24
  createIncortaAuth: () => createIncortaAuth,
25
+ deleteClient: () => deleteClient,
25
26
  getClient: () => getClient,
26
27
  registerClient: () => registerClient,
27
28
  updateClientRedirectUris: () => updateClientRedirectUris
@@ -45,6 +46,7 @@ function resolveConfig(input = {}) {
45
46
  const incortaUrl = stripTrailingSlash(
46
47
  required(input.incortaUrl ?? env.INCORTA_URL, "incortaUrl", "INCORTA_URL")
47
48
  );
49
+ const internalIncortaUrl = input.internalIncortaUrl ?? env.INCORTA_INTERNAL_URL;
48
50
  const tenant = required(input.tenant ?? env.INCORTA_TENANT, "tenant", "INCORTA_TENANT");
49
51
  const clientId = required(
50
52
  input.clientId ?? env.INCORTA_CLIENT_ID,
@@ -75,8 +77,18 @@ function resolveConfig(input = {}) {
75
77
  const appUrl = input.appUrl ?? env.INCORTA_APP_URL;
76
78
  const cookieName = input.session?.cookieName ?? "incorta_auth_session";
77
79
  const issuer = `${incortaUrl}/oauth/${tenant}`;
80
+ const envSameSite = env.INCORTA_COOKIE_SAMESITE?.toLowerCase();
81
+ const cookieSameSite = input.cookies?.sameSite ?? (envSameSite === "none" || envSameSite === "lax" ? envSameSite : "lax");
82
+ const envPartitioned = env.INCORTA_COOKIE_PARTITIONED?.toLowerCase();
83
+ const cookiePartitioned = input.cookies?.partitioned ?? (envPartitioned === "1" || envPartitioned === "true");
84
+ if (cookiePartitioned && cookieSameSite !== "none") {
85
+ throw new Error(
86
+ '[incorta-auth] `cookies.partitioned` requires `cookies.sameSite: "none"` \u2014 browsers only partition cross-site cookies.'
87
+ );
88
+ }
78
89
  return {
79
90
  incortaUrl,
91
+ internalIncortaUrl: internalIncortaUrl ? stripTrailingSlash(internalIncortaUrl) : void 0,
80
92
  tenant,
81
93
  clientId,
82
94
  clientSecret,
@@ -88,6 +100,8 @@ function resolveConfig(input = {}) {
88
100
  stateCookieName: `${cookieName}_state`,
89
101
  sessionMaxAge: input.session?.maxAge ?? 12 * 60 * 60,
90
102
  secureCookies: input.cookies?.secure ?? "auto",
103
+ cookieSameSite,
104
+ cookiePartitioned,
91
105
  exposeAccessToken: input.exposeAccessToken ?? false,
92
106
  issuer,
93
107
  discoveryUrl: input.advanced?.discoveryUrl ?? `${issuer}/.well-known/openid-configuration`,
@@ -129,6 +143,7 @@ function serializeCookie(name, value, options = {}) {
129
143
  if (options.secure) parts.push("Secure");
130
144
  const sameSite = options.sameSite ?? "lax";
131
145
  parts.push(`SameSite=${sameSite.charAt(0).toUpperCase()}${sameSite.slice(1)}`);
146
+ if (options.partitioned) parts.push("Partitioned");
132
147
  return parts.join("; ");
133
148
  }
134
149
  function deleteCookie(name, options = {}) {
@@ -138,14 +153,20 @@ function deleteCookie(name, options = {}) {
138
153
  // src/core/discovery.ts
139
154
  var import_jose = require("jose");
140
155
  var endpointsCache = /* @__PURE__ */ new Map();
156
+ function toInternal(config, url) {
157
+ const internal = config.internalIncortaUrl;
158
+ if (!internal || !url.startsWith(config.incortaUrl)) return url;
159
+ return internal + url.slice(config.incortaUrl.length);
160
+ }
141
161
  function getEndpoints(config) {
142
- const cached = endpointsCache.get(config.issuer);
162
+ const cacheKey = `${config.issuer}|${config.internalIncortaUrl ?? ""}`;
163
+ const cached = endpointsCache.get(cacheKey);
143
164
  if (cached) return cached;
144
165
  const promise = resolve(config).catch((error) => {
145
- endpointsCache.delete(config.issuer);
166
+ endpointsCache.delete(cacheKey);
146
167
  throw error;
147
168
  });
148
- endpointsCache.set(config.issuer, promise);
169
+ endpointsCache.set(cacheKey, promise);
149
170
  return promise;
150
171
  }
151
172
  async function resolve(config) {
@@ -157,7 +178,7 @@ async function resolve(config) {
157
178
  };
158
179
  let doc = fallback;
159
180
  try {
160
- const response = await config.fetch(config.discoveryUrl, {
181
+ const response = await config.fetch(toInternal(config, config.discoveryUrl), {
161
182
  headers: { accept: "application/json" }
162
183
  });
163
184
  if (response.ok) {
@@ -171,14 +192,14 @@ async function resolve(config) {
171
192
  }
172
193
  } catch {
173
194
  }
174
- const jwks = config.jwksOverride ?? (0, import_jose.createRemoteJWKSet)(new URL(doc.jwks_uri), {
195
+ const jwks = config.jwksOverride ?? (0, import_jose.createRemoteJWKSet)(new URL(toInternal(config, doc.jwks_uri)), {
175
196
  cacheMaxAge: 10 * 60 * 1e3,
176
197
  cooldownDuration: 30 * 1e3
177
198
  });
178
199
  return {
179
200
  issuer: doc.issuer,
180
201
  authorizationEndpoint: doc.authorization_endpoint,
181
- tokenEndpoint: doc.token_endpoint,
202
+ tokenEndpoint: toInternal(config, doc.token_endpoint),
182
203
  jwks
183
204
  };
184
205
  }
@@ -393,8 +414,12 @@ function randomToken() {
393
414
  function createHandler(config) {
394
415
  const sessionCookieOptions = (secure) => ({
395
416
  httpOnly: true,
396
- secure,
397
- sameSite: "lax",
417
+ // SameSite=None requires Secure — force the pair even on plain http
418
+ // (Chrome accepts Secure cookies from trustworthy origins like
419
+ // `*.localhost`; real cross-site embedding needs https anyway).
420
+ secure: config.cookieSameSite === "none" ? true : secure,
421
+ sameSite: config.cookieSameSite,
422
+ partitioned: config.cookiePartitioned,
398
423
  path: "/"
399
424
  });
400
425
  async function login(request, url) {
@@ -697,6 +722,19 @@ async function updateClientRedirectUris(options) {
697
722
  }
698
723
  return getClient(options);
699
724
  }
725
+ async function deleteClient(options) {
726
+ const { incortaUrl, tenant, clientId, registrationAccessToken, fetchImpl = fetch } = options;
727
+ const response = await fetchImpl(registrationEndpoint(incortaUrl, tenant, clientId), {
728
+ method: "DELETE",
729
+ headers: {
730
+ accept: "application/json",
731
+ authorization: `Bearer ${registrationAccessToken}`
732
+ }
733
+ });
734
+ if (!response.ok) {
735
+ await readJson(response, "Deleting the client");
736
+ }
737
+ }
700
738
 
701
739
  // src/index.ts
702
740
  function createIncortaAuth(config = {}) {
@@ -708,6 +746,7 @@ function createIncortaAuth(config = {}) {
708
746
  0 && (module.exports = {
709
747
  IncortaAuthError,
710
748
  createIncortaAuth,
749
+ deleteClient,
711
750
  getClient,
712
751
  registerClient,
713
752
  updateClientRedirectUris