@mosano-product-framework/sdk 0.2.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 +827 -0
- package/README.react.md +348 -0
- package/dist/auth/claims-types.d.ts +89 -0
- package/dist/auth/claims.d.ts +125 -0
- package/dist/auth/cross-tab.d.ts +114 -0
- package/dist/auth/errors.d.ts +40 -0
- package/dist/auth/index.d.ts +18 -0
- package/dist/auth/index.js +5 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/oauth-state.d.ts +93 -0
- package/dist/auth/session-manager.d.ts +253 -0
- package/dist/auth/storage.d.ts +36 -0
- package/dist/auth/tenant-directory.d.ts +59 -0
- package/dist/auth/tenant-selection.d.ts +92 -0
- package/dist/chunk-7WAV52EO.js +621 -0
- package/dist/chunk-7WAV52EO.js.map +1 -0
- package/dist/chunk-AJWM5MDZ.js +410 -0
- package/dist/chunk-AJWM5MDZ.js.map +1 -0
- package/dist/chunk-EXPYHNPV.js +212 -0
- package/dist/chunk-EXPYHNPV.js.map +1 -0
- package/dist/chunk-GPWGOYCA.js +85 -0
- package/dist/chunk-GPWGOYCA.js.map +1 -0
- package/dist/chunk-GQJ3QQPH.js +339 -0
- package/dist/chunk-GQJ3QQPH.js.map +1 -0
- package/dist/chunk-K2ELAI2X.js +64 -0
- package/dist/chunk-K2ELAI2X.js.map +1 -0
- package/dist/chunk-LRM6JJ63.js +616 -0
- package/dist/chunk-LRM6JJ63.js.map +1 -0
- package/dist/chunk-XAXFIIRT.js +959 -0
- package/dist/chunk-XAXFIIRT.js.map +1 -0
- package/dist/client/core/client-factory.d.ts +61 -0
- package/dist/client/core/client.d.ts +144 -0
- package/dist/client/core/errors.d.ts +105 -0
- package/dist/client/core/index.d.ts +9 -0
- package/dist/client/core/middleware.d.ts +67 -0
- package/dist/client/core/types.d.ts +99 -0
- package/dist/client/graphql/client.d.ts +66 -0
- package/dist/client/graphql/factory.d.ts +84 -0
- package/dist/client/graphql/operation.d.ts +24 -0
- package/dist/client/graphql/types.d.ts +60 -0
- package/dist/client/graphql/ws-client.d.ts +116 -0
- package/dist/client/index.d.ts +17 -0
- package/dist/client/index.js +227 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/middlewares/admin-auth.d.ts +90 -0
- package/dist/client/middlewares/auth.d.ts +81 -0
- package/dist/client/middlewares/index.d.ts +12 -0
- package/dist/client/middlewares/logging.d.ts +102 -0
- package/dist/client/middlewares/retry.d.ts +138 -0
- package/dist/client/middlewares/tenant.d.ts +60 -0
- package/dist/client/middlewares/turnstile.d.ts +41 -0
- package/dist/client/peer-free.d.ts +25 -0
- package/dist/client/utils/url.d.ts +19 -0
- package/dist/identity/index.d.ts +85 -0
- package/dist/identity/index.js +6 -0
- package/dist/identity/index.js.map +1 -0
- package/dist/identity/types.d.ts +690 -0
- package/dist/identity/v0.d.ts +594 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/react/context.d.ts +47 -0
- package/dist/react/hooks.d.ts +120 -0
- package/dist/react/index.d.ts +19 -0
- package/dist/react/index.js +308 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/provider.d.ts +68 -0
- package/dist/react/store.d.ts +85 -0
- package/dist/storage/index.d.ts +31 -0
- package/dist/storage/index.js +5 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/types.d.ts +107 -0
- package/dist/storage/v0.d.ts +120 -0
- package/package.json +99 -0
package/README.react.md
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# @mosano-product-framework/sdk/react
|
|
2
|
+
|
|
3
|
+
React bindings for the MPF SDK: a provider, six hooks, and nothing else.
|
|
4
|
+
|
|
5
|
+
**Dependency-free apart from React.** No TanStack Query, no zustand, no router.
|
|
6
|
+
State lives in a small external store read through `useSyncExternalStore`, so
|
|
7
|
+
there are no constraints on how you manage the rest of your data — SWR, Apollo,
|
|
8
|
+
TanStack Query and plain `fetch` consumers are equally supported.
|
|
9
|
+
|
|
10
|
+
For the SDK core — tenant selection, claims, session management — see
|
|
11
|
+
[README.md](./README.md).
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
React is an **optional peer dependency**. Installing the SDK does not install
|
|
16
|
+
React, and importing the root barrel never pulls React in.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnpm add @mosano-product-framework/sdk react
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires **React 18+** (`useSyncExternalStore` is 18+).
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { MPFAuthProvider } from '@mosano-product-framework/sdk/react';
|
|
28
|
+
import { createAuthClient } from '@mosano-product-framework/sdk/identity';
|
|
29
|
+
import { createDefaultStorage } from '@mosano-product-framework/sdk/auth';
|
|
30
|
+
|
|
31
|
+
const auth = createAuthClient({ baseUrl: 'https://api.example.com/auth' });
|
|
32
|
+
|
|
33
|
+
export function App() {
|
|
34
|
+
return (
|
|
35
|
+
<MPFAuthProvider
|
|
36
|
+
renew={(refreshToken) => auth.renewSession({ refresh_token: refreshToken })}
|
|
37
|
+
storage={createDefaultStorage()}
|
|
38
|
+
onDeadSession={() => navigate('/login')}
|
|
39
|
+
>
|
|
40
|
+
<Routes />
|
|
41
|
+
</MPFAuthProvider>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### `<MPFAuthProvider>` props
|
|
47
|
+
|
|
48
|
+
| Prop | Required | Description |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `renew` | ✅ | `(refreshToken) => Promise<{ access_token, refresh_token }>`. Wire to `auth.renewSession`. |
|
|
51
|
+
| `storage` | | Where the refresh token and tenant selection are persisted. Defaults to `localStorage`, falling back to memory. |
|
|
52
|
+
| `onDeadSession` | | Called when the session cannot be renewed. Defaults to a one-time `console.warn`. |
|
|
53
|
+
| `tenantProvider` | | A pre-built `createTenantSelection()` store, so non-React code can share the same selection. |
|
|
54
|
+
| `loadTenantDirectory` | | Fetches tenant display names. See [`useTenantDirectory()`](#usetenantdirectory). |
|
|
55
|
+
| `autoLoadTenantDirectory` | | Fetch names automatically once signed in. Default `true` when a loader is supplied. |
|
|
56
|
+
| `sessionOptions` | | Forwarded to the session manager (`bufferSeconds`, `jitterMs`, `crossTab`, ...). |
|
|
57
|
+
| `session` | | A pre-built `SessionManager`. Mainly a test seam. |
|
|
58
|
+
|
|
59
|
+
### Hydration
|
|
60
|
+
|
|
61
|
+
The provider hydrates **synchronously**, during its first render. Anything
|
|
62
|
+
already in storage — the refresh token, the selected tenant — is present on the
|
|
63
|
+
very first paint, so a reload does not flash a login form and does not reset the
|
|
64
|
+
user's tenant to "none". There is no `loading` state to handle for hydration;
|
|
65
|
+
`useAuth().hydrated` is always `true` by the time your component runs.
|
|
66
|
+
|
|
67
|
+
## Hooks
|
|
68
|
+
|
|
69
|
+
### `useAuth()`
|
|
70
|
+
|
|
71
|
+
Core state and actions.
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
function Header() {
|
|
75
|
+
const { authenticated, claims, signOut } = useAuth();
|
|
76
|
+
|
|
77
|
+
if (!authenticated) return <SignInLink />;
|
|
78
|
+
return <button onClick={signOut}>Sign out {claims?.uid}</button>;
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
| Field | Description |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `hydrated` | Always `true` after the first render. |
|
|
85
|
+
| `authenticated` | Whether an access token is present. |
|
|
86
|
+
| `claims` | Decoded (**unverified**) claims, or `null`. |
|
|
87
|
+
| `getAccessToken()` | `Promise<string \| null>`, renewing first if the token is stale. |
|
|
88
|
+
| `setTokens(pair)` | Adopt a fresh token pair after sign-in. |
|
|
89
|
+
| `signOut()` | Drop the session locally. **Does not navigate.** |
|
|
90
|
+
|
|
91
|
+
Sign-in looks like this — the SDK does not own your form or your routing:
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
const { setTokens } = useAuth();
|
|
95
|
+
|
|
96
|
+
async function onSubmit(email: string, password: string) {
|
|
97
|
+
const result = await auth.signInEmailPassword({ email, password });
|
|
98
|
+
setTokens(result); // provider state updates synchronously
|
|
99
|
+
navigate('/dashboard'); // your router, your call
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `useTenants()`
|
|
104
|
+
|
|
105
|
+
The tenants the current token grants access to. **Synchronous, and cannot fail** —
|
|
106
|
+
the answer is already in the access token, which is fresher than anything a round
|
|
107
|
+
trip could tell you.
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
function TenantSwitcher() {
|
|
111
|
+
const { status, tenants } = useTenants();
|
|
112
|
+
const current = useCurrentTenant();
|
|
113
|
+
const select = useSelectTenant();
|
|
114
|
+
|
|
115
|
+
if (status === 'unloaded') return null; // not signed in
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<select value={current.tenant ?? ''} onChange={(e) => select(e.target.value || null)}>
|
|
119
|
+
<option value="">All (no tenant)</option>
|
|
120
|
+
{tenants.map((t) => (
|
|
121
|
+
<option key={t.id} value={t.id}>{t.name ?? t.id}</option>
|
|
122
|
+
))}
|
|
123
|
+
</select>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`status` is only `'unloaded' | 'loaded'`, because there is no fetch here and
|
|
129
|
+
therefore nothing to fail. Each entry has `id`, `roles`, `defaultRole` (the
|
|
130
|
+
token's explicit `dfr` for that tenant when it carries one, else `roles[0]`),
|
|
131
|
+
and — once the directory has loaded — `name` and `slug`.
|
|
132
|
+
|
|
133
|
+
### `useTenantDirectory()`
|
|
134
|
+
|
|
135
|
+
Tenant **display names**, which do require a network call and can genuinely fail.
|
|
136
|
+
|
|
137
|
+
`tnts` in the token carries ids and roles only, so without this a switcher shows
|
|
138
|
+
UUIDs. Opt in by giving the provider a loader:
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
// Simplest: REST. `listMyTenants()` returns id, name and slug.
|
|
142
|
+
<MPFAuthProvider
|
|
143
|
+
renew={renew}
|
|
144
|
+
loadTenantDirectory={() => auth.listMyTenants()}
|
|
145
|
+
>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
// Or GraphQL, if your app already has a client. Query with `{ tenant: null }` —
|
|
150
|
+
// the tenantless role has exactly the permission a switcher needs.
|
|
151
|
+
import { TENANT_DIRECTORY_QUERY } from '@mosano-product-framework/sdk/auth';
|
|
152
|
+
|
|
153
|
+
<MPFAuthProvider
|
|
154
|
+
renew={renew}
|
|
155
|
+
loadTenantDirectory={async () => {
|
|
156
|
+
const data = await gql.query(TENANT_DIRECTORY_QUERY, undefined, { tenant: null });
|
|
157
|
+
return data.identity_tenants;
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The loader is injected rather than built in, so the React layer never depends on
|
|
163
|
+
the GraphQL client and either transport works unchanged.
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
const { status, directory, error, available, load } = useTenantDirectory();
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
| `status` | Meaning |
|
|
170
|
+
|---|---|
|
|
171
|
+
| `'unloaded'` | No loader supplied, or not fetched yet. |
|
|
172
|
+
| `'loading'` | In flight. |
|
|
173
|
+
| `'loaded'` | Names fetched. **May legitimately be empty** — that means "this user has no tenants". |
|
|
174
|
+
| `'failed'` | The fetch failed. `error` says why. |
|
|
175
|
+
|
|
176
|
+
`'loaded'`-but-empty and `'failed'` are deliberately distinct. Collapsing them is
|
|
177
|
+
how an empty switcher comes to silently mean "the server was unreachable".
|
|
178
|
+
|
|
179
|
+
**A failed name lookup never empties the switcher.** The ids come from the token,
|
|
180
|
+
so they remain listed and selectable; only the names are missing. Render
|
|
181
|
+
`name ?? id`:
|
|
182
|
+
|
|
183
|
+
```tsx
|
|
184
|
+
function TenantSwitcher() {
|
|
185
|
+
const { tenants } = useTenants();
|
|
186
|
+
const { status, error } = useTenantDirectory();
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<>
|
|
190
|
+
{status === 'failed' && <Warning>Couldn't load names: {error?.message}</Warning>}
|
|
191
|
+
{tenants.map((t) => <Option key={t.id} label={t.name ?? t.id} />)}
|
|
192
|
+
</>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Names are dropped on `signOut()`. Entries for tenants the token does not grant
|
|
198
|
+
are ignored, so a name lookup can never make an unauthorised tenant selectable.
|
|
199
|
+
|
|
200
|
+
### `useCurrentTenant()`
|
|
201
|
+
|
|
202
|
+
The current selection: `{ tenant: string | null, role?: string }`. `tenant` is
|
|
203
|
+
`null` on the no-tenant path.
|
|
204
|
+
|
|
205
|
+
### `useSelectTenant()`
|
|
206
|
+
|
|
207
|
+
```tsx
|
|
208
|
+
const select = useSelectTenant();
|
|
209
|
+
|
|
210
|
+
select('tenant-uuid'); // select a tenant, default role
|
|
211
|
+
select('tenant-uuid', 'admin'); // select a specific role
|
|
212
|
+
select(null); // no tenant
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**Synchronous. No `await`, no network, no renewal.** Tenant is a request header,
|
|
216
|
+
so switching is a local state change and the next request simply carries a
|
|
217
|
+
different header — the access token is byte-identical before and after. A test
|
|
218
|
+
asserts it fires zero fetches and zero renewals.
|
|
219
|
+
|
|
220
|
+
The selection is persisted, so it survives a reload.
|
|
221
|
+
|
|
222
|
+
### `useClaims()`
|
|
223
|
+
|
|
224
|
+
Decoded claims of the current access token, or `null`.
|
|
225
|
+
|
|
226
|
+
> ### ⚠️ Decode, not verify
|
|
227
|
+
>
|
|
228
|
+
> These claims are **decoded, never verified**. There is no signature check and
|
|
229
|
+
> no key material in this package. A hostile client can forge any value here: any
|
|
230
|
+
> user id, any tenant, any role, any expiry.
|
|
231
|
+
>
|
|
232
|
+
> Use them for UI only — rendering a switcher, greying out an option, deciding
|
|
233
|
+
> when to renew. **Never gate a security-relevant branch on them.** The server
|
|
234
|
+
> verifies the signature and re-derives tenant and role on every request, and a
|
|
235
|
+
> forged claim buys an attacker nothing there.
|
|
236
|
+
|
|
237
|
+
```tsx
|
|
238
|
+
const claims = useClaims();
|
|
239
|
+
const isOwnerSomewhere = (claims?.tnts ?? []).some((t) => t.rls.includes('owner'));
|
|
240
|
+
// ^ fine for showing a menu item. NOT fine as an authorization decision.
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### `useMPFAuthOptional()`
|
|
244
|
+
|
|
245
|
+
Returns the context or `null` instead of throwing, for components rendered both
|
|
246
|
+
inside and outside an authenticated shell.
|
|
247
|
+
|
|
248
|
+
```tsx
|
|
249
|
+
function Header() {
|
|
250
|
+
const ctx = useMPFAuthOptional();
|
|
251
|
+
if (!ctx) return <MarketingHeader />;
|
|
252
|
+
return <AppHeader />;
|
|
253
|
+
}
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Every other hook throws a descriptive error when no provider is mounted.
|
|
257
|
+
|
|
258
|
+
## Storage adapters
|
|
259
|
+
|
|
260
|
+
```tsx
|
|
261
|
+
import { createDefaultStorage, createMemoryStorage } from '@mosano-product-framework/sdk/auth';
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
- `createDefaultStorage()` — `localStorage`, probed for writability and falling
|
|
265
|
+
back to memory (Safari private mode and blocked third-party contexts expose
|
|
266
|
+
`localStorage` but throw on write).
|
|
267
|
+
- `createMemoryStorage()` — in-memory; the automatic choice when `window` is
|
|
268
|
+
absent, and useful in tests.
|
|
269
|
+
- Anything with `getItem` / `setItem` / `removeItem` works, so `sessionStorage`
|
|
270
|
+
or an encrypted wrapper drops straight in.
|
|
271
|
+
|
|
272
|
+
Only the **refresh token** and the **tenant selection** are persisted. The access
|
|
273
|
+
token stays in memory.
|
|
274
|
+
|
|
275
|
+
## `onDeadSession` is a callback, never a navigation
|
|
276
|
+
|
|
277
|
+
The SDK never navigates. Routing is your application's concern, and hard-coding
|
|
278
|
+
`window.location` or a `useNavigate()` call would couple the library to one
|
|
279
|
+
router and one URL scheme.
|
|
280
|
+
|
|
281
|
+
```tsx
|
|
282
|
+
<MPFAuthProvider
|
|
283
|
+
renew={renew}
|
|
284
|
+
onDeadSession={(error) => {
|
|
285
|
+
queryClient.clear();
|
|
286
|
+
navigate('/login', { state: { reason: error.message } });
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The default is a one-time `console.warn` telling you to supply one.
|
|
292
|
+
|
|
293
|
+
## Using with TanStack Query
|
|
294
|
+
|
|
295
|
+
The bindings hold auth state; your query library holds server state. Wire them
|
|
296
|
+
with `getAccessToken`, and key queries on the tenant so a switch refetches.
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
300
|
+
import { useAuth, useCurrentTenant, useSelectTenant } from '@mosano-product-framework/sdk/react';
|
|
301
|
+
|
|
302
|
+
function useMembers() {
|
|
303
|
+
const { getAccessToken } = useAuth();
|
|
304
|
+
const { tenant } = useCurrentTenant();
|
|
305
|
+
|
|
306
|
+
return useQuery({
|
|
307
|
+
// Tenant in the key: switching tenants refetches instead of showing stale rows.
|
|
308
|
+
queryKey: ['members', tenant],
|
|
309
|
+
queryFn: async () => {
|
|
310
|
+
await getAccessToken(); // renews if stale, before the request goes out
|
|
311
|
+
return auth.listTenantMembers(tenant!);
|
|
312
|
+
},
|
|
313
|
+
enabled: Boolean(tenant),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Switching stays synchronous; the query layer reacts to the key change.
|
|
318
|
+
function Switcher() {
|
|
319
|
+
const select = useSelectTenant();
|
|
320
|
+
return <button onClick={() => select('other-tenant')}>Switch</button>;
|
|
321
|
+
}
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
On sign-out, clear the cache so one user's data cannot be shown to the next:
|
|
325
|
+
|
|
326
|
+
```tsx
|
|
327
|
+
const queryClient = useQueryClient();
|
|
328
|
+
const { signOut } = useAuth();
|
|
329
|
+
|
|
330
|
+
const logout = () => {
|
|
331
|
+
signOut();
|
|
332
|
+
queryClient.clear();
|
|
333
|
+
};
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
The same shape works with SWR (`useSWR(['members', tenant], ...)`) or Apollo
|
|
337
|
+
(refetch on the tenant change).
|
|
338
|
+
|
|
339
|
+
## Out of scope
|
|
340
|
+
|
|
341
|
+
GraphQL subscriptions and websockets are not supported — see the
|
|
342
|
+
[Out of scope](./README.md#out-of-scope) section of the main README for why the
|
|
343
|
+
tenant model makes long-lived sockets a separate design problem rather than a
|
|
344
|
+
missing feature.
|
|
345
|
+
|
|
346
|
+
## License
|
|
347
|
+
|
|
348
|
+
MIT
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for MPF access-token claims.
|
|
3
|
+
*
|
|
4
|
+
* These describe what the server puts in a token. They are NOT a security
|
|
5
|
+
* boundary — see the block comment at the top of `claims.ts`.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* A role name.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately `string` and NOT a closed union. Roles are database rows
|
|
11
|
+
* (`identity.tenant_membership_valid_roles`, seeded `owner | admin | member |
|
|
12
|
+
* guest`), so a newly seeded role must not require an SDK release — and closing
|
|
13
|
+
* this union is exactly how the stale `'viewer'` role leaked into the types.
|
|
14
|
+
*
|
|
15
|
+
* Closed unions are appropriate on *request* types (where autocomplete earns
|
|
16
|
+
* its keep), never on decoded server output.
|
|
17
|
+
*/
|
|
18
|
+
export type MPFRole = string;
|
|
19
|
+
/**
|
|
20
|
+
* One entry of the `tnts` array: a tenant the user belongs to.
|
|
21
|
+
*/
|
|
22
|
+
export interface MPFTenantClaim {
|
|
23
|
+
/** Tenant id (uuid). */
|
|
24
|
+
id: string;
|
|
25
|
+
/** Every role held in this tenant. Non-empty. */
|
|
26
|
+
rls: MPFRole[];
|
|
27
|
+
/**
|
|
28
|
+
* This tenant's explicit default role — the role the server applies when a
|
|
29
|
+
* request selects this tenant without naming one.
|
|
30
|
+
*
|
|
31
|
+
* OPTIONAL: omitted entirely when the tenant has no explicit default, in
|
|
32
|
+
* which case selection falls back to `rls[0]`. The server refuses to mint a
|
|
33
|
+
* `dfr` that is not a member of that same tenant's `rls`, so a default that
|
|
34
|
+
* is invalid in its own tenant is unrepresentable.
|
|
35
|
+
*/
|
|
36
|
+
dfr?: MPFRole;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Version 1 of the MPF access-token claim set.
|
|
40
|
+
*/
|
|
41
|
+
export interface MPFClaimsV1 {
|
|
42
|
+
/** Claim-set version. Dispatches parsing. */
|
|
43
|
+
v: 1;
|
|
44
|
+
/** User id (uuid). */
|
|
45
|
+
uid: string;
|
|
46
|
+
/** Session id (uuid). */
|
|
47
|
+
sid: string;
|
|
48
|
+
/** Expiry, seconds since the epoch. */
|
|
49
|
+
exp: number;
|
|
50
|
+
/**
|
|
51
|
+
* Issued-at, seconds since the epoch.
|
|
52
|
+
*
|
|
53
|
+
* Optional in this type on purpose: it is only needed to compute token
|
|
54
|
+
* lifetime for the renewal-buffer clamp. A server that stops emitting it
|
|
55
|
+
* should degrade that clamp, not lock every client out of decoding.
|
|
56
|
+
*/
|
|
57
|
+
iat?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Every tenant the user belongs to, with the roles held in each.
|
|
60
|
+
*
|
|
61
|
+
* An ARRAY, always present — `[]` for a user with no tenants (which is every
|
|
62
|
+
* brand-new signup). Each entry may carry an explicit `dfr`; when it does not,
|
|
63
|
+
* that tenant's default role is `rls[0]`.
|
|
64
|
+
*/
|
|
65
|
+
tnts: MPFTenantClaim[];
|
|
66
|
+
/**
|
|
67
|
+
* The role applied when no tenant is selected.
|
|
68
|
+
*
|
|
69
|
+
* Optional: a deployment may have no tenantless role configured, in which case
|
|
70
|
+
* the server emits an empty string. Nothing the client does with this value is
|
|
71
|
+
* load-bearing — the server decides the effective role on every request — so
|
|
72
|
+
* an empty one degrades to `undefined` rather than failing the whole decode.
|
|
73
|
+
*/
|
|
74
|
+
drl?: MPFRole;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Any supported MPF claim set.
|
|
78
|
+
*
|
|
79
|
+
* A discriminated union on `v`. Adding v2 means adding a member here and one
|
|
80
|
+
* entry to the parser registry in `claims.ts`.
|
|
81
|
+
*/
|
|
82
|
+
export type MPFClaims = MPFClaimsV1;
|
|
83
|
+
/**
|
|
84
|
+
* A parser turning an unverified JWT payload into typed claims.
|
|
85
|
+
*
|
|
86
|
+
* Must throw `MPFClaimsError` when the payload does not match its version's
|
|
87
|
+
* shape, and must tolerate unknown extra keys.
|
|
88
|
+
*/
|
|
89
|
+
export type ClaimsParser = (payload: Record<string, unknown>) => MPFClaims;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* SECURITY BOUNDARY — READ BEFORE USING ANYTHING IN THIS FILE
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* This module DECODES access tokens. It does NOT VERIFY them.
|
|
7
|
+
*
|
|
8
|
+
* There is no signature check here. There is no key material here. We call
|
|
9
|
+
* `jose.decodeJwt`, which base64-decodes the payload and nothing else — an
|
|
10
|
+
* unsigned token, a token signed with the wrong key, and a token whose payload
|
|
11
|
+
* was hand-edited in devtools all decode identically and successfully.
|
|
12
|
+
*
|
|
13
|
+
* A hostile client can therefore forge ANY value this module returns: any user
|
|
14
|
+
* id, any session id, any tenant, any role, any expiry.
|
|
15
|
+
*
|
|
16
|
+
* Every consumer of this module must be UX-only:
|
|
17
|
+
* - rendering a tenant switcher,
|
|
18
|
+
* - failing an obviously-invalid selection locally instead of round-tripping
|
|
19
|
+
* for a 403,
|
|
20
|
+
* - deciding when to proactively renew.
|
|
21
|
+
*
|
|
22
|
+
* NEVER gate a security-relevant branch on these values. The server is the only
|
|
23
|
+
* authority: `hasura-authd` and `internal/go-chi-middleware-auth` verify the
|
|
24
|
+
* signature and re-derive tenant and role from the token on every single
|
|
25
|
+
* request, and a forged claim buys the attacker exactly nothing there.
|
|
26
|
+
*
|
|
27
|
+
* `jwtVerify` must never appear in this package. Verification needs the signing
|
|
28
|
+
* secret, and the signing secret must never reach a browser.
|
|
29
|
+
* ============================================================================
|
|
30
|
+
*/
|
|
31
|
+
import type { ClaimsParser, MPFClaims, MPFRole } from './claims-types.js';
|
|
32
|
+
/**
|
|
33
|
+
* Drop every cached decode.
|
|
34
|
+
*
|
|
35
|
+
* Call on logout so a decoded token cannot outlive the session in memory.
|
|
36
|
+
*/
|
|
37
|
+
export declare function clearClaimsCache(): void;
|
|
38
|
+
/**
|
|
39
|
+
* Register a parser for a claim-set version.
|
|
40
|
+
*
|
|
41
|
+
* Exported so a consumer pinned to an older SDK can teach it a newer shape
|
|
42
|
+
* without waiting for a release.
|
|
43
|
+
*
|
|
44
|
+
* @throws {MPFClaimsError} If the version already has a parser — a silent
|
|
45
|
+
* overwrite would make decoding depend on module import order.
|
|
46
|
+
*/
|
|
47
|
+
export declare function registerClaimsParser(version: number, parser: ClaimsParser): void;
|
|
48
|
+
/**
|
|
49
|
+
* The claim-set versions this build understands.
|
|
50
|
+
*/
|
|
51
|
+
export declare function supportedClaimsVersions(): number[];
|
|
52
|
+
/**
|
|
53
|
+
* Decode an access token's claims.
|
|
54
|
+
*
|
|
55
|
+
* **Decodes, does not verify** — see the security block at the top of this file.
|
|
56
|
+
*
|
|
57
|
+
* Succeeds on an EXPIRED token by design: the session manager needs `exp` in
|
|
58
|
+
* order to decide that a renewal is due, so expiry is the caller's question,
|
|
59
|
+
* not this function's. Use {@link isExpired} to ask it.
|
|
60
|
+
*
|
|
61
|
+
* @param token - Raw JWT string.
|
|
62
|
+
* @returns The typed claims.
|
|
63
|
+
* @throws {MPFUnsupportedClaimsVersionError} If `v` is absent or unknown.
|
|
64
|
+
* @throws {MPFClaimsError} If the token is malformed or a claim has the wrong type.
|
|
65
|
+
*/
|
|
66
|
+
export declare function decodeAccessToken(token: string): MPFClaims;
|
|
67
|
+
/**
|
|
68
|
+
* Decode an access token, returning `null` instead of throwing.
|
|
69
|
+
*
|
|
70
|
+
* For UI paths that must render something regardless of token health.
|
|
71
|
+
*
|
|
72
|
+
* **Decodes, does not verify** — see the security block at the top of this file.
|
|
73
|
+
*/
|
|
74
|
+
export declare function tryDecodeAccessToken(token: string | null | undefined): MPFClaims | null;
|
|
75
|
+
/**
|
|
76
|
+
* Tenant ids present in the token, in `tnts` order.
|
|
77
|
+
*/
|
|
78
|
+
export declare function listTenants(claims: MPFClaims): string[];
|
|
79
|
+
/**
|
|
80
|
+
* Roles held in a tenant.
|
|
81
|
+
*
|
|
82
|
+
* Returns a fresh array; mutating it cannot corrupt the cached claims.
|
|
83
|
+
*/
|
|
84
|
+
export declare function rolesForTenant(claims: MPFClaims, tenantId: string): MPFRole[];
|
|
85
|
+
/**
|
|
86
|
+
* The default role for a tenant: its explicit `dfr` when the entry carries one,
|
|
87
|
+
* otherwise `rls[0]`.
|
|
88
|
+
*
|
|
89
|
+
* `undefined` when the tenant is not in the token, or holds no roles.
|
|
90
|
+
*/
|
|
91
|
+
export declare function defaultRoleForTenant(claims: MPFClaims, tenantId: string): MPFRole | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* Whether a tenant (and optionally a role) *appears* selectable in this token.
|
|
94
|
+
*
|
|
95
|
+
* ⚠️ **NOT A PERMISSION CHECK.** This reads unverified, client-forgeable claims.
|
|
96
|
+
* It exists so a switcher can grey out an entry and so an obviously-invalid
|
|
97
|
+
* selection fails locally instead of costing a round trip for a 403. A `true`
|
|
98
|
+
* here grants nothing: the server re-derives tenant and role from the verified
|
|
99
|
+
* token on every request and will reject a selection this function approved.
|
|
100
|
+
* Never branch on it for anything that must hold.
|
|
101
|
+
*
|
|
102
|
+
* @param claims - Decoded (unverified) claims.
|
|
103
|
+
* @param tenantId - Tenant to select, or `null` for the no-tenant path.
|
|
104
|
+
* @param role - Optional specific role; omitted means "the tenant's default".
|
|
105
|
+
*/
|
|
106
|
+
export declare function canSelect(claims: MPFClaims, tenantId: string | null, role?: MPFRole): boolean;
|
|
107
|
+
/**
|
|
108
|
+
* The token's expiry as a `Date`.
|
|
109
|
+
*/
|
|
110
|
+
export declare function expiresAt(claims: MPFClaims): Date;
|
|
111
|
+
/**
|
|
112
|
+
* Whether the token is expired, optionally treating it as expired
|
|
113
|
+
* `skewSeconds` early.
|
|
114
|
+
*
|
|
115
|
+
* @param claims - Decoded claims.
|
|
116
|
+
* @param skewSeconds - Seconds of headroom. `120` answers "will this token die
|
|
117
|
+
* within the next two minutes?", which is the question a renewal buffer asks.
|
|
118
|
+
* @param nowMs - Injectable clock for tests. Defaults to `Date.now()`.
|
|
119
|
+
*/
|
|
120
|
+
export declare function isExpired(claims: MPFClaims, skewSeconds?: number, nowMs?: number): boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Token lifetime in seconds (`exp - iat`), or `undefined` when the token
|
|
123
|
+
* carries no `iat`.
|
|
124
|
+
*/
|
|
125
|
+
export declare function tokenLifetimeSeconds(claims: MPFClaims): number | undefined;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-tab refresh coordination.
|
|
3
|
+
*
|
|
4
|
+
* ## The problem, precisely
|
|
5
|
+
*
|
|
6
|
+
* Multiple tabs of ONE browser share one login and read the same refresh token
|
|
7
|
+
* from origin-scoped `localStorage`. The refresh token is single-use and
|
|
8
|
+
* rotates, so if two tabs renew concurrently one of them loses and its token is
|
|
9
|
+
* dead. Separate browsers, machines or devices get separate session rows and
|
|
10
|
+
* separate refresh tokens, so they are not a concern here.
|
|
11
|
+
*
|
|
12
|
+
* With proactive refresh every tab's timer fires at roughly the same moment, so
|
|
13
|
+
* this is contended by N tabs at once, not two.
|
|
14
|
+
*
|
|
15
|
+
* ## Mechanism
|
|
16
|
+
*
|
|
17
|
+
* `navigator.locks` (Web Locks) is a standardized cross-tab mutex and needs no
|
|
18
|
+
* dependency on the primary path. Locks auto-release when a tab is destroyed,
|
|
19
|
+
* so a closed tab cannot deadlock the others. A polyfill is loaded only where
|
|
20
|
+
* the API is missing (pre-15.4 Safari, non-secure contexts).
|
|
21
|
+
*
|
|
22
|
+
* A lock ALONE is insufficient: N tabs would queue and then each renew in turn,
|
|
23
|
+
* which is the exact problem the lock was supposed to solve. The session manager
|
|
24
|
+
* therefore does double-checked locking — re-reading shared state INSIDE the
|
|
25
|
+
* lock and adopting instead of renewing when another tab got there first.
|
|
26
|
+
*
|
|
27
|
+
* ## Fanout: BroadcastChannel, not localStorage
|
|
28
|
+
*
|
|
29
|
+
* The winner's fresh ACCESS token is broadcast in memory over a
|
|
30
|
+
* `BroadcastChannel` rather than persisted, so losing tabs can adopt it without
|
|
31
|
+
* a network call while the access token still never touches `localStorage`.
|
|
32
|
+
* Where `BroadcastChannel` is unavailable, losers fall back to renewing with the
|
|
33
|
+
* rotated refresh token — correct, just less efficient.
|
|
34
|
+
*
|
|
35
|
+
* Everything here no-ops when `window` is absent, so Node and the e2e suite are
|
|
36
|
+
* unaffected.
|
|
37
|
+
*/
|
|
38
|
+
/** A token pair shared between tabs. */
|
|
39
|
+
export interface TokenBroadcast {
|
|
40
|
+
accessToken: string;
|
|
41
|
+
refreshToken: string;
|
|
42
|
+
}
|
|
43
|
+
/** The subset of `LockManager` this module uses. */
|
|
44
|
+
export interface LockManagerLike {
|
|
45
|
+
request(name: string, options: {
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
}, callback: () => Promise<unknown>): Promise<unknown>;
|
|
48
|
+
}
|
|
49
|
+
/** The subset of `BroadcastChannel` this module uses. */
|
|
50
|
+
export interface BroadcastChannelLike {
|
|
51
|
+
postMessage(message: unknown): void;
|
|
52
|
+
addEventListener(type: 'message', listener: (event: {
|
|
53
|
+
data: unknown;
|
|
54
|
+
}) => void): void;
|
|
55
|
+
removeEventListener(type: 'message', listener: (event: {
|
|
56
|
+
data: unknown;
|
|
57
|
+
}) => void): void;
|
|
58
|
+
close(): void;
|
|
59
|
+
}
|
|
60
|
+
/** How the coordinator is providing mutual exclusion. */
|
|
61
|
+
export type CrossTabMode = 'native' | 'polyfill' | 'none';
|
|
62
|
+
export interface CrossTabCoordinator {
|
|
63
|
+
/** How mutual exclusion is being provided. */
|
|
64
|
+
readonly mode: CrossTabMode;
|
|
65
|
+
/** Run `fn` holding the cross-tab refresh lock. */
|
|
66
|
+
withLock<T>(fn: () => Promise<T>): Promise<T>;
|
|
67
|
+
/** Tell other tabs about a fresh token pair. */
|
|
68
|
+
publish(message: TokenBroadcast): void;
|
|
69
|
+
/** Observe other tabs' fresh token pairs. Returns an unsubscribe function. */
|
|
70
|
+
subscribe(listener: (message: TokenBroadcast) => void): () => void;
|
|
71
|
+
/** Release resources. */
|
|
72
|
+
close(): void;
|
|
73
|
+
}
|
|
74
|
+
export interface CrossTabOptions {
|
|
75
|
+
/** Lock name. @default 'mpf.refresh' */
|
|
76
|
+
lockName?: string;
|
|
77
|
+
/** BroadcastChannel name. @default 'mpf.auth' */
|
|
78
|
+
channelName?: string;
|
|
79
|
+
/**
|
|
80
|
+
* How long to wait for the lock before giving up and proceeding without it.
|
|
81
|
+
* @default 5000
|
|
82
|
+
*/
|
|
83
|
+
lockTimeoutMs?: number;
|
|
84
|
+
/** Injectable lock manager. Defaults to `navigator.locks`. For tests. */
|
|
85
|
+
locks?: LockManagerLike | null;
|
|
86
|
+
/**
|
|
87
|
+
* Loads a Web Locks polyfill when the native API is absent. Resolve `null` to
|
|
88
|
+
* decline. The default declines — no polyfill is bundled, keeping the primary
|
|
89
|
+
* path dependency-free; a consumer targeting old Safari supplies one here.
|
|
90
|
+
*/
|
|
91
|
+
loadPolyfill?: () => Promise<LockManagerLike | null>;
|
|
92
|
+
/** Injectable channel factory. Defaults to `BroadcastChannel`. For tests. */
|
|
93
|
+
channelFactory?: (name: string) => BroadcastChannelLike | null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Apply symmetric jitter to a delay.
|
|
97
|
+
*
|
|
98
|
+
* Without this, N tabs computed the same proactive-renew deadline from the same
|
|
99
|
+
* token and all wake on the same millisecond, then serialize through the lock
|
|
100
|
+
* for no reason. Result is clamped at 0.
|
|
101
|
+
*
|
|
102
|
+
* @param baseMs - The unjittered delay.
|
|
103
|
+
* @param jitterMs - Maximum deviation in either direction.
|
|
104
|
+
* @param random - Injectable `Math.random` for tests.
|
|
105
|
+
*/
|
|
106
|
+
export declare function applyJitter(baseMs: number, jitterMs: number, random?: () => number): number;
|
|
107
|
+
/**
|
|
108
|
+
* Create a cross-tab coordinator.
|
|
109
|
+
*
|
|
110
|
+
* Resolution order for the lock: injected `locks` → `navigator.locks` →
|
|
111
|
+
* `loadPolyfill()` → none (run without coordination, correct in a
|
|
112
|
+
* single-context environment like Node).
|
|
113
|
+
*/
|
|
114
|
+
export declare function createCrossTabCoordinator(options?: CrossTabOptions): CrossTabCoordinator;
|