@k9o/gate-react-native 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
@@ -3,6 +3,9 @@
3
3
  Kilimanjaro Gate for React Native. Sign end users in with email and password and refresh their session.
4
4
  The refresh token stays in the device keychain.
5
5
 
6
+ Kilimanjaro is the AI-native foundation for building critical applications. Its products are Keep (AI Security and Governance),
7
+ Pulse (Data Intelligence), Gate (Customer Identity and Access Management) and Kibo (Keys and Secrets Management).
8
+
6
9
  ## Contents
7
10
 
8
11
  - [Links](#links)
@@ -13,6 +16,11 @@ The refresh token stays in the device keychain.
13
16
  - [Custom claims](#custom-claims)
14
17
  - [Handle errors](#handle-errors)
15
18
  - [Appendix](#appendix)
19
+ - [Requirements](#requirements)
20
+ - [Client options](#client-options)
21
+ - [Methods](#methods)
22
+ - [Error codes](#error-codes)
23
+ - [Related packages](#related-packages)
16
24
 
17
25
  ## Links
18
26
 
@@ -20,11 +28,6 @@ The refresh token stays in the device keychain.
20
28
  - Documentation / Developer Hub: <https://docs.kilimanjaro.dev>
21
29
  - Platform API: <https://api.k9o.io>
22
30
  - Platform Console: <https://console.k9o.io>
23
- - [Requirements](#requirements)
24
- - [Client options](#client-options)
25
- - [Methods](#methods)
26
- - [Error codes](#error-codes)
27
- - [Related packages](#related-packages)
28
31
 
29
32
  ## Install
30
33
 
@@ -65,15 +68,18 @@ The result has a `user` and a `session`. See [methods](#methods).
65
68
 
66
69
  ## Refresh and sign out
67
70
 
68
- With no arguments, `refreshToken` reads the stored token and saves the new one.
71
+ With no arguments, `refreshToken` reads the stored refresh token and returns a new access token.
72
+ The refresh token is not rotated, so the keychain is left as it is.
69
73
 
70
74
  ```ts
71
75
  const refreshed = await gate.refreshToken();
72
76
 
73
- console.log(refreshed.session.expiresAt);
77
+ console.log(refreshed.accessToken.length > 0);
78
+ console.log(refreshed.expiresAt);
74
79
  ```
75
80
 
76
81
  ```text
82
+ true
77
83
  2026-01-15T11:30:00.000Z
78
84
  ```
79
85
 
@@ -118,7 +124,8 @@ const gate = new GateClient({
118
124
 
119
125
  ## Custom claims
120
126
 
121
- Claims added by extensions appear on the access token under their namespace. Decoding does not verify the signature.
127
+ The token carries each extension claim as a flat key, `ext.<namespace>.<name>`.
128
+ `customClaims` groups them as `{ namespace: { name: value } }`. Decoding does not verify the signature.
122
129
 
123
130
  ```ts
124
131
  import { AccessToken } from "@k9o/gate-react-native";
@@ -181,7 +188,7 @@ See [error codes](#error-codes) for all of them.
181
188
 
182
189
  | Option | Required | Meaning |
183
190
  |---|---|---|
184
- | `orgBaseUrl` | Yes | Your organization's API host, such as `https://acme.api.k9o.io` |
191
+ | `orgBaseUrl` | Yes | Your organization's API host, such as `https://acme.api.k9o.io`; any apex works |
185
192
  | `projectSlug` | Yes | The project users sign in to |
186
193
  | `tokenStore` | No | Where the refresh token is kept; the keychain by default |
187
194
 
@@ -190,14 +197,15 @@ See [error codes](#error-codes) for all of them.
190
197
  | Method | What it does |
191
198
  |---|---|
192
199
  | `signIn({ email, password })` | Sign in; resolves to `{ user, session }` |
193
- | `refreshToken({ refreshToken?, projectHint? })` | Rotate the session; uses the stored token when none is passed |
194
- | `signOut({ refreshToken? })` | End the session and clear the stored token |
200
+ | `refreshToken({ refreshToken? })` | Resolves to `{ accessToken, expiresAt }`; uses the stored token when none is passed |
201
+ | `signOut({ refreshToken? })` | Revoke the session and clear the stored token; does nothing without a token |
195
202
  | `AccessToken.decode(jwt)` | Read an access token's claims; `null` if it is not a JWT |
196
203
 
197
204
  | Result field | Contents |
198
205
  |---|---|
199
206
  | `user` | `id`, `email`, `status`, `roles`, `groups`, timestamps |
200
207
  | `session` | `accessToken`, `refreshToken`, `expiresAt` |
208
+ | refresh result | `accessToken`, `expiresAt` |
201
209
 
202
210
  ### Error codes
203
211
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k9o/gate-react-native",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Kilimanjaro Gate IAK SDK for React Native",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -1,5 +1,6 @@
1
1
  import { GateError, gateErrorFromResponse } from "./GateError";
2
2
  import { parseEnvelope } from "./Models";
3
+ import { resolveRegionalOrigin } from "./region";
3
4
 
4
5
  /**
5
6
  * Internal fetch-based transport layer for @k9o/gate-react-native.
@@ -8,25 +9,38 @@ import { parseEnvelope } from "./Models";
8
9
  export class GateAuthClient {
9
10
  private readonly baseUrl: string;
10
11
  private readonly projectSlug: string;
12
+ private origin: Promise<string> | null = null;
11
13
 
12
14
  constructor(orgBaseUrl: string, projectSlug: string) {
13
15
  this.baseUrl = orgBaseUrl.replace(/\/$/, "");
14
16
  this.projectSlug = projectSlug;
15
17
  }
16
18
 
17
- private url(suffix: string): string {
18
- return `${this.baseUrl}/@${this.projectSlug}/gate/${suffix}`;
19
+ /** The origin credentials go to: the regional host when the base URL is a region-free org host, else the base URL. */
20
+ private resolveOrigin(): Promise<string> {
21
+ if (this.origin === null) {
22
+ const p = resolveRegionalOrigin(this.baseUrl).then((r) => {
23
+ if (!r.cacheable) this.origin = null;
24
+ return r.origin;
25
+ });
26
+ this.origin = p;
27
+ }
28
+ return this.origin;
29
+ }
30
+
31
+ private async url(suffix: string): Promise<string> {
32
+ return `${await this.resolveOrigin()}/@${this.projectSlug}/gate/${suffix}`;
19
33
  }
20
34
 
21
35
  async post<T>(path: string, body: Record<string, unknown>): Promise<T> {
22
36
  let response: Response;
23
37
  try {
24
- response = await fetch(this.url(path), {
38
+ response = await fetch(await this.url(path), {
25
39
  method: "POST",
26
40
  headers: {
27
41
  "Content-Type": "application/json",
28
42
  Accept: "application/json",
29
- "User-Agent": "k9o-react-native-sdk/0.3.0",
43
+ "User-Agent": "k9o-react-native-sdk",
30
44
  },
31
45
  body: JSON.stringify(body),
32
46
  });
package/src/GateClient.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import { GateError } from "./GateError";
2
- import { AuthResult } from "./Models";
2
+ import { AuthResult, RefreshResult } from "./Models";
3
3
  import { GateAuthClient } from "./GateAuthClient";
4
4
  import { KeychainTokenStore, TokenStore } from "./KeychainTokenStore";
5
5
 
6
6
  /**
7
- * Kilimanjaro Gate IAK React Native SDK — v0.3.0
7
+ * Kilimanjaro Gate React Native SDK
8
8
  *
9
9
  * `GateClient` is the primary entry point. Initialise once per project
10
- * and call `signIn`, `refreshToken`, or `signOut`.
10
+ * and call `signIn`, `refreshToken`, or `signOut`. `orgBaseUrl` is the org's gateway host; a region-free host
11
+ * (`<org>.api.<apex>`) is resolved once to its regional host before credentials are sent.
11
12
  *
12
13
  * Refresh tokens are automatically persisted via `tokenStore` (defaults
13
14
  * to `KeychainTokenStore` backed by `react-native-keychain`).
@@ -58,28 +59,17 @@ export class GateClient {
58
59
  }
59
60
 
60
61
  /**
61
- * Rotate the access token using the stored (or supplied) refresh token.
62
+ * Get a new access token using the stored (or supplied) refresh token.
62
63
  *
63
- * If `refreshToken` is omitted, loads the token from the store.
64
- * Updates the store with the new refresh token on success.
65
- *
66
- * `projectHint` is optional; omit for most configurations.
64
+ * If `refreshToken` is omitted, loads the token from the store. The gateway answers a new `accessToken` and its
65
+ * `expiresAt` only; the refresh token stays valid, so the store is left as it is.
67
66
  *
68
67
  * Mirrors: `POST /@<projectSlug>/gate/refresh`
69
68
  */
70
- async refreshToken(input?: {
71
- refreshToken?: string;
72
- projectHint?: string;
73
- }): Promise<AuthResult> {
69
+ async refreshToken(input?: { refreshToken?: string }): Promise<RefreshResult> {
74
70
  const token = input?.refreshToken ?? (await this.tokenStore.load());
75
71
  if (!token) throw GateError.unauthenticated();
76
-
77
- const body: Record<string, unknown> = { refreshToken: token };
78
- if (input?.projectHint) body["projectHint"] = input.projectHint;
79
-
80
- const result = await this.client.post<AuthResult>("refresh", body);
81
- await this.tokenStore.save(result.session.refreshToken);
82
- return result;
72
+ return this.client.post<RefreshResult>("refresh", { refreshToken: token });
83
73
  }
84
74
 
85
75
  /**
@@ -93,7 +83,7 @@ export class GateClient {
93
83
  async signOut(input?: { refreshToken?: string }): Promise<void> {
94
84
  const token = input?.refreshToken ?? (await this.tokenStore.load());
95
85
  if (!token) return;
96
- await this.client.post<Record<string, never>>("signout", { refreshToken: token });
86
+ await this.client.post<{ revoked: boolean }>("signout", { refreshToken: token });
97
87
  await this.tokenStore.delete();
98
88
  }
99
89
  }
package/src/GateError.ts CHANGED
@@ -67,10 +67,9 @@ export class GateError extends Error {
67
67
 
68
68
  // MARK: - Wire error parsing
69
69
 
70
- // The gateway emits a KError at the ROOT of the error body:
71
- // `{ code, message, context?, recommendation? }` — NOT wrapped in `{ error: … }`.
72
- // Codes are platform-namespaced (`K9O_*`), so the
73
- // prefix is stripped before matching the SDK's typed enum.
70
+ // The gateway emits a KError at the ROOT of the error body: `{ code, message, context?, recommendation? }`, not
71
+ // wrapped in `{ error: … }`. Codes are platform-namespaced (`K9O_*`), so the prefix is stripped before matching
72
+ // the SDK's typed enum.
74
73
  interface WireKError {
75
74
  code?: string;
76
75
  message?: string;
package/src/Models.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Core model types for @k9o/gate-react-native.
3
- * Mirror the @k9o/gate TypeScript types verbatim for cross-SDK ergonomic parity.
3
+ * Mirror the @k9o/gate TypeScript types.
4
4
  */
5
5
 
6
6
  /** Project-level end-user identity. IDs carry the `eu_` prefix. */
@@ -15,7 +15,7 @@ export interface EndUser {
15
15
  groups: readonly string[];
16
16
  }
17
17
 
18
- /** Session tokens returned by sign-in, sign-up, or refresh flows. */
18
+ /** Session tokens returned by sign-in. */
19
19
  export interface Session {
20
20
  accessToken: string;
21
21
  refreshToken: string;
@@ -23,13 +23,20 @@ export interface Session {
23
23
  expiresAt: string;
24
24
  }
25
25
 
26
- /** Returned by signIn and refreshToken. */
26
+ /** Returned by signIn. */
27
27
  export interface AuthResult {
28
28
  user: EndUser;
29
29
  session: Session;
30
30
  }
31
31
 
32
- /** Standard response envelope. */
32
+ /** Returned by refreshToken: a new access token only. The refresh token in hand stays valid. */
33
+ export interface RefreshResult {
34
+ accessToken: string;
35
+ /** ISO 8601 instant the new access token expires. */
36
+ expiresAt: string;
37
+ }
38
+
39
+ /** The gateway's response envelope. */
33
40
  interface Envelope<T> {
34
41
  data: T;
35
42
  meta?: Record<string, unknown>;
@@ -52,9 +59,7 @@ export function parseEnvelope<T>(body: string): T {
52
59
  // MARK: - Lightweight JWT access-token decoder
53
60
 
54
61
  /**
55
- * Lightweight access-token decoder. Decodes the JWT payload without
56
- * signature verification. Exposes `ext.<namespace>.<name>` custom-claim
57
- * flatten from.
62
+ * Lightweight access-token decoder. Decodes the JWT payload without signature verification.
58
63
  */
59
64
  export class AccessToken {
60
65
  readonly subject: string | undefined;
@@ -72,16 +77,19 @@ export class AccessToken {
72
77
  }
73
78
 
74
79
  /**
75
- * Custom claims namespaced under `ext.<namespace>` per.
76
- * `customClaims['partners']?.['partner_role']` → "extension_org_admin"
80
+ * Custom claims, grouped by namespace. The token carries each one as a FLAT key `ext.<namespace>.<name>`, so
81
+ * `ext.partners.partner_role = "extension_org_admin"` reads as
82
+ * `customClaims["partners"]?.["partner_role"]` and gives `"extension_org_admin"`.
77
83
  */
78
84
  get customClaims(): Record<string, Record<string, unknown>> {
79
85
  const result: Record<string, Record<string, unknown>> = {};
80
86
  for (const [key, val] of Object.entries(this.allClaims)) {
81
- if (key.startsWith("ext.") && val !== null && typeof val === "object") {
82
- const ns = key.slice("ext.".length);
83
- result[ns] = val as Record<string, unknown>;
84
- }
87
+ if (!key.startsWith("ext.")) continue;
88
+ const rest = key.slice("ext.".length);
89
+ const dot = rest.indexOf(".");
90
+ if (dot <= 0 || dot === rest.length - 1) continue;
91
+ const ns = rest.slice(0, dot);
92
+ (result[ns] ??= {})[rest.slice(dot + 1)] = val;
85
93
  }
86
94
  return result;
87
95
  }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { GateClient } from "./GateClient";
2
2
  export { GateError, type GateErrorCode } from "./GateError";
3
3
  export { KeychainTokenStore, type TokenStore } from "./KeychainTokenStore";
4
- export { AccessToken, type AuthResult, type Session, type EndUser } from "./Models";
4
+ export { AccessToken, type AuthResult, type RefreshResult, type Session, type EndUser } from "./Models";
5
+ export { parseApiHost, regionalOriginFromLocation, type ApiHost } from "./region";
package/src/region.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Regional host resolution for React Native.
3
+ *
4
+ * A region-free org host `<org>.api.<rest>` (or `<org>.api-staging.<rest>`) refuses a request that carries a body or an
5
+ * `Authorization` header, so credentials must go to the regional host `<org>.api-<rgn>.<rest>`. The client learns the
6
+ * region with ONE unauthenticated `HEAD` to the configured host and caches the regional origin.
7
+ *
8
+ * Nothing here knows an apex: the host comes from the configured base URL, so a public-cloud, internal-apex,
9
+ * air-gapped or localhost deployment all work. Only a host shaped `<org>.api.<rest>` is probed; any other base URL is
10
+ * used exactly as given. The regional origin is trusted only if it is `https`, has no userinfo or port, and is exactly
11
+ * the same org, gateway family and apex with a region code. Anything else is ignored, so a hostile redirect can never
12
+ * receive a credential.
13
+ *
14
+ * React Native's `fetch` follows redirects itself and does not honour `redirect: "manual"`, so the probe reads either the
15
+ * `308` `Location` (where the runtime exposes it) or the final `response.url` after the redirect was followed.
16
+ *
17
+ * URLs are read with a small parser rather than the `URL` class, which React Native only partly implements.
18
+ */
19
+
20
+ export type ApiHost = {
21
+ /** First label of an org host; null for a platform host (`api.<apex>`). */
22
+ orgSlug: string | null;
23
+ family: "api" | "api-staging";
24
+ /** 4-character region code when the host is regional (`api-de01`), else null. */
25
+ region: string | null;
26
+ /** Everything after the gateway label. */
27
+ apex: string;
28
+ };
29
+
30
+ const API_LABEL = /^(api|api-staging)(?:-([a-z]{2}\d{2}))?$/;
31
+ const SLUG_RE = /^[a-z][a-z0-9-]{1,62}$/;
32
+
33
+ /** Read the org slug and region out of a gateway host without knowing its apex; null for any other shape. */
34
+ export function parseApiHost(host: string): ApiHost | null {
35
+ const labels = host.toLowerCase().split(".");
36
+ if (labels.some((l) => l.length === 0)) return null;
37
+ const mk = (gatewayLabel: string, orgSlug: string | null, rest: string[]): ApiHost | null => {
38
+ const m = API_LABEL.exec(gatewayLabel);
39
+ if (m === null || rest.length === 0) return null;
40
+ return { orgSlug, family: m[1] as "api" | "api-staging", region: m[2] ?? null, apex: rest.join(".") };
41
+ };
42
+ if (labels.length >= 3 && SLUG_RE.test(labels[0]!) && !labels[0]!.endsWith("-") && API_LABEL.test(labels[1]!)) {
43
+ return mk(labels[1]!, labels[0]!, labels.slice(2));
44
+ }
45
+ if (labels.length >= 2 && API_LABEL.test(labels[0]!)) return mk(labels[0]!, null, labels.slice(1));
46
+ return null;
47
+ }
48
+
49
+ type Origin = { protocol: string; userinfo: boolean; hostname: string; port: string };
50
+
51
+ /** Parse an absolute http(s) URL's origin parts; null when it is not one. */
52
+ function parseOrigin(url: string): Origin | null {
53
+ const m = /^(https?):\/\/([^/?#]*)/i.exec(url);
54
+ if (m === null) return null;
55
+ let authority = m[2]!;
56
+ const at = authority.lastIndexOf("@");
57
+ const userinfo = at >= 0;
58
+ if (userinfo) authority = authority.slice(at + 1);
59
+ if (authority.startsWith("[")) return null; // IPv6 literal: not a gateway host
60
+ const colon = authority.indexOf(":");
61
+ const hostname = (colon >= 0 ? authority.slice(0, colon) : authority).toLowerCase();
62
+ const port = colon >= 0 ? authority.slice(colon + 1) : "";
63
+ if (hostname.length === 0) return null;
64
+ return { protocol: m[1]!.toLowerCase() + ":", userinfo, hostname, port };
65
+ }
66
+
67
+ /** The regional origin a URL may name for `host`, or null if it is not exactly that. Pure. */
68
+ export function regionalOriginFromLocation(host: string, location: string): string | null {
69
+ const from = parseApiHost(host);
70
+ if (from === null || from.orgSlug === null || from.region !== null) return null;
71
+ const u = parseOrigin(location);
72
+ if (u === null || u.protocol !== "https:" || u.userinfo || u.port !== "") return null;
73
+ const to = parseApiHost(u.hostname);
74
+ if (to === null || to.region === null) return null;
75
+ if (to.orgSlug !== from.orgSlug || to.family !== from.family || to.apex !== from.apex) return null;
76
+ return `https://${u.hostname}`;
77
+ }
78
+
79
+ /** The origin requests go to: the regional origin when the configured one is region-free and redirects, else as given. */
80
+ export async function resolveRegionalOrigin(
81
+ baseUrl: string,
82
+ fetchImpl: typeof fetch = fetch,
83
+ ): Promise<{ origin: string; cacheable: boolean }> {
84
+ const configured = baseUrl.replace(/\/+$/, "");
85
+ const parsed = parseOrigin(configured);
86
+ if (parsed === null) return { origin: configured, cacheable: true };
87
+ const host = parseApiHost(parsed.hostname);
88
+ if (host === null || host.orgSlug === null || host.region !== null) return { origin: configured, cacheable: true };
89
+ try {
90
+ const res = await fetchImpl(`${configured}/`, { method: "HEAD", redirect: "manual", headers: { Accept: "*/*" } });
91
+ const location = res.status === 308 ? res.headers?.get?.("location") ?? null : null;
92
+ const named = location ?? (typeof res.url === "string" && res.url.length > 0 ? res.url : null);
93
+ const origin = named === null ? null : regionalOriginFromLocation(parsed.hostname, named);
94
+ return { origin: origin ?? configured, cacheable: true };
95
+ } catch {
96
+ return { origin: configured, cacheable: false };
97
+ }
98
+ }