@iqauth/sdk 2.6.4 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +173 -1
  2. package/dist/browser-session.d.mts +4 -4
  3. package/dist/browser-session.d.ts +4 -4
  4. package/dist/browser-session.js +212 -46
  5. package/dist/browser-session.mjs +3 -3
  6. package/dist/browser.d.mts +5 -5
  7. package/dist/browser.d.ts +5 -5
  8. package/dist/browser.js +293 -34
  9. package/dist/browser.mjs +5 -5
  10. package/dist/{chunk-BVV54LPI.mjs → chunk-25SSYDIP.mjs} +10 -4
  11. package/dist/{chunk-XAWYUPMO.mjs → chunk-4V7FKOTG.mjs} +242 -22
  12. package/dist/{chunk-6I6RM4MN.mjs → chunk-6PJRLRB4.mjs} +33 -3
  13. package/dist/{chunk-SL3KRS4W.mjs → chunk-CIJORODR.mjs} +23 -1
  14. package/dist/{chunk-LIZYFXH7.mjs → chunk-DFWHSDYQ.mjs} +1 -1
  15. package/dist/chunk-GLXSIGVS.mjs +66 -0
  16. package/dist/{chunk-DJIBN2N7.mjs → chunk-GN37E64I.mjs} +29 -7
  17. package/dist/{chunk-WQWBJSSS.mjs → chunk-HVHNYPDC.mjs} +6 -6
  18. package/dist/chunk-JRDVUWAL.mjs +46 -0
  19. package/dist/{chunk-UNYDG2L4.mjs → chunk-NUO2I65G.mjs} +56 -23
  20. package/dist/{chunk-5T7GHBX6.mjs → chunk-TLET552H.mjs} +36 -0
  21. package/dist/chunk-VYQ3ETCK.mjs +244 -0
  22. package/dist/{chunk-3JULWS6F.mjs → chunk-WCELYTJ3.mjs} +3 -3
  23. package/dist/chunk-WHT6WKTY.mjs +3180 -0
  24. package/dist/{chunk-MKKZULZR.mjs → chunk-WIFG74IK.mjs} +1 -1
  25. package/dist/chunk-WSH4SW7F.mjs +490 -0
  26. package/dist/{chunk-W3F4JYGP.mjs → chunk-ZLJPABB7.mjs} +139 -23
  27. package/dist/cli/index.js +2 -2
  28. package/dist/cli/index.mjs +2 -2
  29. package/dist/{client-BNQe3AgF.d.ts → client-D8L-PaWr.d.mts} +59 -6
  30. package/dist/{client-kYlJFgPv.d.mts → client-DkPL0EPZ.d.ts} +59 -6
  31. package/dist/{doctor-YYNHNMLD.mjs → doctor-JAFXWU3X.mjs} +2 -2
  32. package/dist/errors-Jl1Jtm-6.d.mts +107 -0
  33. package/dist/errors-Jl1Jtm-6.d.ts +107 -0
  34. package/dist/{express-CHpfa7D_.d.ts → express-Budysq4h.d.ts} +2 -2
  35. package/dist/{express-B6_1vBYZ.d.mts → express-DDTA3qV1.d.mts} +2 -2
  36. package/dist/express.d.mts +7 -6
  37. package/dist/express.d.ts +7 -6
  38. package/dist/express.js +563 -85
  39. package/dist/express.mjs +73 -34
  40. package/dist/fastify.d.mts +10 -0
  41. package/dist/fastify.d.ts +10 -0
  42. package/dist/fastify.js +589 -65
  43. package/dist/fastify.mjs +101 -11
  44. package/dist/hono.d.mts +10 -0
  45. package/dist/hono.d.ts +10 -0
  46. package/dist/hono.js +566 -65
  47. package/dist/hono.mjs +78 -11
  48. package/dist/index-Cko-d5po.d.mts +1848 -0
  49. package/dist/index-RNqwEcmY.d.ts +1848 -0
  50. package/dist/index.d.mts +56 -8
  51. package/dist/index.d.ts +56 -8
  52. package/dist/index.js +694 -75
  53. package/dist/index.mjs +30 -10
  54. package/dist/{keys-NLWFAOEM.mjs → keys-6Y776TG2.mjs} +2 -2
  55. package/dist/locales.d.mts +1 -1
  56. package/dist/locales.d.ts +1 -1
  57. package/dist/locales.js +36 -0
  58. package/dist/locales.mjs +1 -1
  59. package/dist/mobile.d.mts +77 -7
  60. package/dist/mobile.d.ts +77 -7
  61. package/dist/mobile.js +307 -46
  62. package/dist/mobile.mjs +98 -3
  63. package/dist/next.d.mts +10 -1
  64. package/dist/next.d.ts +10 -1
  65. package/dist/next.js +596 -205
  66. package/dist/next.mjs +83 -10
  67. package/dist/{provisioningBridge-88xjOS2n.d.mts → provisioningBridge-BXPMZCLe.d.ts} +30 -2
  68. package/dist/{provisioningBridge-DnTfzdZK.d.ts → provisioningBridge-IEycmsgb.d.mts} +30 -2
  69. package/dist/{publishableKey-BaR0HoAH.d.ts → publishableKey-f2kq-rKw.d.mts} +1 -1
  70. package/dist/{publishableKey-BaR0HoAH.d.mts → publishableKey-f2kq-rKw.d.ts} +1 -1
  71. package/dist/react-permissions.d.mts +52 -0
  72. package/dist/react-permissions.d.ts +52 -0
  73. package/dist/react-permissions.js +239 -0
  74. package/dist/react-permissions.mjs +98 -0
  75. package/dist/react.d.mts +9 -1624
  76. package/dist/react.d.ts +9 -1624
  77. package/dist/react.js +882 -73
  78. package/dist/react.mjs +71 -2631
  79. package/dist/{reverify-4UEJXUS6.mjs → reverify-C64QXKJO.mjs} +2 -2
  80. package/dist/server/handlers.d.mts +200 -4
  81. package/dist/server/handlers.d.ts +200 -4
  82. package/dist/server/handlers.js +530 -16
  83. package/dist/server/handlers.mjs +14 -3
  84. package/dist/server.d.mts +171 -8
  85. package/dist/server.d.ts +171 -8
  86. package/dist/server.js +579 -61
  87. package/dist/server.mjs +99 -12
  88. package/dist/service.d.mts +4 -4
  89. package/dist/service.d.ts +4 -4
  90. package/dist/service.js +212 -46
  91. package/dist/service.mjs +3 -3
  92. package/dist/{signIn-CiIBTJIh.d.mts → signIn-CReqfXsh.d.mts} +95 -3
  93. package/dist/{signIn-OCr88Zf8.d.ts → signIn-Cfa1GTpO.d.ts} +95 -3
  94. package/dist/{signIn-4OKLDEIH.mjs → signIn-SHBW6Z4T.mjs} +1 -1
  95. package/dist/test.mjs +3 -3
  96. package/dist/{tokens-DCyzzn8L.d.mts → tokens-9F6ETrzk.d.ts} +9 -2
  97. package/dist/{tokens-aHiGFr_E.d.ts → tokens-B06VtvUi.d.mts} +9 -2
  98. package/dist/{types-DZAflmmq.d.mts → types-Bn8O-OEd.d.mts} +164 -11
  99. package/dist/{types-DZAflmmq.d.ts → types-Bn8O-OEd.d.ts} +164 -11
  100. package/dist/{types-6bNdxesb.d.ts → types-DnU2LhXR.d.mts} +7 -1
  101. package/dist/{types-6bNdxesb.d.mts → types-DnU2LhXR.d.ts} +7 -1
  102. package/dist/webhooks.d.mts +113 -17
  103. package/dist/webhooks.d.ts +113 -17
  104. package/dist/webhooks.js +179 -15
  105. package/dist/webhooks.mjs +7 -1
  106. package/dist/ws.d.mts +2 -2
  107. package/dist/ws.d.ts +2 -2
  108. package/dist/ws.js +80 -30
  109. package/dist/ws.mjs +4 -4
  110. package/docs/error-handling.md +101 -0
  111. package/docs/guides/effective-permissions.md +171 -0
  112. package/docs/guides/invitations.md +65 -0
  113. package/package.json +19 -4
  114. package/dist/chunk-6TDJJER7.mjs +0 -217
  115. package/dist/chunk-UKZLOHZG.mjs +0 -83
  116. package/dist/errors-CDdl24MP.d.mts +0 -52
  117. package/dist/errors-CDdl24MP.d.ts +0 -52
@@ -0,0 +1,171 @@
1
+ # Effective permissions: `useEffectivePermissions` + wildcard utilities
2
+
3
+ Some IQAuth apps have hundreds of permission nodes. Stuffing the full set
4
+ into the JWT `entitlements` claim is impractical — tokens grow past header
5
+ limits and rotation gets expensive. The canonical pattern is:
6
+
7
+ 1. Keep the JWT small. Carry only the **roles** + a *coarse* subset of
8
+ `entitlements` for instant first-paint gating.
9
+ 2. Fetch the **full** effective permission set on demand from the issuer
10
+ and use it for fine-grained UI gating.
11
+ 3. Use the **same** wildcard semantics on the client and the server so the
12
+ two halves can never drift and produce the classic "user can see the
13
+ page but every API call 403s" foot-gun.
14
+
15
+ The SDK ships:
16
+
17
+ - `hasPermission(set, id)` and `expandPermissions(set)` — pure utilities
18
+ re-exported from `@iqauth/sdk`.
19
+ - `useEffectivePermissions({ appKey })` — React hook in `@iqauth/sdk/react/permissions` (separate sub-entry so the main React entry never pulls in TanStack Query unless you actually use this hook). Re-exported from
20
+ `@iqauth/sdk/react`.
21
+
22
+ ## Wildcard semantics
23
+
24
+ Permission ids are dot-separated keys: `metrics`, `metrics.read`,
25
+ `billing.invoices.delete`. Two wildcard forms are supported:
26
+
27
+ | Pattern | Matches |
28
+ | --------------- | ------------------------------------------------------------- |
29
+ | `*` | Every permission id (root wildcard). |
30
+ | `<prefix>.*` | `<prefix>` itself **and** every descendant. |
31
+
32
+ Examples:
33
+
34
+ - `metrics.*` matches `metrics`, `metrics.read`, `metrics.foo.bar`.
35
+ - `metrics.*` does **not** match `metric`, `metricsX`, or
36
+ `billing.metrics`.
37
+ - `*` matches everything, including any wildcard query.
38
+
39
+ Wildcards in the middle of an id (e.g. `metrics.*.read`) are treated as
40
+ literal strings and match nothing useful — keep wildcards at the leaf.
41
+
42
+ `expandPermissions(set)` normalizes a set: dedupes, sorts, and strips
43
+ entries already implied by a broader wildcard. It does **not** enumerate
44
+ descendants of a wildcard (that set is open-ended).
45
+
46
+ ```ts
47
+ import { expandPermissions, hasPermission } from "@iqauth/sdk";
48
+
49
+ expandPermissions(["metrics.*", "metrics.read", "billing.read"]);
50
+ // → ["billing.read", "metrics.*"]
51
+
52
+ hasPermission(["metrics.*"], "metrics.foo.bar"); // true
53
+ hasPermission(["metrics.read"], "metrics.write"); // false
54
+ ```
55
+
56
+ ## When to use the hook vs raw `entitlements`
57
+
58
+ - **JWT `entitlements`** — instant, sync, available the moment the user is
59
+ authenticated. Good for top-level navigation gating where a small,
60
+ hand-picked subset is enough. **Hard size budget: keep `entitlements`
61
+ under ~50 entries to stay well under header limits across proxies.**
62
+ - **`useEffectivePermissions()`** — async, network-backed, returns the
63
+ *complete* per-app set. Good for granular feature gating, action
64
+ buttons, admin-only fields. Cached for 5 minutes per
65
+ `(user, tenant, appKey)`.
66
+
67
+ The hook automatically falls back to `claims.entitlements` while the
68
+ fetch is in flight, so first paint is never blocked.
69
+
70
+ ## React usage
71
+
72
+ The hook is backed by [TanStack Query](https://tanstack.com/query). The
73
+ SDK declares `@tanstack/react-query` as an **optional** peer dependency
74
+ — install it (`npm i @tanstack/react-query`) and mount a
75
+ `QueryClientProvider` somewhere above `<IQAuthProvider/>`:
76
+
77
+ ```tsx
78
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
79
+ import { IQAuthProvider } from "@iqauth/sdk/react";
80
+
81
+ const queryClient = new QueryClient();
82
+
83
+ <QueryClientProvider client={queryClient}>
84
+ <IQAuthProvider publishableKey={…}>
85
+ <App />
86
+ </IQAuthProvider>
87
+ </QueryClientProvider>
88
+ ```
89
+
90
+ Then anywhere inside:
91
+
92
+ ```tsx
93
+ import { SignedIn } from "@iqauth/sdk/react";
94
+ import { useEffectivePermissions } from "@iqauth/sdk/react/permissions";
95
+
96
+ function BillingPanel() {
97
+ const { permissions, hasPermission, isLoading, error, refetch } =
98
+ useEffectivePermissions({ appKey: "iqreuse" });
99
+
100
+ if (error) return <p>Couldn't load permissions: {error.message}</p>;
101
+
102
+ return (
103
+ <section aria-busy={isLoading}>
104
+ {hasPermission("billing.invoices.read") && <InvoiceList />}
105
+ {hasPermission("billing.invoices.write") && <NewInvoiceButton />}
106
+ {/* Wildcards work too — granted via `billing.*` or `*` */}
107
+ {hasPermission("billing.invoices.delete") && <DeleteInvoiceButton />}
108
+ </section>
109
+ );
110
+ }
111
+ ```
112
+
113
+ ### What the hook returns
114
+
115
+ ```ts
116
+ {
117
+ permissions: string[]; // normalized set
118
+ hasPermission: (id: string) => boolean; // wildcard-aware
119
+ isLoading: boolean;
120
+ error: { code: string; message: string } | null;
121
+ refetch: () => Promise<void>; // bypass staleTime
122
+ }
123
+ ```
124
+
125
+ ### Behaviour
126
+
127
+ - **Caching.** Results are cached in-memory for 5 minutes per
128
+ `(issuer, tenantId, userId, appKey)`. Repeat mounts within the window
129
+ do not refetch. Override with `staleTime` (ms).
130
+ - **Single-flight.** Concurrent renders/mounts share one in-flight
131
+ request — you will not see a thundering herd of `/permissions/effective`
132
+ calls when many components mount at once.
133
+ - **No focus refetch.** The hook does **not** refetch on window focus.
134
+ Trigger `refetch()` after a mutation that changes permissions
135
+ (group assignment, role change, override write).
136
+ - **Platform admin short-circuit.** If the JWT carries
137
+ `roles: ["platform_admin", …]` the hook returns `permissions: ["*"]`
138
+ and `hasPermission` always returns `true` without a network round trip.
139
+ - **Entitlements fallback.** While the first fetch is in flight,
140
+ `permissions` is seeded from `claims.entitlements` so first paint isn't
141
+ blocked. The fetch result replaces this on success.
142
+
143
+ ### Endpoint shape
144
+
145
+ The hook calls the existing issuer endpoint:
146
+
147
+ ```
148
+ GET /api/v1/tenants/{tenantId}/users/{userId}/permissions/effective?appKey=<appKey>
149
+ ```
150
+
151
+ It expects an `ApiResponse.success(...)` body whose `data` is an array of
152
+ `{ scope: string; effect: "allow" | "deny"; … }` rows (the same shape
153
+ returned by `permissionService.resolveEffectivePermissions`). Rows with
154
+ `effect === "deny"` are stripped and the remaining `scope` strings are
155
+ fed through `expandPermissions()`.
156
+
157
+ No new server route is required.
158
+
159
+ ### Server-side parity
160
+
161
+ Use the **same** utilities for server-side authorization checks so the
162
+ two halves cannot drift:
163
+
164
+ ```ts
165
+ import { hasPermission } from "@iqauth/sdk";
166
+
167
+ // Inside an Express/Fastify/Hono route handler:
168
+ if (!hasPermission(grantedPermissionSet, "billing.invoices.delete")) {
169
+ return res.status(403).json({ error: { code: "PERMISSION_DENIED" } });
170
+ }
171
+ ```
@@ -33,11 +33,76 @@ const result = await client.invites.create({
33
33
  role: "user",
34
34
  vendorId: "vendor-uuid", // optional
35
35
  products: ["iqcapture"], // optional product access
36
+
37
+ // Optional: auto-land the invitee in your app after they accept
38
+ // (new users only — see "Auto-redirect after accept" below).
39
+ redirectUri: "https://app.example.com/iqauth/callback",
40
+ clientId: "oidc-client-id-for-your-app",
36
41
  });
37
42
  // result.inviteToken — for programmatic flows
38
43
  // In production, user receives email with invite link
39
44
  ```
40
45
 
46
+ ### Auto-redirect after accept (opt-in)
47
+
48
+ Pass `redirectUri` + `clientId` together when creating the invite to have
49
+ the hosted accept page mint an OIDC authorization code and `302` straight
50
+ into your app after acceptance. The inviter app's existing
51
+ `/api/iqauth/callback` adapter (Express / Fastify / Hono / Next) handles
52
+ the code exchange — no SDK code changes required on the consumer side.
53
+
54
+ **Constraints (validated at create time):**
55
+
56
+ - Both fields must be present, or both omitted (half-pairs return `400`).
57
+ - `clientId` must reference an **active OIDC client bound to the same tenant**
58
+ as the invite. Platform-wide clients (`tenant_id IS NULL`) are rejected.
59
+ - `redirectUri` must match one of the client's registered redirect URIs,
60
+ using the same normalizing comparator as `/oidc/authorize` (trailing-slash
61
+ and case-insensitive host).
62
+ - Both checks re-run at accept time; if the client/redirect config drifted
63
+ after the invite was sent, the accept silently falls back to the default
64
+ post-accept landing.
65
+
66
+ **Response shape on `accept()`:**
67
+
68
+ The `redirectTo` key is **omitted from the response entirely** (not `null`) when:
69
+
70
+ - the invite was created without `redirectUri` + `clientId` (byte-for-byte back-compat),
71
+ - the accepting user already had an IQAuth account (`existedUser === true`) — see
72
+ *Known limitations* below,
73
+ - or the client/redirect config changed between create and accept.
74
+
75
+ When present, `redirectTo` is a fully-qualified URL of the form
76
+ `<redirectUri>?code=<oidc_code>&state=<random>`. Treat it as optional and
77
+ only act on it when present.
78
+
79
+ **Known limitations (v1):**
80
+
81
+ - **Existing users.** When the invitee already has an IQAuth account, `redirectTo`
82
+ is suppressed. The accept endpoint is anonymous (the invite token is the only
83
+ credential) — minting an OIDC code for a pre-existing account would be an
84
+ auth-assurance downgrade for any account that has MFA configured.
85
+ - **Multi-membership / scope picker.** Deferred for the same reason.
86
+ - **PKCE / public clients.** The minted code is a confidential-client OIDC code.
87
+ Public-client / SPA flows that require PKCE are out of scope in v1.
88
+
89
+ ### Bulk-invite redirect
90
+
91
+ `/api/v1/invites/bulk` accepts a single bulk-level `redirectUri` + `clientId`
92
+ pair applied to every row, validated once before any rows are created:
93
+
94
+ ```typescript
95
+ await client.invites.createBulk({
96
+ tenantId: "tenant-uuid",
97
+ invitations: [
98
+ { email: "alice@example.com", role: "user" },
99
+ { email: "bob@example.com", role: "user" },
100
+ ],
101
+ redirectUri: "https://app.example.com/iqauth/callback",
102
+ clientId: "oidc-client-id-for-your-app",
103
+ });
104
+ ```
105
+
41
106
  ### 2. Validate Token
42
107
 
43
108
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iqauth/sdk",
3
- "version": "2.6.4",
3
+ "version": "2.8.1",
4
4
  "description": "TypeScript SDK for IQAuth — the canonical way for all IQ projects to integrate with IQAuthService",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -29,6 +29,11 @@
29
29
  "import": "./dist/react.mjs",
30
30
  "require": "./dist/react.js"
31
31
  },
32
+ "./react/permissions": {
33
+ "types": "./dist/react-permissions.d.ts",
34
+ "import": "./dist/react-permissions.mjs",
35
+ "require": "./dist/react-permissions.js"
36
+ },
32
37
  "./server": {
33
38
  "types": "./dist/server.d.ts",
34
39
  "import": "./dist/server.mjs",
@@ -96,8 +101,10 @@
96
101
  "docs"
97
102
  ],
98
103
  "scripts": {
99
- "build": "tsup src/index.ts src/browser-session.ts src/browser.ts src/react.ts src/server.ts src/server/handlers.ts src/express.ts src/fastify.ts src/hono.ts src/next.ts src/mobile.ts src/service.ts src/ws.ts src/test.ts src/webhooks.ts src/locales.ts src/cli/index.ts --format cjs,esm --dts --clean --external react --external react-dom --external next/headers",
100
- "test": "vitest run",
104
+ "build": "tsup src/index.ts src/browser-session.ts src/browser.ts src/react.ts src/react-permissions.ts src/server.ts src/server/handlers.ts src/express.ts src/fastify.ts src/hono.ts src/next.ts src/mobile.ts src/service.ts src/ws.ts src/test.ts src/webhooks.ts src/locales.ts src/cli/index.ts --format cjs,esm --dts --clean --external react --external react-dom --external next/headers --external @tanstack/react-query",
105
+ "test": "vitest run && vitest run --config vitest.dom.config.mts",
106
+ "test:node": "vitest run",
107
+ "test:dom": "vitest run --config vitest.dom.config.mts",
101
108
  "test:watch": "vitest",
102
109
  "test:coverage": "vitest run --coverage",
103
110
  "typecheck": "tsc --noEmit"
@@ -108,7 +115,8 @@
108
115
  },
109
116
  "peerDependencies": {
110
117
  "react": ">=18",
111
- "react-dom": ">=18"
118
+ "react-dom": ">=18",
119
+ "@tanstack/react-query": ">=4"
112
120
  },
113
121
  "peerDependenciesMeta": {
114
122
  "react": {
@@ -116,13 +124,20 @@
116
124
  },
117
125
  "react-dom": {
118
126
  "optional": true
127
+ },
128
+ "@tanstack/react-query": {
129
+ "optional": true
119
130
  }
120
131
  },
121
132
  "devDependencies": {
133
+ "@tanstack/react-query": "^5.60.5",
134
+ "@testing-library/dom": "^10.4.1",
135
+ "@testing-library/react": "^16.3.2",
122
136
  "@types/jsonwebtoken": "^9.0.7",
123
137
  "@types/node": "^20.0.0",
124
138
  "@types/react": "^18.0.0",
125
139
  "@types/react-dom": "^18.0.0",
140
+ "jsdom": "^29.1.1",
126
141
  "react": "^18.0.0",
127
142
  "react-dom": "^18.0.0",
128
143
  "tsup": "^8.0.0",
@@ -1,217 +0,0 @@
1
- import {
2
- assertPublishableKey
3
- } from "./chunk-WQWBJSSS.mjs";
4
-
5
- // src/server/handlers.ts
6
- var TERMINAL_REFRESH_ERROR_CODES = /* @__PURE__ */ new Set([
7
- "TOKEN_REVOKED",
8
- "SESSION_REVOKED",
9
- "INVALID_GRANT",
10
- "invalid_grant",
11
- "USER_DEACTIVATED",
12
- "USER_DISABLED",
13
- "TENANT_SUSPENDED"
14
- ]);
15
- function shouldClearCookiesOnFailure(policy, status, errorCode) {
16
- if (policy === "always") return true;
17
- if (policy === "never") return false;
18
- if (status === 410) return true;
19
- if (errorCode && TERMINAL_REFRESH_ERROR_CODES.has(errorCode)) return true;
20
- return false;
21
- }
22
- var ACCESS_TOKEN_TTL_SECONDS = 60 * 15;
23
- var REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
24
- function resolve(config) {
25
- const parsed = assertPublishableKey(config.publishableKey, { context: "@iqauth/sdk helpers" });
26
- const inferredIssuer = parsed.iss.startsWith("http") ? parsed.iss : `https://${parsed.iss}`;
27
- return {
28
- publishableKey: config.publishableKey,
29
- secretKey: config.secretKey,
30
- issuer: (config.issuer ?? inferredIssuer).replace(/\/+$/, ""),
31
- accessCookieName: config.accessCookieName ?? config.cookieNames?.access ?? "iqauth_at",
32
- refreshCookieName: config.refreshCookieName ?? config.cookieNames?.refresh ?? "iqauth_rt",
33
- cookieDomain: config.cookieDomain,
34
- sameSite: config.sameSite ?? "lax",
35
- secure: config.secure ?? true,
36
- cookiePath: config.cookiePath ?? "/",
37
- tokenPath: config.tokenPath ?? "/oidc/token",
38
- refreshPath: config.refreshPath ?? "/api/v1/auth/refresh",
39
- logoutPath: config.logoutPath ?? "/api/v1/auth/logout",
40
- fetchImpl: config.fetchImpl ?? (typeof fetch !== "undefined" ? fetch.bind(globalThis) : (() => {
41
- throw new Error("global fetch is unavailable; pass fetchImpl");
42
- })),
43
- appId: parsed.appId,
44
- tenantId: parsed.tenantId,
45
- clearCookiesOnRefreshFailure: config.clearCookiesOnRefreshFailure ?? "terminal-only"
46
- };
47
- }
48
- function makeCookie(cfg, name, value, maxAge, httpOnly = true) {
49
- return {
50
- name,
51
- value,
52
- maxAge,
53
- httpOnly,
54
- secure: cfg.secure,
55
- sameSite: cfg.sameSite,
56
- path: cfg.cookiePath,
57
- domain: cfg.cookieDomain
58
- };
59
- }
60
- function clearCookies(cfg) {
61
- return [
62
- makeCookie(cfg, cfg.accessCookieName, "", 0),
63
- makeCookie(cfg, cfg.refreshCookieName, "", 0)
64
- ];
65
- }
66
- function serializeCookie(d) {
67
- const parts = [`${d.name}=${encodeURIComponent(d.value)}`];
68
- parts.push(`Path=${d.path}`);
69
- if (d.domain) parts.push(`Domain=${d.domain}`);
70
- parts.push(`Max-Age=${d.maxAge}`);
71
- if (d.secure) parts.push("Secure");
72
- if (d.httpOnly) parts.push("HttpOnly");
73
- parts.push(`SameSite=${d.sameSite}`);
74
- return parts.join("; ");
75
- }
76
- async function handleCallback(config, input) {
77
- const cfg = resolve(config);
78
- if (!input.code || !input.redirectUri) {
79
- return {
80
- status: 400,
81
- body: { success: false, error: { code: "VALIDATION_ERROR", message: "code and redirectUri are required" } },
82
- cookies: []
83
- };
84
- }
85
- if (!cfg.secretKey) {
86
- return {
87
- status: 500,
88
- body: { success: false, error: { code: "INTERNAL_ERROR", message: "secretKey is required for the callback handler" } },
89
- cookies: []
90
- };
91
- }
92
- const body = new URLSearchParams({
93
- grant_type: "authorization_code",
94
- code: input.code,
95
- redirect_uri: input.redirectUri,
96
- client_id: cfg.appId
97
- });
98
- if (input.codeVerifier) body.set("code_verifier", input.codeVerifier);
99
- const res = await cfg.fetchImpl(`${cfg.issuer}${cfg.tokenPath}`, {
100
- method: "POST",
101
- headers: {
102
- "Content-Type": "application/x-www-form-urlencoded",
103
- Authorization: `Basic ${typeof btoa === "function" ? btoa(`${cfg.appId}:${cfg.secretKey}`) : Buffer.from(`${cfg.appId}:${cfg.secretKey}`).toString("base64")}`
104
- },
105
- body: body.toString()
106
- });
107
- const json = await res.json().catch(() => ({}));
108
- if (!res.ok || !json.access_token) {
109
- return {
110
- status: res.status || 502,
111
- body: {
112
- success: false,
113
- error: {
114
- code: json.error || "OIDC_EXCHANGE_FAILED",
115
- message: json.error_description || "Authorization code exchange failed"
116
- }
117
- },
118
- cookies: []
119
- };
120
- }
121
- const cookies = [];
122
- cookies.push(
123
- makeCookie(cfg, cfg.accessCookieName, json.access_token, json.expires_in ?? ACCESS_TOKEN_TTL_SECONDS)
124
- );
125
- if (json.refresh_token) {
126
- cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.refresh_token, REFRESH_TOKEN_TTL_SECONDS));
127
- }
128
- return {
129
- status: 200,
130
- body: { success: true, data: { authenticated: true } },
131
- cookies
132
- };
133
- }
134
- async function handleRefresh(config, input) {
135
- const cfg = resolve(config);
136
- const refreshToken = input.refreshToken;
137
- if (!refreshToken) {
138
- return {
139
- status: 401,
140
- body: { success: false, error: { code: "TOKEN_INVALID", message: "Missing refresh token" } },
141
- cookies: cfg.clearCookiesOnRefreshFailure === "always" ? clearCookies(cfg) : []
142
- };
143
- }
144
- const res = await cfg.fetchImpl(`${cfg.issuer}${cfg.refreshPath}`, {
145
- method: "POST",
146
- headers: { "Content-Type": "application/json" },
147
- body: JSON.stringify({ refreshToken })
148
- });
149
- const json = await res.json().catch(() => ({}));
150
- if (!res.ok || !json.success || !json.data?.accessToken) {
151
- const status = res.status || 401;
152
- const errorCode = json.error?.code || "TOKEN_INVALID";
153
- const shouldClear = shouldClearCookiesOnFailure(
154
- cfg.clearCookiesOnRefreshFailure,
155
- status,
156
- errorCode
157
- );
158
- return {
159
- status,
160
- body: {
161
- success: false,
162
- error: {
163
- code: errorCode,
164
- message: json.error?.message || "Refresh failed"
165
- }
166
- },
167
- cookies: shouldClear ? clearCookies(cfg) : []
168
- };
169
- }
170
- const cookies = [
171
- makeCookie(cfg, cfg.accessCookieName, json.data.accessToken, ACCESS_TOKEN_TTL_SECONDS)
172
- ];
173
- if (json.data.refreshToken) {
174
- cookies.push(makeCookie(cfg, cfg.refreshCookieName, json.data.refreshToken, REFRESH_TOKEN_TTL_SECONDS));
175
- }
176
- return {
177
- status: 200,
178
- body: { success: true, data: { accessToken: json.data.accessToken } },
179
- cookies
180
- };
181
- }
182
- async function handleSignout(config, input) {
183
- const cfg = resolve(config);
184
- if (input.accessToken) {
185
- try {
186
- await cfg.fetchImpl(`${cfg.issuer}${cfg.logoutPath}`, {
187
- method: "POST",
188
- headers: {
189
- "Content-Type": "application/json",
190
- Authorization: `Bearer ${input.accessToken}`
191
- }
192
- });
193
- } catch {
194
- }
195
- }
196
- if (input.endSsoSession !== false && input.ssoCookieHeader) {
197
- try {
198
- await cfg.fetchImpl(`${cfg.issuer}/oidc/sso-logout`, {
199
- method: "POST",
200
- headers: { Cookie: input.ssoCookieHeader }
201
- });
202
- } catch {
203
- }
204
- }
205
- return {
206
- status: 200,
207
- body: { success: true, data: { signedOut: true } },
208
- cookies: clearCookies(cfg)
209
- };
210
- }
211
-
212
- export {
213
- serializeCookie,
214
- handleCallback,
215
- handleRefresh,
216
- handleSignout
217
- };
@@ -1,83 +0,0 @@
1
- // src/webhooks.ts
2
- import crypto from "crypto";
3
- var WebhookSignatureError = class extends Error {
4
- constructor(code, message) {
5
- super(message);
6
- this.name = "WebhookSignatureError";
7
- this.code = code;
8
- }
9
- };
10
- function toBuffer(p) {
11
- if (typeof p === "string") return Buffer.from(p, "utf8");
12
- if (Buffer.isBuffer(p)) return p;
13
- return Buffer.from(p);
14
- }
15
- function parseHeader(header) {
16
- let t = NaN;
17
- const v1 = [];
18
- for (const part of header.split(",")) {
19
- const [k, v] = part.split("=", 2);
20
- if (!k || v === void 0) continue;
21
- const key = k.trim();
22
- const value = v.trim();
23
- if (key === "t") t = Number(value);
24
- else if (key === "v1") v1.push(value);
25
- }
26
- return { t, v1 };
27
- }
28
- function timingSafeEqualHex(a, b) {
29
- if (a.length !== b.length) return false;
30
- try {
31
- return crypto.timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
32
- } catch {
33
- return false;
34
- }
35
- }
36
- function verifyWebhookSignature(opts) {
37
- const headerRaw = Array.isArray(opts.header) ? opts.header[0] : opts.header;
38
- if (!headerRaw || typeof headerRaw !== "string") {
39
- throw new WebhookSignatureError("MISSING_HEADER", "Missing X-IQAuth-Signature header");
40
- }
41
- if (!opts.secret) {
42
- throw new WebhookSignatureError("MISSING_SECRET", "secret is required");
43
- }
44
- const { t, v1 } = parseHeader(headerRaw);
45
- if (!Number.isFinite(t) || v1.length === 0) {
46
- throw new WebhookSignatureError("MALFORMED_HEADER", `Could not parse signature header: ${headerRaw}`);
47
- }
48
- const tolerance = opts.toleranceSeconds ?? 300;
49
- const now = opts.nowSeconds ?? Math.floor(Date.now() / 1e3);
50
- if (Math.abs(now - t) > tolerance) {
51
- throw new WebhookSignatureError(
52
- "TIMESTAMP_OUT_OF_TOLERANCE",
53
- `Signature timestamp ${t} is outside the ${tolerance}s tolerance window (now=${now})`
54
- );
55
- }
56
- const body = toBuffer(opts.payload);
57
- const expected = crypto.createHmac("sha256", opts.secret).update(`${t}.`).update(body).digest("hex");
58
- const matched = v1.some((sig) => timingSafeEqualHex(sig, expected));
59
- if (!matched) {
60
- throw new WebhookSignatureError("SIGNATURE_MISMATCH", "Webhook signature does not match expected value");
61
- }
62
- let parsed;
63
- try {
64
- parsed = JSON.parse(body.toString("utf8"));
65
- } catch {
66
- throw new WebhookSignatureError("MALFORMED_BODY", "Webhook body is not valid JSON");
67
- }
68
- return parsed;
69
- }
70
- function isValidWebhookSignature(opts) {
71
- try {
72
- verifyWebhookSignature(opts);
73
- return true;
74
- } catch {
75
- return false;
76
- }
77
- }
78
-
79
- export {
80
- WebhookSignatureError,
81
- verifyWebhookSignature,
82
- isValidWebhookSignature
83
- };
@@ -1,52 +0,0 @@
1
- /**
2
- * SOURCE REFS:
3
- * - Route file: src/lib/response.ts (error envelope: { success: false, error: { code, message } })
4
- * - All route files for error code extraction
5
- * - Verified claims: N/A (error module)
6
- * - Last verified: Phase 0 Research Summary
7
- */
8
- declare class IQAuthError extends Error {
9
- code: string;
10
- status?: number;
11
- raw?: unknown;
12
- constructor(code: string, message: string, status?: number, raw?: unknown);
13
- }
14
- declare const ErrorCodes: {
15
- readonly VALIDATION_ERROR: "VALIDATION_ERROR";
16
- readonly INVALID_CREDENTIALS: "INVALID_CREDENTIALS";
17
- readonly ACCOUNT_INACTIVE: "ACCOUNT_INACTIVE";
18
- readonly ACCOUNT_LOCKED: "ACCOUNT_LOCKED";
19
- readonly INSUFFICIENT_PERMISSIONS: "INSUFFICIENT_PERMISSIONS";
20
- readonly TOKEN_INVALID: "TOKEN_INVALID";
21
- readonly TOKEN_EXPIRED: "TOKEN_EXPIRED";
22
- readonly TOKEN_REVOKED: "TOKEN_REVOKED";
23
- readonly USER_INACTIVE: "USER_INACTIVE";
24
- readonly INTERNAL_ERROR: "INTERNAL_ERROR";
25
- readonly NOT_FOUND: "NOT_FOUND";
26
- readonly SESSION_INVALID: "SESSION_INVALID";
27
- readonly SESSION_EXPIRED: "SESSION_EXPIRED";
28
- readonly REFRESH_TOKEN_REUSED: "REFRESH_TOKEN_REUSED";
29
- readonly PASSWORD_EXPIRED: "PASSWORD_EXPIRED";
30
- readonly PIN_EXPIRED: "PIN_EXPIRED";
31
- readonly PASSWORD_POLICY_VIOLATION: "PASSWORD_POLICY_VIOLATION";
32
- readonly MFA_INVALID_CODE: "MFA_INVALID_CODE";
33
- readonly MFA_METHOD_UNAVAILABLE: "MFA_METHOD_UNAVAILABLE";
34
- readonly MFA_RATE_LIMITED: "MFA_RATE_LIMITED";
35
- readonly MFA_ENROLLMENT_REQUIRED: "MFA_ENROLLMENT_REQUIRED";
36
- readonly API_KEY_REQUIRED: "API_KEY_REQUIRED";
37
- readonly API_KEY_INVALID: "API_KEY_INVALID";
38
- readonly AUTH_REQUIRED: "AUTH_REQUIRED";
39
- readonly ALREADY_EXISTS: "ALREADY_EXISTS";
40
- readonly FORBIDDEN: "FORBIDDEN";
41
- readonly OAUTH_NOT_CONFIGURED: "OAUTH_NOT_CONFIGURED";
42
- readonly UPLOAD_ERROR: "UPLOAD_ERROR";
43
- readonly EMAIL_SERVICE_UNAVAILABLE: "EMAIL_SERVICE_UNAVAILABLE";
44
- readonly INVALID_CODE: "INVALID_CODE";
45
- readonly CODE_ALREADY_USED: "CODE_ALREADY_USED";
46
- readonly CODE_EXPIRED: "CODE_EXPIRED";
47
- readonly CODE_IP_MISMATCH: "CODE_IP_MISMATCH";
48
- readonly UNKNOWN_PAYLOAD: "UNKNOWN_PAYLOAD";
49
- };
50
- type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
51
-
52
- export { ErrorCodes as E, IQAuthError as I, type ErrorCode as a };