@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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Errors for the auth layer.
|
|
3
|
+
*/
|
|
4
|
+
import { MPFError } from '../client/core/errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Base error for access-token claim decoding failures.
|
|
7
|
+
*/
|
|
8
|
+
export declare class MPFClaimsError extends MPFError {
|
|
9
|
+
constructor(message: string, code?: string, details?: unknown);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Thrown when a token's claim-set version has no registered parser.
|
|
13
|
+
*
|
|
14
|
+
* Carries the offending version so a client can distinguish "this app is older
|
|
15
|
+
* than the server" (a deploy-ordering problem, usually fixed by reloading) from
|
|
16
|
+
* "this token is garbage" (a real auth failure).
|
|
17
|
+
*/
|
|
18
|
+
export declare class MPFUnsupportedClaimsVersionError extends MPFClaimsError {
|
|
19
|
+
/** The `v` value found in the token, or null when it was absent/non-numeric. */
|
|
20
|
+
readonly version: number | null;
|
|
21
|
+
constructor(version: number | null);
|
|
22
|
+
toJSON(): Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Thrown when the session can no longer be renewed and the user must
|
|
26
|
+
* re-authenticate.
|
|
27
|
+
*
|
|
28
|
+
* This is a terminal state: the refresh token was rejected, absent, or the
|
|
29
|
+
* renew endpoint failed in a non-recoverable way.
|
|
30
|
+
*/
|
|
31
|
+
export declare class DeadSessionError extends MPFError {
|
|
32
|
+
/** The underlying error that killed the session, when there was one. */
|
|
33
|
+
readonly cause?: Error;
|
|
34
|
+
constructor(message?: string, cause?: Error);
|
|
35
|
+
toJSON(): Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Type guard for {@link DeadSessionError}.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isDeadSession(error: unknown): error is DeadSessionError;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth layer: token claims, tenant selection, session lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ Claim decoding in this module DECODES BUT DOES NOT VERIFY. Its output is
|
|
5
|
+
* UX-only and is forgeable by a hostile client. See the security block at the
|
|
6
|
+
* top of `claims.ts`.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
export type { MPFRole, MPFClaims, MPFClaimsV1, MPFTenantClaim, ClaimsParser, } from './claims-types.js';
|
|
11
|
+
export { decodeAccessToken, tryDecodeAccessToken, listTenants, rolesForTenant, defaultRoleForTenant, canSelect, expiresAt, isExpired, tokenLifetimeSeconds, clearClaimsCache, registerClaimsParser, supportedClaimsVersions, } from './claims.js';
|
|
12
|
+
export { MPFClaimsError, MPFUnsupportedClaimsVersionError, DeadSessionError, isDeadSession, } from './errors.js';
|
|
13
|
+
export { createTenantSelection, type TenantSelection, type TenantSelectionStore, type TenantSelectionPersistence, type CreateTenantSelectionOptions, } from './tenant-selection.js';
|
|
14
|
+
export { createDefaultStorage, createSessionStorage, createMemoryStorage, type MPFAuthStorage, } from './storage.js';
|
|
15
|
+
export { createOAuthStateStore, type OAuthStateStore, type OAuthStateStoreOptions, } from './oauth-state.js';
|
|
16
|
+
export { createCrossTabCoordinator, applyJitter, type CrossTabCoordinator, type CrossTabMode, type CrossTabOptions, type TokenBroadcast, type LockManagerLike, type BroadcastChannelLike, } from './cross-tab.js';
|
|
17
|
+
export { TENANT_DIRECTORY_QUERY, type TenantDirectoryEntry, type TenantDirectoryLoader, } from './tenant-directory.js';
|
|
18
|
+
export { SessionManager, createSessionManager, isSupersededError, REFRESH_TOKEN_SUPERSEDED, type SessionManagerOptions, type SignOutResult, type TokenPair, } from './session-manager.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { TENANT_DIRECTORY_QUERY, createOAuthStateStore } from '../chunk-GPWGOYCA.js';
|
|
2
|
+
export { REFRESH_TOKEN_SUPERSEDED, SessionManager, applyJitter, createCrossTabCoordinator, createDefaultStorage, createMemoryStorage, createSessionManager, createSessionStorage, createTenantSelection, isSupersededError } from '../chunk-7WAV52EO.js';
|
|
3
|
+
export { DeadSessionError, MPFClaimsError, MPFUnsupportedClaimsVersionError, canSelect, clearClaimsCache, decodeAccessToken, defaultRoleForTenant, expiresAt, isDeadSession, isExpired, listTenants, registerClaimsParser, rolesForTenant, supportedClaimsVersions, tokenLifetimeSeconds, tryDecodeAccessToken } from '../chunk-AJWM5MDZ.js';
|
|
4
|
+
//# sourceMappingURL=index.js.map
|
|
5
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"names":[],"mappings":"","file":"index.js"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth `state` persistence — CSRF protection for the Google OAuth flow.
|
|
3
|
+
*
|
|
4
|
+
* ## What the state is for
|
|
5
|
+
*
|
|
6
|
+
* Without it, an OAuth callback route accepts *any* `code` presented to it. An
|
|
7
|
+
* attacker who obtains an authorization code for their own account can send the
|
|
8
|
+
* victim to `.../callback?code=<attacker's code>`; the victim's browser posts it,
|
|
9
|
+
* the server exchanges it, and the victim is now signed in **as the attacker** —
|
|
10
|
+
* whose account the attacker still controls. Anything the victim then does
|
|
11
|
+
* (entering payment details, uploading documents, accepting an invitation)
|
|
12
|
+
* happens inside the attacker's account.
|
|
13
|
+
*
|
|
14
|
+
* That is not theoretical: it was confirmed exploitable end to end in the
|
|
15
|
+
* reference boilerplate, whose callback route discarded `state` and auto-posted
|
|
16
|
+
* `code` on page load with no user interaction.
|
|
17
|
+
*
|
|
18
|
+
* The state closes it: the server mints an unguessable value, the client stores
|
|
19
|
+
* it before redirecting, and the server accepts the callback only if the value
|
|
20
|
+
* comes back. An attacker cannot produce a state that matches the victim's
|
|
21
|
+
* browser storage.
|
|
22
|
+
*
|
|
23
|
+
* ## Why this store exists
|
|
24
|
+
*
|
|
25
|
+
* The state must survive a **full-page redirect to Google and back**, so it
|
|
26
|
+
* cannot live in memory or in React state. That leaves web storage, and
|
|
27
|
+
* "remember to persist a value across a redirect" is precisely the step the
|
|
28
|
+
* boilerplate omitted. This makes it one call each way.
|
|
29
|
+
*
|
|
30
|
+
* Sending the state stays explicit: `GoogleOAuthCallbackRequest.state` is
|
|
31
|
+
* required, so TypeScript rejects a callback that forgets it. This store solves
|
|
32
|
+
* *where to keep it*, not *whether to send it* — a security parameter should not
|
|
33
|
+
* be invisibly injected.
|
|
34
|
+
*/
|
|
35
|
+
import { type MPFAuthStorage } from './storage.js';
|
|
36
|
+
export interface OAuthStateStore {
|
|
37
|
+
/** Persist the state returned by `getGoogleOAuthURL`, before redirecting. */
|
|
38
|
+
save(state: string): void;
|
|
39
|
+
/**
|
|
40
|
+
* Read and **clear** the stored state, for sending on the callback.
|
|
41
|
+
*
|
|
42
|
+
* Single-use by design: a state that could be replayed is not much better than
|
|
43
|
+
* no state at all, and the server also rejects reuse.
|
|
44
|
+
*/
|
|
45
|
+
take(): string | null;
|
|
46
|
+
/** Read without clearing. For diagnostics; prefer `take()` in the flow. */
|
|
47
|
+
peek(): string | null;
|
|
48
|
+
/** Discard any pending state (e.g. the user abandoned the flow). */
|
|
49
|
+
clear(): void;
|
|
50
|
+
}
|
|
51
|
+
export interface OAuthStateStoreOptions {
|
|
52
|
+
/**
|
|
53
|
+
* Where to keep the pending state.
|
|
54
|
+
*
|
|
55
|
+
* Defaults to `sessionStorage`, which is the right default for a redirect
|
|
56
|
+
* flow: it is scoped to the tab that started it, so a state minted in one tab
|
|
57
|
+
* cannot be consumed by another, and it is cleared when the tab closes.
|
|
58
|
+
*
|
|
59
|
+
* **Limitation worth knowing:** `sessionStorage` is per-tab, so a callback
|
|
60
|
+
* that lands in a *different* tab (a provider opening a new tab, or the user
|
|
61
|
+
* moving the URL) will not find the state and the callback will be rejected —
|
|
62
|
+
* failing closed, which is the correct direction, but it will look like a
|
|
63
|
+
* spurious error. Pass `createDefaultStorage()` (localStorage) if your flow
|
|
64
|
+
* can land in another tab, accepting that the state is then visible to every
|
|
65
|
+
* tab on the origin.
|
|
66
|
+
*
|
|
67
|
+
* A cookie is not offered: one set from JavaScript cannot be `httpOnly`, so it
|
|
68
|
+
* is no less readable than web storage while adding `SameSite` pitfalls on the
|
|
69
|
+
* cross-site return leg.
|
|
70
|
+
*/
|
|
71
|
+
storage?: MPFAuthStorage;
|
|
72
|
+
/** Storage key. @default 'mpf.oauth_state' */
|
|
73
|
+
storageKey?: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Create a store for the pending OAuth state.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* const oauthState = createOAuthStateStore();
|
|
81
|
+
* const auth = createAuthClient({ baseUrl, oauthStateStore: oauthState });
|
|
82
|
+
*
|
|
83
|
+
* // Starting the flow — the client saves the state for you.
|
|
84
|
+
* const { url } = await auth.getGoogleOAuthURL({ redirect_uri });
|
|
85
|
+
* window.location.href = url;
|
|
86
|
+
*
|
|
87
|
+
* // On the callback route, send it back explicitly.
|
|
88
|
+
* const state = oauthState.take();
|
|
89
|
+
* if (!state) throw new Error('No pending OAuth flow in this tab');
|
|
90
|
+
* const tokens = await auth.handleGoogleOAuthCallback({ code, redirect_uri, state });
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export declare function createOAuthStateStore(options?: OAuthStateStoreOptions): OAuthStateStore;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session manager — single access token, single refresh token.
|
|
3
|
+
*
|
|
4
|
+
* There is deliberately no per-tenant token map, no `renewScoped`, and no
|
|
5
|
+
* scoped/general token split: tenant is a request header now, so all of that
|
|
6
|
+
* machinery is gone by construction rather than by choice.
|
|
7
|
+
*
|
|
8
|
+
* What remains, and why:
|
|
9
|
+
* - **Coalescing** — the refresh token is single-use, so concurrent renewals
|
|
10
|
+
* must serialize into one network call or they kill each other.
|
|
11
|
+
* - **Proactive timer** — renew before expiry so requests never pay for it.
|
|
12
|
+
* - **Wake handling** — `setTimeout` does not fire in frozen or discarded tabs,
|
|
13
|
+
* so the timer alone is not a safety net. See `handleWake`.
|
|
14
|
+
* - **Cross-tab lock** — N tabs of one browser share one refresh token.
|
|
15
|
+
*/
|
|
16
|
+
import type { MPFClaims } from './claims-types.js';
|
|
17
|
+
import { DeadSessionError } from './errors.js';
|
|
18
|
+
import { type MPFAuthStorage } from './storage.js';
|
|
19
|
+
import { type CrossTabCoordinator } from './cross-tab.js';
|
|
20
|
+
/** Outcome of {@link SessionManager.signOut}. */
|
|
21
|
+
export interface SignOutResult {
|
|
22
|
+
/** Whether the server-side revoke succeeded. */
|
|
23
|
+
revoked: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Why the revoke failed, when it did. Local state is cleared regardless, so
|
|
26
|
+
* this is informational — surface it if you want to tell the user the session
|
|
27
|
+
* may still be live elsewhere.
|
|
28
|
+
*/
|
|
29
|
+
error?: Error;
|
|
30
|
+
}
|
|
31
|
+
/** A token pair as returned by `POST /v0/sessions/renew`. */
|
|
32
|
+
export interface TokenPair {
|
|
33
|
+
access_token: string;
|
|
34
|
+
refresh_token: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The server's code when a renew loses the rotation race — another tab renewed
|
|
38
|
+
* with the same refresh token first.
|
|
39
|
+
*
|
|
40
|
+
* This is recoverable: the winner's token is valid, so adopt it instead of
|
|
41
|
+
* declaring the session dead.
|
|
42
|
+
*/
|
|
43
|
+
export declare const REFRESH_TOKEN_SUPERSEDED = "REFRESH_TOKEN_SUPERSEDED";
|
|
44
|
+
export interface SessionManagerOptions {
|
|
45
|
+
/**
|
|
46
|
+
* Performs the network renewal. Injected rather than taking an `AuthClientV0`
|
|
47
|
+
* so this module stays a leaf and cannot form an import cycle with the
|
|
48
|
+
* identity client that consumes it.
|
|
49
|
+
*/
|
|
50
|
+
renew: (refreshToken: string) => Promise<TokenPair>;
|
|
51
|
+
/**
|
|
52
|
+
* Revokes the session server-side. Wire to
|
|
53
|
+
* `(refreshToken) => authClient.revokeSession({ refresh_token: refreshToken })`.
|
|
54
|
+
*
|
|
55
|
+
* Injected for the same reason as {@link renew}: it keeps `src/auth/` a leaf.
|
|
56
|
+
*
|
|
57
|
+
* Supplying it is what makes {@link SessionManager.signOut} revoke rather than
|
|
58
|
+
* only clearing locally. Without it, `signOut()` still clears — a sign-out
|
|
59
|
+
* button must always sign the user out — but the refresh token stays valid
|
|
60
|
+
* server-side until it expires.
|
|
61
|
+
*/
|
|
62
|
+
revoke?: (refreshToken: string) => Promise<unknown>;
|
|
63
|
+
/** Where the refresh token is persisted. @default localStorage, else memory */
|
|
64
|
+
storage?: MPFAuthStorage;
|
|
65
|
+
/** Storage key for the refresh token. @default 'mpf.refresh_token' */
|
|
66
|
+
storageKey?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Seconds before expiry at which a token is considered stale.
|
|
69
|
+
*
|
|
70
|
+
* @default 120
|
|
71
|
+
*
|
|
72
|
+
* 120 rather than 60: at a 900s TTL, a request *starting* at `exp − 61s`
|
|
73
|
+
* passes a 60s freshness check and then carries a token that dies mid-flight,
|
|
74
|
+
* which storage uploads routinely will. The buffer must cover renew latency
|
|
75
|
+
* plus the longest in-flight request, and browser clock skew is unbounded.
|
|
76
|
+
*
|
|
77
|
+
* Clamped per token — see {@link SessionManager.effectiveBufferSeconds}.
|
|
78
|
+
*/
|
|
79
|
+
bufferSeconds?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Minimum gap between two network renewals. Second line of defence against a
|
|
82
|
+
* renew loop caused by a misconfigured server TTL.
|
|
83
|
+
* @default 5000
|
|
84
|
+
*/
|
|
85
|
+
minRenewIntervalMs?: number;
|
|
86
|
+
/** Symmetric jitter on the proactive timer. @default 30000 */
|
|
87
|
+
jitterMs?: number;
|
|
88
|
+
/** Called when the session cannot be renewed and the user must log in again. */
|
|
89
|
+
onDeadSession?: (error: DeadSessionError) => void;
|
|
90
|
+
/** Called whenever the in-memory token pair changes. */
|
|
91
|
+
onTokensChanged?: (tokens: TokenPair | null) => void;
|
|
92
|
+
/**
|
|
93
|
+
* Cross-tab coordinator. Pass `null` to disable coordination entirely.
|
|
94
|
+
* @default createCrossTabCoordinator()
|
|
95
|
+
*/
|
|
96
|
+
crossTab?: CrossTabCoordinator | null;
|
|
97
|
+
/** Injectable clock, in ms. @default Date.now */
|
|
98
|
+
now?: () => number;
|
|
99
|
+
/** Injectable randomness for jitter. @default Math.random */
|
|
100
|
+
random?: () => number;
|
|
101
|
+
/**
|
|
102
|
+
* Whether to attach `visibilitychange` / `online` listeners.
|
|
103
|
+
* @default true when `window` exists
|
|
104
|
+
*/
|
|
105
|
+
listenToWake?: boolean;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Manages one session's tokens.
|
|
109
|
+
*/
|
|
110
|
+
export declare class SessionManager {
|
|
111
|
+
private readonly options;
|
|
112
|
+
private accessToken;
|
|
113
|
+
private refreshToken;
|
|
114
|
+
/** In-flight renewal, for coalescing. */
|
|
115
|
+
private inflight;
|
|
116
|
+
/**
|
|
117
|
+
* Gate that `getAccessToken()` awaits. Set by the wake path so that requests
|
|
118
|
+
* queued during a wake renewal block behind it instead of shipping a token
|
|
119
|
+
* that is already dead.
|
|
120
|
+
*/
|
|
121
|
+
private wakeGate;
|
|
122
|
+
private timer;
|
|
123
|
+
private lastRenewAt;
|
|
124
|
+
private destroyed;
|
|
125
|
+
/**
|
|
126
|
+
* Incremented on every `clear()`. A renewal captures it before its network
|
|
127
|
+
* call and refuses to adopt a result from a superseded generation, so signing
|
|
128
|
+
* out (or clearing) mid-renew cannot be undone by the renewal completing.
|
|
129
|
+
*/
|
|
130
|
+
private generation;
|
|
131
|
+
private readonly storage;
|
|
132
|
+
private readonly storageKey;
|
|
133
|
+
private readonly bufferSeconds;
|
|
134
|
+
private readonly minRenewIntervalMs;
|
|
135
|
+
private readonly jitterMs;
|
|
136
|
+
private readonly now;
|
|
137
|
+
private readonly random;
|
|
138
|
+
private readonly crossTab;
|
|
139
|
+
private readonly unsubscribeBroadcast;
|
|
140
|
+
private readonly wakeListening;
|
|
141
|
+
constructor(options: SessionManagerOptions);
|
|
142
|
+
/**
|
|
143
|
+
* Adopt a freshly minted token pair (after sign-in, or after any flow that
|
|
144
|
+
* changes memberships).
|
|
145
|
+
*/
|
|
146
|
+
setTokens(tokens: TokenPair): void;
|
|
147
|
+
/**
|
|
148
|
+
* A usable access token, renewing first if necessary.
|
|
149
|
+
*
|
|
150
|
+
* Returns `null` only when there is no session at all.
|
|
151
|
+
*/
|
|
152
|
+
getAccessToken(): Promise<string | null>;
|
|
153
|
+
/** The current access token without renewing. */
|
|
154
|
+
peekAccessToken(): string | null;
|
|
155
|
+
/** Decoded (UNVERIFIED — UX only) claims of the current access token. */
|
|
156
|
+
getClaims(): MPFClaims | null;
|
|
157
|
+
/**
|
|
158
|
+
* Renew the session. Coalesced: concurrent callers share one network call,
|
|
159
|
+
* which is mandatory because the refresh token is single-use.
|
|
160
|
+
*/
|
|
161
|
+
renew(): Promise<string>;
|
|
162
|
+
/**
|
|
163
|
+
* Sign out: revoke the session server-side, then clear it locally.
|
|
164
|
+
*
|
|
165
|
+
* **Never throws, and always clears.** A user who clicked sign-out must end up
|
|
166
|
+
* signed out locally whatever the network did — a UI that refuses to log out
|
|
167
|
+
* is worse than a token that outlives its client, and the failed revoke leaves
|
|
168
|
+
* that token behind either way. The outcome is reported in the return value so
|
|
169
|
+
* an app can say "signed out, but we couldn't reach the server" rather than
|
|
170
|
+
* having to guess.
|
|
171
|
+
*
|
|
172
|
+
* Order is load-bearing: `revokeSession` is an *authenticated* endpoint, so
|
|
173
|
+
* the revoke must go out **before** the access token is cleared. Reversing it
|
|
174
|
+
* silently sends an unauthenticated request that cannot succeed.
|
|
175
|
+
*
|
|
176
|
+
* Idempotent: with no session, it is a no-op reporting `revoked: false`.
|
|
177
|
+
*
|
|
178
|
+
* This exists so consumers never need the raw refresh token. Reaching into
|
|
179
|
+
* storage for it — `localStorage.getItem('mpf.refresh_token')` — hardcodes the
|
|
180
|
+
* default key and breaks for anyone using a custom storage or key.
|
|
181
|
+
*/
|
|
182
|
+
signOut(): Promise<SignOutResult>;
|
|
183
|
+
/**
|
|
184
|
+
* Forget the session locally. Stops timers and invalidates any in-flight
|
|
185
|
+
* renewal so it cannot resurrect the session after it resolves.
|
|
186
|
+
*
|
|
187
|
+
* Prefer {@link signOut} for a user-initiated sign-out: this leaves the
|
|
188
|
+
* refresh token valid server-side.
|
|
189
|
+
*/
|
|
190
|
+
clear(): void;
|
|
191
|
+
/** Detach listeners and release the coordinator. */
|
|
192
|
+
destroy(): void;
|
|
193
|
+
/**
|
|
194
|
+
* The buffer actually in force for the current token.
|
|
195
|
+
*
|
|
196
|
+
* The configured buffer is clamped to just under half the token's lifetime:
|
|
197
|
+
* if the buffer were ≥ half the lifetime, a freshly minted token would be
|
|
198
|
+
* "stale" the instant it arrived and the manager would renew-loop, hammering
|
|
199
|
+
* `/sessions/renew` forever. A 60s-TTL token therefore gets a 29s buffer, not
|
|
200
|
+
* the configured 120s.
|
|
201
|
+
*
|
|
202
|
+
* Falls back to the configured value when the token carries no `iat` and the
|
|
203
|
+
* lifetime is unknowable.
|
|
204
|
+
*/
|
|
205
|
+
effectiveBufferSeconds(): number;
|
|
206
|
+
private isStale;
|
|
207
|
+
private adopt;
|
|
208
|
+
private renewGuarded;
|
|
209
|
+
private networkRenew;
|
|
210
|
+
/**
|
|
211
|
+
* After a REFRESH_TOKEN_SUPERSEDED, take the winner's tokens.
|
|
212
|
+
*
|
|
213
|
+
* The winner's access token arrives over the broadcast channel; its refresh
|
|
214
|
+
* token is in storage. If neither is usable there is nothing to adopt and the
|
|
215
|
+
* caller declares the session dead.
|
|
216
|
+
*/
|
|
217
|
+
private adoptAfterSuperseded;
|
|
218
|
+
private die;
|
|
219
|
+
private cancelTimer;
|
|
220
|
+
/**
|
|
221
|
+
* Schedule a renewal shortly before the token goes stale.
|
|
222
|
+
*
|
|
223
|
+
* Jittered so that N tabs computing the same deadline from the same token do
|
|
224
|
+
* not all wake on the same millisecond.
|
|
225
|
+
*/
|
|
226
|
+
private scheduleProactiveRenew;
|
|
227
|
+
private readonly onWakeEvent;
|
|
228
|
+
/**
|
|
229
|
+
* Wake path — the real safety net.
|
|
230
|
+
*
|
|
231
|
+
* `setTimeout` does not fire in frozen or discarded tabs (Chrome tab
|
|
232
|
+
* freezing, iOS Safari), so a tab backgrounded for 20 minutes wakes holding a
|
|
233
|
+
* guaranteed-dead token with its timer never having run.
|
|
234
|
+
*
|
|
235
|
+
* Merely rescheduling here is wrong: the delay computes negative, becomes
|
|
236
|
+
* `setTimeout(0)`, and then races whatever the app fires on focus. Instead
|
|
237
|
+
* renew immediately and publish a gate that `getAccessToken()` awaits, so
|
|
238
|
+
* requests triggered by the same focus event queue behind the renewal rather
|
|
239
|
+
* than each eating a 401.
|
|
240
|
+
*/
|
|
241
|
+
private handleWake;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Whether an error is the server's "another tab renewed first" signal.
|
|
245
|
+
*
|
|
246
|
+
* Checks `serverCode` because `MPFAuthError.code` is derived from the error
|
|
247
|
+
* classification and collapses every 401 to `UNAUTHORIZED`.
|
|
248
|
+
*/
|
|
249
|
+
export declare function isSupersededError(error: unknown): boolean;
|
|
250
|
+
/**
|
|
251
|
+
* Create a session manager.
|
|
252
|
+
*/
|
|
253
|
+
export declare function createSessionManager(options: SessionManagerOptions): SessionManager;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable persistence for the session manager.
|
|
3
|
+
*
|
|
4
|
+
* Only the REFRESH token is persisted. The access token stays in memory: at a
|
|
5
|
+
* 15-minute TTL, persisting it buys almost nothing and it is the more dangerous
|
|
6
|
+
* of the two to leave sitting in `localStorage`.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Minimal synchronous key/value store.
|
|
10
|
+
*
|
|
11
|
+
* Matches the `localStorage` shape so the browser default is a pass-through.
|
|
12
|
+
*/
|
|
13
|
+
export interface MPFAuthStorage {
|
|
14
|
+
getItem(key: string): string | null;
|
|
15
|
+
setItem(key: string, value: string): void;
|
|
16
|
+
removeItem(key: string): void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* An in-memory store. Used automatically when `window` is absent (Node, SSR,
|
|
20
|
+
* the e2e suite) and available explicitly for tests.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createMemoryStorage(): MPFAuthStorage;
|
|
23
|
+
/**
|
|
24
|
+
* `sessionStorage` when it is usable, an in-memory store otherwise.
|
|
25
|
+
*
|
|
26
|
+
* Per-tab and cleared when the tab closes, which is what a redirect flow wants:
|
|
27
|
+
* see `createOAuthStateStore`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createSessionStorage(): MPFAuthStorage;
|
|
30
|
+
/**
|
|
31
|
+
* `localStorage` when it is usable, an in-memory store otherwise.
|
|
32
|
+
*
|
|
33
|
+
* Access is probed rather than assumed: Safari in private mode and blocked
|
|
34
|
+
* third-party contexts expose `localStorage` but throw on write.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createDefaultStorage(): MPFAuthStorage;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant display metadata — names and slugs.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is separate from the claim set
|
|
5
|
+
*
|
|
6
|
+
* The access token's `tnts` array carries tenant **ids and roles only**. That is
|
|
7
|
+
* the authoritative, freshest answer to "which tenants may this user select",
|
|
8
|
+
* and it needs no network call — but it renders a switcher full of UUIDs.
|
|
9
|
+
*
|
|
10
|
+
* Names live in the database (`identity.tenants`), so fetching them is a real
|
|
11
|
+
* network operation that can really fail. Keeping it in its own module, behind
|
|
12
|
+
* its own state, means the two questions stay distinguishable:
|
|
13
|
+
*
|
|
14
|
+
* - "which tenants can I select?" — claims, synchronous, cannot fail
|
|
15
|
+
* - "what are they called?" — network, asynchronous, can fail
|
|
16
|
+
*
|
|
17
|
+
* Merging them would make a failed name lookup indistinguishable from having no
|
|
18
|
+
* tenants, which is the exact bug class the tri-state exists to prevent.
|
|
19
|
+
*
|
|
20
|
+
* ## Reachability
|
|
21
|
+
*
|
|
22
|
+
* The `tenantless` Hasura role has select permissions on `identity_tenants`
|
|
23
|
+
* scoped to `X-Hasura-User-Id`, so a signed-in user with NO tenant selected can
|
|
24
|
+
* read the names of the tenants they belong to. That is precisely the switcher's
|
|
25
|
+
* situation.
|
|
26
|
+
*/
|
|
27
|
+
/** Display metadata for one tenant. */
|
|
28
|
+
export interface TenantDirectoryEntry {
|
|
29
|
+
id: string;
|
|
30
|
+
name?: string;
|
|
31
|
+
slug?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Loads display metadata for the given tenant ids.
|
|
35
|
+
*
|
|
36
|
+
* Injected rather than implemented here so that neither this module nor the
|
|
37
|
+
* React layer has to depend on the GraphQL client — which keeps `src/auth/` a
|
|
38
|
+
* leaf and the React layer free of `src/client/graphql/`. Use
|
|
39
|
+
* {@link TENANT_DIRECTORY_QUERY} with whichever GraphQL client the app already
|
|
40
|
+
* has.
|
|
41
|
+
*/
|
|
42
|
+
export type TenantDirectoryLoader = (tenantIds: string[]) => Promise<TenantDirectoryEntry[]>;
|
|
43
|
+
/**
|
|
44
|
+
* A ready-made query for the loader.
|
|
45
|
+
*
|
|
46
|
+
* Runs under the `tenantless` role — i.e. with no `X-MPF-Tenant` header — which
|
|
47
|
+
* is why a switcher can populate itself before any tenant is selected.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* import { TENANT_DIRECTORY_QUERY } from '@mosano-product-framework/sdk/auth';
|
|
52
|
+
*
|
|
53
|
+
* const loadTenantDirectory = async () => {
|
|
54
|
+
* const data = await gql.query(TENANT_DIRECTORY_QUERY, undefined, { tenant: null });
|
|
55
|
+
* return data.identity_tenants;
|
|
56
|
+
* };
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export declare const TENANT_DIRECTORY_QUERY = "query MPFTenantDirectory {\n identity_tenants {\n id\n name\n slug\n }\n}";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant selection — a dependency-free mutable leaf.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately imports nothing but its own types, so it can be constructed
|
|
5
|
+
* BEFORE any client and then shared by the identity, storage and GraphQL
|
|
6
|
+
* clients without an import cycle:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const tenants = createTenantSelection();
|
|
10
|
+
* const auth = createAuthClient({ baseUrl, tokenProvider, tenantProvider: tenants });
|
|
11
|
+
* const storage = createStorageClient({ baseUrl, tokenProvider, tenantProvider: tenants });
|
|
12
|
+
* tenants.set(tenantId); // both clients now scope to it, no reconstruction
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
import type { MPFRole } from './claims-types.js';
|
|
16
|
+
import type { MPFAuthStorage } from './storage.js';
|
|
17
|
+
/**
|
|
18
|
+
* The currently selected tenant.
|
|
19
|
+
*/
|
|
20
|
+
export interface TenantSelection {
|
|
21
|
+
/** Selected tenant id, or `null` for the no-tenant (`drl`) path. */
|
|
22
|
+
tenant: string | null;
|
|
23
|
+
/**
|
|
24
|
+
* Selected role. `undefined` lets the server apply that tenant's default
|
|
25
|
+
* role (`tnts[].dfr`, else `tnts[].rls[0]`).
|
|
26
|
+
*/
|
|
27
|
+
role?: MPFRole;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A mutable, observable tenant selection.
|
|
31
|
+
*/
|
|
32
|
+
export interface TenantSelectionStore {
|
|
33
|
+
/** Current selection. */
|
|
34
|
+
get(): TenantSelection;
|
|
35
|
+
/**
|
|
36
|
+
* Select a tenant.
|
|
37
|
+
*
|
|
38
|
+
* Synchronous by design — selecting a tenant is a header change, so there is
|
|
39
|
+
* no renew, no await and no network call.
|
|
40
|
+
*
|
|
41
|
+
* @param tenant - Tenant id, or `null` for the no-tenant path.
|
|
42
|
+
* @param role - Optional role; omit to let the server apply the default.
|
|
43
|
+
*/
|
|
44
|
+
set(tenant: string | null, role?: MPFRole): void;
|
|
45
|
+
/** Reset to the no-tenant path. */
|
|
46
|
+
clear(): void;
|
|
47
|
+
/**
|
|
48
|
+
* Observe changes. Returns an unsubscribe function.
|
|
49
|
+
*
|
|
50
|
+
* The listener is called only when the selection actually changes.
|
|
51
|
+
*/
|
|
52
|
+
subscribe(listener: (selection: TenantSelection) => void): () => void;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Persistence options for the tenant selection.
|
|
56
|
+
*
|
|
57
|
+
* The selected tenant IS persisted (unlike the access token). A tenant uuid the
|
|
58
|
+
* user demonstrably has access to is not credential material, and losing the
|
|
59
|
+
* selection on reload is a real UX regression: pick a tenant, refresh, land back
|
|
60
|
+
* in the no-tenant state. Reading it synchronously at construction is also what
|
|
61
|
+
* makes the provider's anti-flash hydration possible.
|
|
62
|
+
*
|
|
63
|
+
* Note this is a UX convenience only. A tampered value grants nothing — the
|
|
64
|
+
* server re-derives tenant and role from the verified token on every request and
|
|
65
|
+
* rejects a tenant the token does not hold.
|
|
66
|
+
*/
|
|
67
|
+
export interface TenantSelectionPersistence {
|
|
68
|
+
/** Where to store it. Omit to disable persistence. */
|
|
69
|
+
storage?: MPFAuthStorage;
|
|
70
|
+
/** Storage key. @default 'mpf.tenant_selection' */
|
|
71
|
+
storageKey?: string;
|
|
72
|
+
}
|
|
73
|
+
export interface CreateTenantSelectionOptions extends TenantSelectionPersistence {
|
|
74
|
+
/**
|
|
75
|
+
* Initial selection. Takes precedence over a persisted value, so an app can
|
|
76
|
+
* force a tenant (e.g. from a URL) without the stored one overriding it.
|
|
77
|
+
*/
|
|
78
|
+
initial?: TenantSelection;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Create a tenant selection store.
|
|
82
|
+
*
|
|
83
|
+
* Accepts either a bare initial selection (back-compatible) or an options
|
|
84
|
+
* object enabling persistence.
|
|
85
|
+
*
|
|
86
|
+
* @example
|
|
87
|
+
* ```ts
|
|
88
|
+
* const tenants = createTenantSelection({ storage: createDefaultStorage() });
|
|
89
|
+
* // survives a page reload
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export declare function createTenantSelection(initialOrOptions?: TenantSelection | CreateTenantSelectionOptions): TenantSelectionStore;
|