@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.
- package/README.md +173 -1
- package/dist/browser-session.d.mts +4 -4
- package/dist/browser-session.d.ts +4 -4
- package/dist/browser-session.js +212 -46
- package/dist/browser-session.mjs +3 -3
- package/dist/browser.d.mts +5 -5
- package/dist/browser.d.ts +5 -5
- package/dist/browser.js +293 -34
- package/dist/browser.mjs +5 -5
- package/dist/{chunk-BVV54LPI.mjs → chunk-25SSYDIP.mjs} +10 -4
- package/dist/{chunk-XAWYUPMO.mjs → chunk-4V7FKOTG.mjs} +242 -22
- package/dist/{chunk-6I6RM4MN.mjs → chunk-6PJRLRB4.mjs} +33 -3
- package/dist/{chunk-SL3KRS4W.mjs → chunk-CIJORODR.mjs} +23 -1
- package/dist/{chunk-LIZYFXH7.mjs → chunk-DFWHSDYQ.mjs} +1 -1
- package/dist/chunk-GLXSIGVS.mjs +66 -0
- package/dist/{chunk-DJIBN2N7.mjs → chunk-GN37E64I.mjs} +29 -7
- package/dist/{chunk-WQWBJSSS.mjs → chunk-HVHNYPDC.mjs} +6 -6
- package/dist/chunk-JRDVUWAL.mjs +46 -0
- package/dist/{chunk-UNYDG2L4.mjs → chunk-NUO2I65G.mjs} +56 -23
- package/dist/{chunk-5T7GHBX6.mjs → chunk-TLET552H.mjs} +36 -0
- package/dist/chunk-VYQ3ETCK.mjs +244 -0
- package/dist/{chunk-3JULWS6F.mjs → chunk-WCELYTJ3.mjs} +3 -3
- package/dist/chunk-WHT6WKTY.mjs +3180 -0
- package/dist/{chunk-MKKZULZR.mjs → chunk-WIFG74IK.mjs} +1 -1
- package/dist/chunk-WSH4SW7F.mjs +490 -0
- package/dist/{chunk-W3F4JYGP.mjs → chunk-ZLJPABB7.mjs} +139 -23
- package/dist/cli/index.js +2 -2
- package/dist/cli/index.mjs +2 -2
- package/dist/{client-BNQe3AgF.d.ts → client-D8L-PaWr.d.mts} +59 -6
- package/dist/{client-kYlJFgPv.d.mts → client-DkPL0EPZ.d.ts} +59 -6
- package/dist/{doctor-YYNHNMLD.mjs → doctor-JAFXWU3X.mjs} +2 -2
- package/dist/errors-Jl1Jtm-6.d.mts +107 -0
- package/dist/errors-Jl1Jtm-6.d.ts +107 -0
- package/dist/{express-CHpfa7D_.d.ts → express-Budysq4h.d.ts} +2 -2
- package/dist/{express-B6_1vBYZ.d.mts → express-DDTA3qV1.d.mts} +2 -2
- package/dist/express.d.mts +7 -6
- package/dist/express.d.ts +7 -6
- package/dist/express.js +563 -85
- package/dist/express.mjs +73 -34
- package/dist/fastify.d.mts +10 -0
- package/dist/fastify.d.ts +10 -0
- package/dist/fastify.js +589 -65
- package/dist/fastify.mjs +101 -11
- package/dist/hono.d.mts +10 -0
- package/dist/hono.d.ts +10 -0
- package/dist/hono.js +566 -65
- package/dist/hono.mjs +78 -11
- package/dist/index-Cko-d5po.d.mts +1848 -0
- package/dist/index-RNqwEcmY.d.ts +1848 -0
- package/dist/index.d.mts +56 -8
- package/dist/index.d.ts +56 -8
- package/dist/index.js +694 -75
- package/dist/index.mjs +30 -10
- package/dist/{keys-NLWFAOEM.mjs → keys-6Y776TG2.mjs} +2 -2
- package/dist/locales.d.mts +1 -1
- package/dist/locales.d.ts +1 -1
- package/dist/locales.js +36 -0
- package/dist/locales.mjs +1 -1
- package/dist/mobile.d.mts +77 -7
- package/dist/mobile.d.ts +77 -7
- package/dist/mobile.js +307 -46
- package/dist/mobile.mjs +98 -3
- package/dist/next.d.mts +10 -1
- package/dist/next.d.ts +10 -1
- package/dist/next.js +596 -205
- package/dist/next.mjs +83 -10
- package/dist/{provisioningBridge-88xjOS2n.d.mts → provisioningBridge-BXPMZCLe.d.ts} +30 -2
- package/dist/{provisioningBridge-DnTfzdZK.d.ts → provisioningBridge-IEycmsgb.d.mts} +30 -2
- package/dist/{publishableKey-BaR0HoAH.d.ts → publishableKey-f2kq-rKw.d.mts} +1 -1
- package/dist/{publishableKey-BaR0HoAH.d.mts → publishableKey-f2kq-rKw.d.ts} +1 -1
- package/dist/react-permissions.d.mts +52 -0
- package/dist/react-permissions.d.ts +52 -0
- package/dist/react-permissions.js +239 -0
- package/dist/react-permissions.mjs +98 -0
- package/dist/react.d.mts +9 -1624
- package/dist/react.d.ts +9 -1624
- package/dist/react.js +882 -73
- package/dist/react.mjs +71 -2631
- package/dist/{reverify-4UEJXUS6.mjs → reverify-C64QXKJO.mjs} +2 -2
- package/dist/server/handlers.d.mts +200 -4
- package/dist/server/handlers.d.ts +200 -4
- package/dist/server/handlers.js +530 -16
- package/dist/server/handlers.mjs +14 -3
- package/dist/server.d.mts +171 -8
- package/dist/server.d.ts +171 -8
- package/dist/server.js +579 -61
- package/dist/server.mjs +99 -12
- package/dist/service.d.mts +4 -4
- package/dist/service.d.ts +4 -4
- package/dist/service.js +212 -46
- package/dist/service.mjs +3 -3
- package/dist/{signIn-CiIBTJIh.d.mts → signIn-CReqfXsh.d.mts} +95 -3
- package/dist/{signIn-OCr88Zf8.d.ts → signIn-Cfa1GTpO.d.ts} +95 -3
- package/dist/{signIn-4OKLDEIH.mjs → signIn-SHBW6Z4T.mjs} +1 -1
- package/dist/test.mjs +3 -3
- package/dist/{tokens-DCyzzn8L.d.mts → tokens-9F6ETrzk.d.ts} +9 -2
- package/dist/{tokens-aHiGFr_E.d.ts → tokens-B06VtvUi.d.mts} +9 -2
- package/dist/{types-DZAflmmq.d.mts → types-Bn8O-OEd.d.mts} +164 -11
- package/dist/{types-DZAflmmq.d.ts → types-Bn8O-OEd.d.ts} +164 -11
- package/dist/{types-6bNdxesb.d.ts → types-DnU2LhXR.d.mts} +7 -1
- package/dist/{types-6bNdxesb.d.mts → types-DnU2LhXR.d.ts} +7 -1
- package/dist/webhooks.d.mts +113 -17
- package/dist/webhooks.d.ts +113 -17
- package/dist/webhooks.js +179 -15
- package/dist/webhooks.mjs +7 -1
- package/dist/ws.d.mts +2 -2
- package/dist/ws.d.ts +2 -2
- package/dist/ws.js +80 -30
- package/dist/ws.mjs +4 -4
- package/docs/error-handling.md +101 -0
- package/docs/guides/effective-permissions.md +171 -0
- package/docs/guides/invitations.md +65 -0
- package/package.json +19 -4
- package/dist/chunk-6TDJJER7.mjs +0 -217
- package/dist/chunk-UKZLOHZG.mjs +0 -83
- package/dist/errors-CDdl24MP.d.mts +0 -52
- 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.
|
|
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",
|
package/dist/chunk-6TDJJER7.mjs
DELETED
|
@@ -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
|
-
};
|
package/dist/chunk-UKZLOHZG.mjs
DELETED
|
@@ -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 };
|