doover-js 0.8.0 → 0.9.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 +37 -0
- package/dist/apis/agents-api.d.ts +137 -0
- package/dist/apis/agents-api.js +90 -0
- package/dist/auth/build-auth.d.ts +3 -2
- package/dist/auth/build-auth.js +25 -5
- package/dist/auth/cookie-auth.d.ts +74 -1
- package/dist/auth/cookie-auth.js +172 -4
- package/dist/client/capabilities.d.ts +1 -1
- package/dist/client/capabilities.js +2 -0
- package/dist/client/local-agent-client.js +7 -0
- package/dist/client/multiplex-client.js +7 -0
- package/dist/index.d.ts +2 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +10 -1
- package/dist/react/useAgentTokenState.d.ts +70 -0
- package/dist/react/useAgentTokenState.js +137 -0
- package/dist/test/auth.test.js +187 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,6 +37,43 @@ const client = new DooverClient({
|
|
|
37
37
|
const channels = await client.viewer.getChannels({ agentId: "123" });
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
### Cookie usage with session refresh (browser apps behind FusionAuth)
|
|
41
|
+
|
|
42
|
+
Pass `authServerUrl` — and no token material — to get a `CookieAuth` that can
|
|
43
|
+
renew the session itself. The client then POSTs the hosted-backend refresh
|
|
44
|
+
endpoint (`/app/refresh/` by default) when the access token has expired: before
|
|
45
|
+
a request goes out, and again if one comes back 401, replaying it once.
|
|
46
|
+
|
|
47
|
+
This exists because the FusionAuth React SDK's auto-refresh is a single
|
|
48
|
+
`setTimeout`. A tab the browser suspends (backgrounded, machine asleep) never
|
|
49
|
+
fires it, so it wakes with a dead access-token cookie and 401s on everything —
|
|
50
|
+
even though the refresh token itself is usually still valid.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const client = new DooverClient({
|
|
54
|
+
dataRestUrl: "https://example.com/api",
|
|
55
|
+
controlApiUrl: "https://example.com/control",
|
|
56
|
+
dataWssUrl: "wss://example.com/gateway",
|
|
57
|
+
authServerUrl: "https://auth.example.com",
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`CookieAuth` also exposes the session state apps need for their own lifecycle
|
|
62
|
+
hooks — typically a proactive refresh when a tab regains visibility:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
const auth = client.auth as CookieAuth;
|
|
66
|
+
|
|
67
|
+
document.addEventListener("visibilitychange", () => {
|
|
68
|
+
if (document.visibilityState !== "visible") return;
|
|
69
|
+
if (!auth.isAccessTokenStale(5 * 60_000)) return;
|
|
70
|
+
void auth.refreshAccessToken();
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Without `authServerUrl`, `CookieAuth` behaves exactly as before: ambient
|
|
75
|
+
cookies, no refresh.
|
|
76
|
+
|
|
40
77
|
### Explicit token usage
|
|
41
78
|
|
|
42
79
|
Pass a token directly to use bearer auth. The client will send `Authorization: Bearer <token>` on every HTTP request and use `credentials: "omit"`.
|
|
@@ -19,6 +19,65 @@ export interface MultiAgentMessagesParams {
|
|
|
19
19
|
agent_message_limit?: number;
|
|
20
20
|
field_name?: string[];
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* One token-posture knob across all three layers, so a UI can show
|
|
24
|
+
* "inherited from organisation" vs "overridden on this device" and offer a reset
|
|
25
|
+
* without duplicating the precedence rule client-side.
|
|
26
|
+
*/
|
|
27
|
+
export interface TokenPolicyField<T> {
|
|
28
|
+
/** What is actually enforced: `agent_override` if set, else `org_default`. */
|
|
29
|
+
effective: T | null;
|
|
30
|
+
/** This device's override. `null` means it inherits. */
|
|
31
|
+
agent_override: T | null;
|
|
32
|
+
/** The organisation-wide default inherited when not overridden. */
|
|
33
|
+
org_default: T | null;
|
|
34
|
+
}
|
|
35
|
+
export interface AgentTokenState {
|
|
36
|
+
/**
|
|
37
|
+
* Unix-ms revocation floor: every long-lived credential issued before this is
|
|
38
|
+
* rejected. `null` means nothing has ever been revoked for this agent.
|
|
39
|
+
*/
|
|
40
|
+
tokens_valid_from: number | null;
|
|
41
|
+
/** Auth kill-switch — while true, every auth attempt is rejected. */
|
|
42
|
+
auth_locked: boolean;
|
|
43
|
+
self_rotation: TokenPolicyField<boolean>;
|
|
44
|
+
max_age_ms: TokenPolicyField<number>;
|
|
45
|
+
reuse_grace_ms: TokenPolicyField<number>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A token-posture override. `null` on any field means "inherit the organisation
|
|
49
|
+
* default"; there is no way to express "explicitly off, ignoring the default",
|
|
50
|
+
* because every knob here is off when absent.
|
|
51
|
+
*/
|
|
52
|
+
export interface AgentTokenPolicy {
|
|
53
|
+
/** Allow the device to trade its own credential for a fresh one. */
|
|
54
|
+
self_rotation?: boolean | null;
|
|
55
|
+
/**
|
|
56
|
+
* Hard maximum credential age. Only valid alongside `self_rotation` — without
|
|
57
|
+
* it the device cannot rotate before the deadline and will be locked out.
|
|
58
|
+
*/
|
|
59
|
+
max_age_ms?: number | null;
|
|
60
|
+
/**
|
|
61
|
+
* Grace window after a rotation before a stale credential counts as reuse.
|
|
62
|
+
* Setting this opts the device into *automatic* lockout on reuse detection.
|
|
63
|
+
*/
|
|
64
|
+
reuse_grace_ms?: number | null;
|
|
65
|
+
}
|
|
66
|
+
export interface ResourcePermission {
|
|
67
|
+
permission_id: string;
|
|
68
|
+
permission: string;
|
|
69
|
+
}
|
|
70
|
+
export interface AdhocTokenResponse {
|
|
71
|
+
/** Shown once. Not retrievable afterwards. */
|
|
72
|
+
token: string;
|
|
73
|
+
token_id: string | null;
|
|
74
|
+
}
|
|
75
|
+
export interface DeviceTokenResponse {
|
|
76
|
+
/** Shown once. Not stored anywhere and not retrievable afterwards. */
|
|
77
|
+
token: string;
|
|
78
|
+
/** Unix-ms issuance time, which is what the revocation floor compares against. */
|
|
79
|
+
issued_at_ms: number | null;
|
|
80
|
+
}
|
|
22
81
|
export interface MultiAgentAggregatesParams {
|
|
23
82
|
agent_id: string[];
|
|
24
83
|
/**
|
|
@@ -56,4 +115,82 @@ export declare class AgentsApi {
|
|
|
56
115
|
results: AgentAggregate[];
|
|
57
116
|
count: number;
|
|
58
117
|
}>;
|
|
118
|
+
/**
|
|
119
|
+
* Read an agent's credential state: the revocation floor, the auth
|
|
120
|
+
* kill-switch, and the token posture resolved across the per-agent override
|
|
121
|
+
* and the organisation default.
|
|
122
|
+
*
|
|
123
|
+
* Requires `AgentTokenRead`, which is read-only — a caller can display
|
|
124
|
+
* credential state (including an auth-lock banner, often the only explanation
|
|
125
|
+
* for why a device is offline) without being able to change any of it.
|
|
126
|
+
*/
|
|
127
|
+
getTokenState(agentId: string): Promise<AgentTokenState>;
|
|
128
|
+
/**
|
|
129
|
+
* Replace an agent's token-posture override. Omitted or null fields mean
|
|
130
|
+
* "inherit the organisation default" — a full replacement rather than a
|
|
131
|
+
* merge, so clearing an override back to inherited is expressible.
|
|
132
|
+
*
|
|
133
|
+
* Rejected server-side if the resolved posture sets `max_age_ms` without
|
|
134
|
+
* `self_rotation`: a device that can't self-rotate has no way to trade its
|
|
135
|
+
* token in before the max age elapses, making that combination a scheduled
|
|
136
|
+
* outage.
|
|
137
|
+
*/
|
|
138
|
+
setTokenPolicy(agentId: string, policy: AgentTokenPolicy): Promise<{
|
|
139
|
+
effective: AgentTokenPolicy;
|
|
140
|
+
}>;
|
|
141
|
+
/**
|
|
142
|
+
* Set or clear the auth kill-switch. While locked, every auth attempt for the
|
|
143
|
+
* agent is rejected regardless of token validity.
|
|
144
|
+
*
|
|
145
|
+
* doover-data sets this automatically on suspected token reuse, so clearing it
|
|
146
|
+
* is a routine incident-response action rather than an exotic one.
|
|
147
|
+
*/
|
|
148
|
+
setAuthLock(agentId: string, locked: boolean): Promise<void>;
|
|
149
|
+
/**
|
|
150
|
+
* Revoke every outstanding long-lived credential for an agent by advancing the
|
|
151
|
+
* revocation floor. Mints nothing.
|
|
152
|
+
*
|
|
153
|
+
* This takes the device offline until it is re-provisioned. Break-glass:
|
|
154
|
+
* confirm before calling.
|
|
155
|
+
*/
|
|
156
|
+
revokeAllTokens(agentId: string): Promise<{
|
|
157
|
+
tokens_valid_from: number;
|
|
158
|
+
}>;
|
|
159
|
+
/**
|
|
160
|
+
* Set the revocation floor to an explicit point, in either direction. `0` or
|
|
161
|
+
* `null` clears it.
|
|
162
|
+
*
|
|
163
|
+
* `revokeAllTokens` is the normal way to revoke. Lowering the floor reverses a
|
|
164
|
+
* revocation, so it is for undoing one made by mistake — not for recovering
|
|
165
|
+
* from a leak, where you should issue fresh credentials instead.
|
|
166
|
+
*/
|
|
167
|
+
setRevocationFloor(agentId: string, tokensValidFrom: number | null): Promise<{
|
|
168
|
+
tokens_valid_from: number | null;
|
|
169
|
+
}>;
|
|
170
|
+
/**
|
|
171
|
+
* Mint a short-lived scoped JWT for ad-hoc access to an agent — debugging, a
|
|
172
|
+
* one-off script, an integration.
|
|
173
|
+
*
|
|
174
|
+
* This is the way to give a *person* access to a device. The device's own
|
|
175
|
+
* long-lived credential is deliberately not obtainable through the API: it is
|
|
176
|
+
* minted into an installer bundle and never shown. Expiry stands in for
|
|
177
|
+
* revocation here, so keep `timeout` as short as the task allows.
|
|
178
|
+
*/
|
|
179
|
+
createAdhocToken(agentId: string, options?: {
|
|
180
|
+
permissions?: ResourcePermission[];
|
|
181
|
+
timeout?: number;
|
|
182
|
+
}): Promise<AdhocTokenResponse>;
|
|
183
|
+
/**
|
|
184
|
+
* Mint the device's long-lived credential and return it once, for manual
|
|
185
|
+
* provisioning — pasting into a device's config by hand. The installer download
|
|
186
|
+
* mints one into the bundle for you; this is the equivalent for a device you're
|
|
187
|
+
* configuring yourself.
|
|
188
|
+
*
|
|
189
|
+
* Always additive: the revocation floor is left alone, so minting can never
|
|
190
|
+
* knock a running device offline. `revoke_existing` is intentionally not
|
|
191
|
+
* exposed here — revoking is `revokeAllTokens`, a separate deliberate act.
|
|
192
|
+
*
|
|
193
|
+
* Returned once and stored nowhere. If it's lost, mint another.
|
|
194
|
+
*/
|
|
195
|
+
createDeviceToken(agentId: string): Promise<DeviceTokenResponse>;
|
|
59
196
|
}
|
package/dist/apis/agents-api.js
CHANGED
|
@@ -232,6 +232,96 @@ class AgentsApi {
|
|
|
232
232
|
count: responses.reduce((acc, r) => acc + r.count, 0),
|
|
233
233
|
};
|
|
234
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* Read an agent's credential state: the revocation floor, the auth
|
|
237
|
+
* kill-switch, and the token posture resolved across the per-agent override
|
|
238
|
+
* and the organisation default.
|
|
239
|
+
*
|
|
240
|
+
* Requires `AgentTokenRead`, which is read-only — a caller can display
|
|
241
|
+
* credential state (including an auth-lock banner, often the only explanation
|
|
242
|
+
* for why a device is offline) without being able to change any of it.
|
|
243
|
+
*/
|
|
244
|
+
getTokenState(agentId) {
|
|
245
|
+
return this.rest.get(`/agents/${agentId}/token_state`);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Replace an agent's token-posture override. Omitted or null fields mean
|
|
249
|
+
* "inherit the organisation default" — a full replacement rather than a
|
|
250
|
+
* merge, so clearing an override back to inherited is expressible.
|
|
251
|
+
*
|
|
252
|
+
* Rejected server-side if the resolved posture sets `max_age_ms` without
|
|
253
|
+
* `self_rotation`: a device that can't self-rotate has no way to trade its
|
|
254
|
+
* token in before the max age elapses, making that combination a scheduled
|
|
255
|
+
* outage.
|
|
256
|
+
*/
|
|
257
|
+
setTokenPolicy(agentId, policy) {
|
|
258
|
+
return this.rest.put(`/agents/${agentId}/token_policy`, policy);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Set or clear the auth kill-switch. While locked, every auth attempt for the
|
|
262
|
+
* agent is rejected regardless of token validity.
|
|
263
|
+
*
|
|
264
|
+
* doover-data sets this automatically on suspected token reuse, so clearing it
|
|
265
|
+
* is a routine incident-response action rather than an exotic one.
|
|
266
|
+
*/
|
|
267
|
+
setAuthLock(agentId, locked) {
|
|
268
|
+
return this.rest.post(`/agents/${agentId}/auth_lock`, { locked });
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Revoke every outstanding long-lived credential for an agent by advancing the
|
|
272
|
+
* revocation floor. Mints nothing.
|
|
273
|
+
*
|
|
274
|
+
* This takes the device offline until it is re-provisioned. Break-glass:
|
|
275
|
+
* confirm before calling.
|
|
276
|
+
*/
|
|
277
|
+
revokeAllTokens(agentId) {
|
|
278
|
+
return this.rest.post(`/agents/${agentId}/token/revoke_all`, {});
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Set the revocation floor to an explicit point, in either direction. `0` or
|
|
282
|
+
* `null` clears it.
|
|
283
|
+
*
|
|
284
|
+
* `revokeAllTokens` is the normal way to revoke. Lowering the floor reverses a
|
|
285
|
+
* revocation, so it is for undoing one made by mistake — not for recovering
|
|
286
|
+
* from a leak, where you should issue fresh credentials instead.
|
|
287
|
+
*/
|
|
288
|
+
setRevocationFloor(agentId, tokensValidFrom) {
|
|
289
|
+
return this.rest.post(`/agents/${agentId}/token/floor`, { tokens_valid_from: tokensValidFrom });
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Mint a short-lived scoped JWT for ad-hoc access to an agent — debugging, a
|
|
293
|
+
* one-off script, an integration.
|
|
294
|
+
*
|
|
295
|
+
* This is the way to give a *person* access to a device. The device's own
|
|
296
|
+
* long-lived credential is deliberately not obtainable through the API: it is
|
|
297
|
+
* minted into an installer bundle and never shown. Expiry stands in for
|
|
298
|
+
* revocation here, so keep `timeout` as short as the task allows.
|
|
299
|
+
*/
|
|
300
|
+
createAdhocToken(agentId, options = {}) {
|
|
301
|
+
return this.rest.post(`/agents/${agentId}/token`, {
|
|
302
|
+
format: "jwt",
|
|
303
|
+
permissions: options.permissions ?? [],
|
|
304
|
+
timeout: options.timeout,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Mint the device's long-lived credential and return it once, for manual
|
|
309
|
+
* provisioning — pasting into a device's config by hand. The installer download
|
|
310
|
+
* mints one into the bundle for you; this is the equivalent for a device you're
|
|
311
|
+
* configuring yourself.
|
|
312
|
+
*
|
|
313
|
+
* Always additive: the revocation floor is left alone, so minting can never
|
|
314
|
+
* knock a running device offline. `revoke_existing` is intentionally not
|
|
315
|
+
* exposed here — revoking is `revokeAllTokens`, a separate deliberate act.
|
|
316
|
+
*
|
|
317
|
+
* Returned once and stored nowhere. If it's lost, mint another.
|
|
318
|
+
*/
|
|
319
|
+
createDeviceToken(agentId) {
|
|
320
|
+
return this.rest.post(`/agents/${agentId}/token`, {
|
|
321
|
+
format: "mini_v1",
|
|
322
|
+
revoke_existing: false,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
235
325
|
}
|
|
236
326
|
exports.AgentsApi = AgentsApi;
|
|
237
327
|
// CloudFront fronts the channels-rest API with a hard 8,192-byte URL quota.
|
|
@@ -28,10 +28,11 @@ interface BuildAuthOptions extends AuthConfig {
|
|
|
28
28
|
*
|
|
29
29
|
* Rules:
|
|
30
30
|
* 1. If `auth` is provided it is returned as-is.
|
|
31
|
-
* 2. If any token
|
|
31
|
+
* 2. If any token material is present, build `DooverTokenAuth`.
|
|
32
32
|
* 3. If `profile` is an `AuthProfile`, use it to seed a `DooverTokenAuth`.
|
|
33
33
|
* 4. If `profile` is a string, look it up via `configManager`.
|
|
34
|
-
* 5. Otherwise fall back to `CookieAuth
|
|
34
|
+
* 5. Otherwise fall back to `CookieAuth`, refresh-capable when
|
|
35
|
+
* `authServerUrl` is known.
|
|
35
36
|
*/
|
|
36
37
|
export declare function buildAuth(options: BuildAuthOptions): DooverAuth;
|
|
37
38
|
export {};
|
package/dist/auth/build-auth.js
CHANGED
|
@@ -12,15 +12,31 @@ const TOKEN_FIELDS = [
|
|
|
12
12
|
"authServerUrl",
|
|
13
13
|
"authServerClientId",
|
|
14
14
|
];
|
|
15
|
+
/**
|
|
16
|
+
* The subset of `TOKEN_FIELDS` that actually implies bearer-token auth.
|
|
17
|
+
*
|
|
18
|
+
* `authServerUrl` / `authServerClientId` describe *where* to refresh, not what
|
|
19
|
+
* we hold: on their own they now configure a refresh-capable `CookieAuth`
|
|
20
|
+
* (a browser session in an app that knows its auth server), which is far more
|
|
21
|
+
* useful than the tokenless `DooverTokenAuth` they used to produce — that
|
|
22
|
+
* instance could only ever throw "missing refreshToken" on first use.
|
|
23
|
+
*/
|
|
24
|
+
const TOKEN_MATERIAL_FIELDS = [
|
|
25
|
+
"token",
|
|
26
|
+
"tokenExpires",
|
|
27
|
+
"refreshToken",
|
|
28
|
+
"refreshTokenId",
|
|
29
|
+
];
|
|
15
30
|
/**
|
|
16
31
|
* Build a `DooverAuth` instance from the provided configuration.
|
|
17
32
|
*
|
|
18
33
|
* Rules:
|
|
19
34
|
* 1. If `auth` is provided it is returned as-is.
|
|
20
|
-
* 2. If any token
|
|
35
|
+
* 2. If any token material is present, build `DooverTokenAuth`.
|
|
21
36
|
* 3. If `profile` is an `AuthProfile`, use it to seed a `DooverTokenAuth`.
|
|
22
37
|
* 4. If `profile` is a string, look it up via `configManager`.
|
|
23
|
-
* 5. Otherwise fall back to `CookieAuth
|
|
38
|
+
* 5. Otherwise fall back to `CookieAuth`, refresh-capable when
|
|
39
|
+
* `authServerUrl` is known.
|
|
24
40
|
*/
|
|
25
41
|
function buildAuth(options) {
|
|
26
42
|
if (options.auth) {
|
|
@@ -48,11 +64,15 @@ function buildAuth(options) {
|
|
|
48
64
|
resolvedProfile = options.profile;
|
|
49
65
|
}
|
|
50
66
|
// Determine whether we need token auth.
|
|
51
|
-
const hasTokenInput =
|
|
67
|
+
const hasTokenInput = TOKEN_MATERIAL_FIELDS.some((k) => options[k] !== undefined);
|
|
52
68
|
const profileHasToken = resolvedProfile?.token != null;
|
|
53
69
|
if (!hasTokenInput && !profileHasToken) {
|
|
54
|
-
// No token data at all — fall back to cookie auth
|
|
55
|
-
|
|
70
|
+
// No token data at all — fall back to cookie auth, which can still renew
|
|
71
|
+
// the session when the app has told us where its auth server lives.
|
|
72
|
+
const auth = new cookie_auth_1.CookieAuth({
|
|
73
|
+
authServerUrl: options.authServerUrl ?? resolvedProfile?.authServerUrl ?? null,
|
|
74
|
+
fetchImpl: options.fetchImpl,
|
|
75
|
+
});
|
|
56
76
|
if (resolvedProfile) {
|
|
57
77
|
auth.attachProfile(resolvedProfile, options.configManager);
|
|
58
78
|
}
|
|
@@ -1,11 +1,49 @@
|
|
|
1
1
|
import { DooverAuth } from "./doover-auth";
|
|
2
|
+
export interface CookieAuthOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Base URL of the auth server that serves the hosted-backend refresh
|
|
5
|
+
* endpoint. Required for any refresh behaviour; without it `CookieAuth`
|
|
6
|
+
* behaves exactly as it always has (no refresh, no recovery).
|
|
7
|
+
*/
|
|
8
|
+
authServerUrl?: string | null;
|
|
9
|
+
/** Refresh endpoint path. Defaults to `/app/refresh/`. */
|
|
10
|
+
refreshPath?: string;
|
|
11
|
+
/** Name of the access-token expiry cookie. Defaults to `app.at_exp`. */
|
|
12
|
+
accessTokenExpiryCookieName?: string;
|
|
13
|
+
/** Override `fetch` (tests, native hosts). */
|
|
14
|
+
fetchImpl?: typeof fetch;
|
|
15
|
+
/** Override how the cookie string is read. Defaults to `document.cookie`. */
|
|
16
|
+
cookieReader?: () => string;
|
|
17
|
+
}
|
|
2
18
|
/**
|
|
3
19
|
* Cookie-based auth — the default browser strategy.
|
|
4
20
|
*
|
|
5
21
|
* Relies on ambient cookies (`credentials: "include"`) and does not add any
|
|
6
|
-
* `Authorization` header.
|
|
22
|
+
* `Authorization` header. The access token itself is httpOnly and invisible to
|
|
23
|
+
* JS; the only readable part of the session is a companion expiry cookie
|
|
24
|
+
* (`app.at_exp`).
|
|
25
|
+
*
|
|
26
|
+
* ## Why this refreshes
|
|
27
|
+
*
|
|
28
|
+
* The FusionAuth React SDK's auto-refresh is a single `setTimeout` per token.
|
|
29
|
+
* A browser that suspends the tab (backgrounded, laptop asleep) never fires it,
|
|
30
|
+
* so the tab wakes with an expired access-token cookie and every request 401s —
|
|
31
|
+
* even though the refresh token itself is usually still valid. Rather than
|
|
32
|
+
* leaving every browser app to work around that on its own, `CookieAuth`
|
|
33
|
+
* handles it whenever `authServerUrl` is configured: `ensureReady` renews an
|
|
34
|
+
* already-expired token before a request goes out (which also covers gateway
|
|
35
|
+
* reconnects, since `GatewayClient` awaits it before opening a socket), and
|
|
36
|
+
* `handleUnauthorized` lets `RestClient` replay a 401 once after a renewal.
|
|
7
37
|
*/
|
|
8
38
|
export declare class CookieAuth extends DooverAuth {
|
|
39
|
+
private readonly authServerUrl;
|
|
40
|
+
private readonly refreshPath;
|
|
41
|
+
private readonly expiryCookieName;
|
|
42
|
+
private readonly fetchImpl;
|
|
43
|
+
private readonly cookieReader;
|
|
44
|
+
private refreshInFlight;
|
|
45
|
+
private lastAttempt;
|
|
46
|
+
constructor(options?: CookieAuthOptions);
|
|
9
47
|
getHttpHeaders(): Promise<Record<string, string>>;
|
|
10
48
|
getFetchCredentials(): RequestCredentials;
|
|
11
49
|
prepareWebSocket(url: string, _canUseHeaders: boolean): Promise<{
|
|
@@ -14,5 +52,40 @@ export declare class CookieAuth extends DooverAuth {
|
|
|
14
52
|
}>;
|
|
15
53
|
setToken(_token: string | null, _tokenExpires?: Date | number | null): void;
|
|
16
54
|
setRefreshToken(_refreshToken: string | null): void;
|
|
55
|
+
/**
|
|
56
|
+
* Renew the access token when it has already expired, so the request that
|
|
57
|
+
* follows isn't a guaranteed 401. A token that is merely close to expiry is
|
|
58
|
+
* left alone: requests must not block on a refresh they don't need, and
|
|
59
|
+
* `handleUnauthorized` is there for the race.
|
|
60
|
+
*/
|
|
17
61
|
ensureReady(): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* Called by `RestClient` after a 401. A successful refresh means the request
|
|
64
|
+
* is worth replaying once.
|
|
65
|
+
*/
|
|
66
|
+
handleUnauthorized(): Promise<boolean>;
|
|
67
|
+
/** Expiry of the current access token, or `null` if there is no session. */
|
|
68
|
+
getAccessTokenExpiry(): Date | null;
|
|
69
|
+
/** True when a session cookie exists and has not yet expired. */
|
|
70
|
+
isAccessTokenValid(): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* True when the access token expires within `thresholdMs` (or already has).
|
|
73
|
+
* Apps use this to decide whether a `visibilitychange` is worth a proactive
|
|
74
|
+
* refresh. False when there is no session at all — nothing to refresh.
|
|
75
|
+
*/
|
|
76
|
+
isAccessTokenStale(thresholdMs?: number): boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Refresh the access token, returning whether a valid one is now in place.
|
|
79
|
+
* Concurrent callers share one request, and the result is reused briefly so
|
|
80
|
+
* a burst of 401s costs a single round-trip.
|
|
81
|
+
*
|
|
82
|
+
* Safe to call directly — e.g. from a `visibilitychange` handler when
|
|
83
|
+
* {@link isAccessTokenStale} says the token is nearly up.
|
|
84
|
+
*/
|
|
85
|
+
refreshAccessToken(): Promise<boolean>;
|
|
86
|
+
private canRefresh;
|
|
87
|
+
private performRefresh;
|
|
88
|
+
/** The expiry cookie in epoch milliseconds, or `null` when absent. */
|
|
89
|
+
private readExpiryCookie;
|
|
90
|
+
private readCookieString;
|
|
18
91
|
}
|
package/dist/auth/cookie-auth.js
CHANGED
|
@@ -2,13 +2,52 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CookieAuth = void 0;
|
|
4
4
|
const doover_auth_1 = require("./doover-auth");
|
|
5
|
+
/** Default FusionAuth hosted-backend refresh endpoint. */
|
|
6
|
+
const DEFAULT_REFRESH_PATH = "/app/refresh/";
|
|
7
|
+
/** Default name of the cookie holding the access token's expiry (Unix seconds). */
|
|
8
|
+
const DEFAULT_EXPIRY_COOKIE = "app.at_exp";
|
|
9
|
+
/**
|
|
10
|
+
* How long a refresh result is reused. Collapses the burst of callers that all
|
|
11
|
+
* discover an expired token at the same moment (every queued request 401-ing
|
|
12
|
+
* after a tab wakes up), and — more importantly — stops a *failed* refresh from
|
|
13
|
+
* being retried in a tight loop.
|
|
14
|
+
*/
|
|
15
|
+
const ATTEMPT_TTL_MS = 5000;
|
|
5
16
|
/**
|
|
6
17
|
* Cookie-based auth — the default browser strategy.
|
|
7
18
|
*
|
|
8
19
|
* Relies on ambient cookies (`credentials: "include"`) and does not add any
|
|
9
|
-
* `Authorization` header.
|
|
20
|
+
* `Authorization` header. The access token itself is httpOnly and invisible to
|
|
21
|
+
* JS; the only readable part of the session is a companion expiry cookie
|
|
22
|
+
* (`app.at_exp`).
|
|
23
|
+
*
|
|
24
|
+
* ## Why this refreshes
|
|
25
|
+
*
|
|
26
|
+
* The FusionAuth React SDK's auto-refresh is a single `setTimeout` per token.
|
|
27
|
+
* A browser that suspends the tab (backgrounded, laptop asleep) never fires it,
|
|
28
|
+
* so the tab wakes with an expired access-token cookie and every request 401s —
|
|
29
|
+
* even though the refresh token itself is usually still valid. Rather than
|
|
30
|
+
* leaving every browser app to work around that on its own, `CookieAuth`
|
|
31
|
+
* handles it whenever `authServerUrl` is configured: `ensureReady` renews an
|
|
32
|
+
* already-expired token before a request goes out (which also covers gateway
|
|
33
|
+
* reconnects, since `GatewayClient` awaits it before opening a socket), and
|
|
34
|
+
* `handleUnauthorized` lets `RestClient` replay a 401 once after a renewal.
|
|
10
35
|
*/
|
|
11
36
|
class CookieAuth extends doover_auth_1.DooverAuth {
|
|
37
|
+
constructor(options = {}) {
|
|
38
|
+
super();
|
|
39
|
+
this.refreshInFlight = null;
|
|
40
|
+
this.lastAttempt = null;
|
|
41
|
+
this.authServerUrl = options.authServerUrl ?? null;
|
|
42
|
+
this.refreshPath = options.refreshPath ?? DEFAULT_REFRESH_PATH;
|
|
43
|
+
this.expiryCookieName =
|
|
44
|
+
options.accessTokenExpiryCookieName ?? DEFAULT_EXPIRY_COOKIE;
|
|
45
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
46
|
+
this.cookieReader = options.cookieReader ?? null;
|
|
47
|
+
}
|
|
48
|
+
// ------------------------------------------------------------------
|
|
49
|
+
// DooverAuth interface
|
|
50
|
+
// ------------------------------------------------------------------
|
|
12
51
|
async getHttpHeaders() {
|
|
13
52
|
return {};
|
|
14
53
|
}
|
|
@@ -20,13 +59,142 @@ class CookieAuth extends doover_auth_1.DooverAuth {
|
|
|
20
59
|
return { url };
|
|
21
60
|
}
|
|
22
61
|
setToken(_token, _tokenExpires) {
|
|
23
|
-
// No-op for cookie auth.
|
|
62
|
+
// No-op for cookie auth — the token lives in an httpOnly cookie.
|
|
24
63
|
}
|
|
25
64
|
setRefreshToken(_refreshToken) {
|
|
26
|
-
// No-op for cookie auth.
|
|
65
|
+
// No-op for cookie auth — the refresh token lives in an httpOnly cookie.
|
|
27
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Renew the access token when it has already expired, so the request that
|
|
69
|
+
* follows isn't a guaranteed 401. A token that is merely close to expiry is
|
|
70
|
+
* left alone: requests must not block on a refresh they don't need, and
|
|
71
|
+
* `handleUnauthorized` is there for the race.
|
|
72
|
+
*/
|
|
28
73
|
async ensureReady() {
|
|
29
|
-
|
|
74
|
+
if (!this.canRefresh()) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (this.isAccessTokenValid()) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// A failed refresh is not fatal here — let the request go out and be
|
|
81
|
+
// handled by `handleUnauthorized` / the caller's error handling.
|
|
82
|
+
await this.refreshAccessToken();
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Called by `RestClient` after a 401. A successful refresh means the request
|
|
86
|
+
* is worth replaying once.
|
|
87
|
+
*/
|
|
88
|
+
async handleUnauthorized() {
|
|
89
|
+
if (!this.canRefresh()) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
return this.refreshAccessToken();
|
|
93
|
+
}
|
|
94
|
+
// ------------------------------------------------------------------
|
|
95
|
+
// Public helpers — apps need these for their own lifecycle hooks
|
|
96
|
+
// ------------------------------------------------------------------
|
|
97
|
+
/** Expiry of the current access token, or `null` if there is no session. */
|
|
98
|
+
getAccessTokenExpiry() {
|
|
99
|
+
const expiryMs = this.readExpiryCookie();
|
|
100
|
+
return expiryMs == null ? null : new Date(expiryMs);
|
|
101
|
+
}
|
|
102
|
+
/** True when a session cookie exists and has not yet expired. */
|
|
103
|
+
isAccessTokenValid() {
|
|
104
|
+
const expiryMs = this.readExpiryCookie();
|
|
105
|
+
return expiryMs != null && expiryMs > Date.now();
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* True when the access token expires within `thresholdMs` (or already has).
|
|
109
|
+
* Apps use this to decide whether a `visibilitychange` is worth a proactive
|
|
110
|
+
* refresh. False when there is no session at all — nothing to refresh.
|
|
111
|
+
*/
|
|
112
|
+
isAccessTokenStale(thresholdMs = 60000) {
|
|
113
|
+
const expiryMs = this.readExpiryCookie();
|
|
114
|
+
if (expiryMs == null) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
return expiryMs - Date.now() < thresholdMs;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Refresh the access token, returning whether a valid one is now in place.
|
|
121
|
+
* Concurrent callers share one request, and the result is reused briefly so
|
|
122
|
+
* a burst of 401s costs a single round-trip.
|
|
123
|
+
*
|
|
124
|
+
* Safe to call directly — e.g. from a `visibilitychange` handler when
|
|
125
|
+
* {@link isAccessTokenStale} says the token is nearly up.
|
|
126
|
+
*/
|
|
127
|
+
async refreshAccessToken() {
|
|
128
|
+
if (!this.canRefresh()) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (this.refreshInFlight) {
|
|
132
|
+
return this.refreshInFlight;
|
|
133
|
+
}
|
|
134
|
+
// No expiry cookie means we were never logged in, or have since logged
|
|
135
|
+
// out. The refresh endpoint would just 400.
|
|
136
|
+
if (this.readExpiryCookie() == null) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
if (this.lastAttempt && Date.now() - this.lastAttempt.at < ATTEMPT_TTL_MS) {
|
|
140
|
+
return this.lastAttempt.result;
|
|
141
|
+
}
|
|
142
|
+
const inFlight = this.performRefresh().then((result) => {
|
|
143
|
+
this.lastAttempt = { result, at: Date.now() };
|
|
144
|
+
this.refreshInFlight = null;
|
|
145
|
+
return result;
|
|
146
|
+
});
|
|
147
|
+
this.refreshInFlight = inFlight;
|
|
148
|
+
return inFlight;
|
|
149
|
+
}
|
|
150
|
+
// ------------------------------------------------------------------
|
|
151
|
+
// Internal
|
|
152
|
+
// ------------------------------------------------------------------
|
|
153
|
+
canRefresh() {
|
|
154
|
+
return this.authServerUrl != null && this.authServerUrl !== "";
|
|
155
|
+
}
|
|
156
|
+
async performRefresh() {
|
|
157
|
+
const url = `${this.authServerUrl}${this.refreshPath}`;
|
|
158
|
+
try {
|
|
159
|
+
const response = await this.fetchImpl(url, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
credentials: "include",
|
|
162
|
+
headers: { "Content-Type": "text/plain" },
|
|
163
|
+
});
|
|
164
|
+
return response.ok;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// Offline, DNS failure, blocked request — indistinguishable from here,
|
|
168
|
+
// and all mean "no new token".
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** The expiry cookie in epoch milliseconds, or `null` when absent. */
|
|
173
|
+
readExpiryCookie() {
|
|
174
|
+
const cookieString = this.readCookieString();
|
|
175
|
+
if (!cookieString) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const match = cookieString.match(new RegExp(`(?:^|;\\s*)${escapeRegExp(this.expiryCookieName)}=([^;]+)`));
|
|
179
|
+
if (!match) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
// Stored as Unix *seconds* (matching the FusionAuth SDK's own parser);
|
|
183
|
+
// callers compare against Date.now(), so normalise to milliseconds.
|
|
184
|
+
const seconds = parseInt(decodeURIComponent(match[1]), 10);
|
|
185
|
+
return Number.isFinite(seconds) ? seconds * 1000 : null;
|
|
186
|
+
}
|
|
187
|
+
readCookieString() {
|
|
188
|
+
if (this.cookieReader) {
|
|
189
|
+
return this.cookieReader();
|
|
190
|
+
}
|
|
191
|
+
if (typeof document === "undefined") {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
return document.cookie;
|
|
30
195
|
}
|
|
31
196
|
}
|
|
32
197
|
exports.CookieAuth = CookieAuth;
|
|
198
|
+
function escapeRegExp(value) {
|
|
199
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
200
|
+
}
|
|
@@ -6,5 +6,5 @@
|
|
|
6
6
|
* String-literal union (not a TS enum) so values serialise cleanly and can
|
|
7
7
|
* appear in error messages / debug UIs verbatim.
|
|
8
8
|
*/
|
|
9
|
-
export type Capability = "agents.list" | "agents.multiAgentMessages" | "agents.multiAgentAggregates" | "channels.list" | "channels.get" | "channels.create" | "channels.archive" | "channels.dataSeries" | "aggregates.get" | "aggregates.put" | "aggregates.patch" | "aggregates.attachment" | "messages.list" | "messages.listHistorical" | "messages.get" | "messages.post" | "messages.put" | "messages.delete" | "messages.attachment" | "messages.timeseries" | "messages.invocationLogs" | "gateway.subscribe" | "gateway.realtime" | "gateway.oneShot" | "rpc.send" | "alarms.read" | "alarms.write" | "connections.read" | "connections.write" | "notifications.read" | "notifications.write" | "permissions.read" | "permissions.write" | "processors.read" | "processors.write" | "turn.credentials" | "users.me";
|
|
9
|
+
export type Capability = "agents.list" | "agents.multiAgentMessages" | "agents.multiAgentAggregates" | "agents.tokenRead" | "agents.tokenManage" | "channels.list" | "channels.get" | "channels.create" | "channels.archive" | "channels.dataSeries" | "aggregates.get" | "aggregates.put" | "aggregates.patch" | "aggregates.attachment" | "messages.list" | "messages.listHistorical" | "messages.get" | "messages.post" | "messages.put" | "messages.delete" | "messages.attachment" | "messages.timeseries" | "messages.invocationLogs" | "gateway.subscribe" | "gateway.realtime" | "gateway.oneShot" | "rpc.send" | "alarms.read" | "alarms.write" | "connections.read" | "connections.write" | "notifications.read" | "notifications.write" | "permissions.read" | "permissions.write" | "processors.read" | "processors.write" | "turn.credentials" | "users.me";
|
|
10
10
|
export declare const ALL_CAPABILITIES: readonly Capability[];
|
|
@@ -183,6 +183,13 @@ const METHOD_TO_CAPABILITY = {
|
|
|
183
183
|
"agents.listAgents": "agents.list",
|
|
184
184
|
"agents.getMultiAgentMessages": "agents.multiAgentMessages",
|
|
185
185
|
"agents.getMultiAgentAggregates": "agents.multiAgentAggregates",
|
|
186
|
+
"agents.getTokenState": "agents.tokenRead",
|
|
187
|
+
"agents.setTokenPolicy": "agents.tokenManage",
|
|
188
|
+
"agents.setAuthLock": "agents.tokenManage",
|
|
189
|
+
"agents.revokeAllTokens": "agents.tokenManage",
|
|
190
|
+
"agents.setRevocationFloor": "agents.tokenManage",
|
|
191
|
+
"agents.createAdhocToken": "agents.tokenManage",
|
|
192
|
+
"agents.createDeviceToken": "agents.tokenManage",
|
|
186
193
|
"channels.listChannels": "channels.list",
|
|
187
194
|
"channels.getChannel": "channels.get",
|
|
188
195
|
"channels.createChannel": "channels.create",
|
|
@@ -660,6 +660,13 @@ class MultiplexClient {
|
|
|
660
660
|
const merged = (0, multiplex_merge_1.dedupeBy)([].concat(...responses.map((r) => r.results)), (a) => a.agent_id);
|
|
661
661
|
return { results: merged, count: merged.length };
|
|
662
662
|
},
|
|
663
|
+
getTokenState: self.makeFanoutFirst("agents.getTokenState", "agents.tokenRead"),
|
|
664
|
+
setTokenPolicy: self.routedWrite("agents.setTokenPolicy", "agents.tokenManage"),
|
|
665
|
+
setAuthLock: self.routedWrite("agents.setAuthLock", "agents.tokenManage"),
|
|
666
|
+
revokeAllTokens: self.routedWrite("agents.revokeAllTokens", "agents.tokenManage"),
|
|
667
|
+
setRevocationFloor: self.routedWrite("agents.setRevocationFloor", "agents.tokenManage"),
|
|
668
|
+
createAdhocToken: self.routedWrite("agents.createAdhocToken", "agents.tokenManage"),
|
|
669
|
+
createDeviceToken: self.routedWrite("agents.createDeviceToken", "agents.tokenManage"),
|
|
663
670
|
};
|
|
664
671
|
}
|
|
665
672
|
// ===== non-core generic facade =====
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,7 @@ export { getDooverClient, peekDooverClient, resetDooverClient, } from "./client/
|
|
|
7
7
|
export { DooverStatsCollector } from "./client/stats";
|
|
8
8
|
export type { DooverStatsSnapshot, RestStatsSnapshot, GatewayStatsSnapshot, } from "./client/stats";
|
|
9
9
|
export { AgentsApi } from "./apis/agents-api";
|
|
10
|
+
export type { AgentTokenState, AgentTokenPolicy, TokenPolicyField, AdhocTokenResponse, DeviceTokenResponse, ResourcePermission, } from "./apis/agents-api";
|
|
10
11
|
export { AggregatesApi } from "./apis/aggregates-api";
|
|
11
12
|
export { AlarmsApi } from "./apis/alarms-api";
|
|
12
13
|
export { ChannelsApi } from "./apis/channels-api";
|
|
@@ -37,6 +38,7 @@ export type { DataClient, AgentScope, DataClientStatus, DataClientConnectionStat
|
|
|
37
38
|
export type { SourceProvenance, SourceProvenanceViaRest, SourceProvenanceViaGateway, } from "./types/provenance";
|
|
38
39
|
export { DooverAuth } from "./auth/doover-auth";
|
|
39
40
|
export { CookieAuth } from "./auth/cookie-auth";
|
|
41
|
+
export type { CookieAuthOptions } from "./auth/cookie-auth";
|
|
40
42
|
export { DooverTokenAuth } from "./auth/doover-token-auth";
|
|
41
43
|
export { AuthProfile } from "./auth/auth-profile";
|
|
42
44
|
export type { AuthProfileData } from "./auth/auth-profile";
|
package/dist/react/index.d.ts
CHANGED
|
@@ -33,4 +33,6 @@ export type { UseMultiAgentChannelMessagesOptions, UseMultiAgentChannelMessagesR
|
|
|
33
33
|
export { useDeviceMap } from "./useDeviceMap";
|
|
34
34
|
export type { DeviceMapEntry, UseDeviceMapOptions, UseDeviceMapResult, } from "./useDeviceMap";
|
|
35
35
|
export { useTurnCredentials } from "./useTurnCredentials";
|
|
36
|
+
export { useAgentTokenState, useSetAgentTokenPolicy, useSetAgentAuthLock, useRevokeAgentTokens, useSetRevocationFloor, useCreateAdhocToken, useCreateDeviceToken, agentTokenStateQueryKey, } from "./useAgentTokenState";
|
|
37
|
+
export type { UseAgentTokenStateOptions } from "./useAgentTokenState";
|
|
36
38
|
export { getSharedQueryClient, resetSharedQueryClient, } from "./sharedQueryClient";
|
package/dist/react/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.resetSharedQueryClient = exports.getSharedQueryClient = exports.useTurnCredentials = exports.useDeviceMap = exports.multiAgentChannelMessagesQueryKey = exports.useMultiAgentChannelMessages = exports.multiAgentAggregatesQueryKey = exports.useMultiAgentAggregates = exports.useSendRpc = exports.invocationLogsQueryKey = exports.useInvocationLogs = exports.channelMessageQueryKey = exports.useChannelMessage = exports.channelMessagesQueryKey = exports.useChannelMessages = exports.useUpdateMessage = exports.useUpdateAggregate = exports.useSendMessage = exports.useAgentChannel = exports.channelAggregateQueryKey = exports.useChannelAggregate = exports.useChannelSubscription = exports.agentConnectionsQueryKey = exports.useAgentConnections = exports.useConnectionState = exports.hasOfflineStatus = exports.useOfflineStatus = exports.useClientStatus = exports.useDooverClient = exports.DooverProvider = void 0;
|
|
3
|
+
exports.resetSharedQueryClient = exports.getSharedQueryClient = exports.agentTokenStateQueryKey = exports.useCreateDeviceToken = exports.useCreateAdhocToken = exports.useSetRevocationFloor = exports.useRevokeAgentTokens = exports.useSetAgentAuthLock = exports.useSetAgentTokenPolicy = exports.useAgentTokenState = exports.useTurnCredentials = exports.useDeviceMap = exports.multiAgentChannelMessagesQueryKey = exports.useMultiAgentChannelMessages = exports.multiAgentAggregatesQueryKey = exports.useMultiAgentAggregates = exports.useSendRpc = exports.invocationLogsQueryKey = exports.useInvocationLogs = exports.channelMessageQueryKey = exports.useChannelMessage = exports.channelMessagesQueryKey = exports.useChannelMessages = exports.useUpdateMessage = exports.useUpdateAggregate = exports.useSendMessage = exports.useAgentChannel = exports.channelAggregateQueryKey = exports.useChannelAggregate = exports.useChannelSubscription = exports.agentConnectionsQueryKey = exports.useAgentConnections = exports.useConnectionState = exports.hasOfflineStatus = exports.useOfflineStatus = exports.useClientStatus = exports.useDooverClient = exports.DooverProvider = void 0;
|
|
4
4
|
var context_1 = require("./context");
|
|
5
5
|
Object.defineProperty(exports, "DooverProvider", { enumerable: true, get: function () { return context_1.DooverProvider; } });
|
|
6
6
|
Object.defineProperty(exports, "useDooverClient", { enumerable: true, get: function () { return context_1.useDooverClient; } });
|
|
@@ -48,6 +48,15 @@ var useDeviceMap_1 = require("./useDeviceMap");
|
|
|
48
48
|
Object.defineProperty(exports, "useDeviceMap", { enumerable: true, get: function () { return useDeviceMap_1.useDeviceMap; } });
|
|
49
49
|
var useTurnCredentials_1 = require("./useTurnCredentials");
|
|
50
50
|
Object.defineProperty(exports, "useTurnCredentials", { enumerable: true, get: function () { return useTurnCredentials_1.useTurnCredentials; } });
|
|
51
|
+
var useAgentTokenState_1 = require("./useAgentTokenState");
|
|
52
|
+
Object.defineProperty(exports, "useAgentTokenState", { enumerable: true, get: function () { return useAgentTokenState_1.useAgentTokenState; } });
|
|
53
|
+
Object.defineProperty(exports, "useSetAgentTokenPolicy", { enumerable: true, get: function () { return useAgentTokenState_1.useSetAgentTokenPolicy; } });
|
|
54
|
+
Object.defineProperty(exports, "useSetAgentAuthLock", { enumerable: true, get: function () { return useAgentTokenState_1.useSetAgentAuthLock; } });
|
|
55
|
+
Object.defineProperty(exports, "useRevokeAgentTokens", { enumerable: true, get: function () { return useAgentTokenState_1.useRevokeAgentTokens; } });
|
|
56
|
+
Object.defineProperty(exports, "useSetRevocationFloor", { enumerable: true, get: function () { return useAgentTokenState_1.useSetRevocationFloor; } });
|
|
57
|
+
Object.defineProperty(exports, "useCreateAdhocToken", { enumerable: true, get: function () { return useAgentTokenState_1.useCreateAdhocToken; } });
|
|
58
|
+
Object.defineProperty(exports, "useCreateDeviceToken", { enumerable: true, get: function () { return useAgentTokenState_1.useCreateDeviceToken; } });
|
|
59
|
+
Object.defineProperty(exports, "agentTokenStateQueryKey", { enumerable: true, get: function () { return useAgentTokenState_1.agentTokenStateQueryKey; } });
|
|
51
60
|
var sharedQueryClient_1 = require("./sharedQueryClient");
|
|
52
61
|
Object.defineProperty(exports, "getSharedQueryClient", { enumerable: true, get: function () { return sharedQueryClient_1.getSharedQueryClient; } });
|
|
53
62
|
Object.defineProperty(exports, "resetSharedQueryClient", { enumerable: true, get: function () { return sharedQueryClient_1.resetSharedQueryClient; } });
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type UseQueryResult } from "@tanstack/react-query";
|
|
2
|
+
import type { AgentTokenPolicy, AgentTokenState, ResourcePermission } from "../apis/agents-api";
|
|
3
|
+
export declare function agentTokenStateQueryKey(agentId: string | undefined): readonly ["doover", "agent", string | null, "tokenState"];
|
|
4
|
+
export interface UseAgentTokenStateOptions {
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
staleTime?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Read an agent's credential state — revocation floor, auth kill-switch, and the
|
|
10
|
+
* token posture resolved across the per-agent override and organisation default.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately short `staleTime`: `auth_locked` can be flipped by doover-data
|
|
13
|
+
* itself (automatic lockout on suspected token reuse), so this value changes
|
|
14
|
+
* without any action from this client. Showing a stale "not locked" on the one
|
|
15
|
+
* screen someone opens *because* their device went offline would be worse than
|
|
16
|
+
* an extra fetch.
|
|
17
|
+
*/
|
|
18
|
+
export declare function useAgentTokenState(agentId: string | undefined, options?: UseAgentTokenStateOptions): UseQueryResult<AgentTokenState>;
|
|
19
|
+
/**
|
|
20
|
+
* Write an agent's token-posture override. Full replacement — a null field means
|
|
21
|
+
* "inherit the organisation default".
|
|
22
|
+
*
|
|
23
|
+
* Invalidates the token-state query on success so the inherited-vs-overridden
|
|
24
|
+
* display reflects the write without the caller wiring that up.
|
|
25
|
+
*/
|
|
26
|
+
export declare function useSetAgentTokenPolicy(agentId: string | undefined): import("@tanstack/react-query").UseMutationResult<{
|
|
27
|
+
effective: AgentTokenPolicy;
|
|
28
|
+
}, Error, AgentTokenPolicy, unknown>;
|
|
29
|
+
/** Set or clear the auth kill-switch. */
|
|
30
|
+
export declare function useSetAgentAuthLock(agentId: string | undefined): import("@tanstack/react-query").UseMutationResult<void, Error, boolean, unknown>;
|
|
31
|
+
/**
|
|
32
|
+
* Mint a short-lived scoped token for ad-hoc access to an agent.
|
|
33
|
+
*
|
|
34
|
+
* Does not invalidate the token-state query: an ad-hoc token is a JWT with its
|
|
35
|
+
* own expiry and doesn't touch the revocation floor or the agent's posture, so
|
|
36
|
+
* there is nothing in that query for it to change.
|
|
37
|
+
*
|
|
38
|
+
* The resulting token is returned once and is not retrievable afterwards — hand
|
|
39
|
+
* it straight to the user and don't cache it.
|
|
40
|
+
*/
|
|
41
|
+
export declare function useCreateAdhocToken(agentId: string | undefined): import("@tanstack/react-query").UseMutationResult<import("../apis/agents-api").AdhocTokenResponse, Error, {
|
|
42
|
+
permissions?: ResourcePermission[];
|
|
43
|
+
timeout?: number;
|
|
44
|
+
}, unknown>;
|
|
45
|
+
/**
|
|
46
|
+
* Set the revocation floor to an explicit point; pass `0` or `null` to clear it.
|
|
47
|
+
*
|
|
48
|
+
* Lowering it reverses a revocation, so this is for undoing one made by mistake
|
|
49
|
+
* rather than for recovering a device whose credentials leaked.
|
|
50
|
+
*/
|
|
51
|
+
export declare function useSetRevocationFloor(agentId: string | undefined): import("@tanstack/react-query").UseMutationResult<{
|
|
52
|
+
tokens_valid_from: number | null;
|
|
53
|
+
}, Error, number | null, unknown>;
|
|
54
|
+
/**
|
|
55
|
+
* Mint the device's long-lived credential for manual provisioning, returned once.
|
|
56
|
+
*
|
|
57
|
+
* Invalidates the token-state query so the revocation floor display stays honest
|
|
58
|
+
* — the mint itself is additive and doesn't move the floor, but refetching keeps
|
|
59
|
+
* this in step with anything else that has changed since the panel loaded.
|
|
60
|
+
*/
|
|
61
|
+
export declare function useCreateDeviceToken(agentId: string | undefined): import("@tanstack/react-query").UseMutationResult<import("../apis/agents-api").DeviceTokenResponse, Error, void, unknown>;
|
|
62
|
+
/**
|
|
63
|
+
* Revoke every outstanding long-lived credential for an agent.
|
|
64
|
+
*
|
|
65
|
+
* Break-glass: the device stays offline until it is re-provisioned. Confirm with
|
|
66
|
+
* the user before calling.
|
|
67
|
+
*/
|
|
68
|
+
export declare function useRevokeAgentTokens(agentId: string | undefined): import("@tanstack/react-query").UseMutationResult<{
|
|
69
|
+
tokens_valid_from: number;
|
|
70
|
+
}, Error, void, unknown>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.agentTokenStateQueryKey = agentTokenStateQueryKey;
|
|
4
|
+
exports.useAgentTokenState = useAgentTokenState;
|
|
5
|
+
exports.useSetAgentTokenPolicy = useSetAgentTokenPolicy;
|
|
6
|
+
exports.useSetAgentAuthLock = useSetAgentAuthLock;
|
|
7
|
+
exports.useCreateAdhocToken = useCreateAdhocToken;
|
|
8
|
+
exports.useSetRevocationFloor = useSetRevocationFloor;
|
|
9
|
+
exports.useCreateDeviceToken = useCreateDeviceToken;
|
|
10
|
+
exports.useRevokeAgentTokens = useRevokeAgentTokens;
|
|
11
|
+
const react_query_1 = require("@tanstack/react-query");
|
|
12
|
+
const context_1 = require("./context");
|
|
13
|
+
function agentTokenStateQueryKey(agentId) {
|
|
14
|
+
return ["doover", "agent", agentId ?? null, "tokenState"];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Read an agent's credential state — revocation floor, auth kill-switch, and the
|
|
18
|
+
* token posture resolved across the per-agent override and organisation default.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately short `staleTime`: `auth_locked` can be flipped by doover-data
|
|
21
|
+
* itself (automatic lockout on suspected token reuse), so this value changes
|
|
22
|
+
* without any action from this client. Showing a stale "not locked" on the one
|
|
23
|
+
* screen someone opens *because* their device went offline would be worse than
|
|
24
|
+
* an extra fetch.
|
|
25
|
+
*/
|
|
26
|
+
function useAgentTokenState(agentId, options) {
|
|
27
|
+
const client = (0, context_1.useDooverClient)();
|
|
28
|
+
return (0, react_query_1.useQuery)({
|
|
29
|
+
queryKey: agentTokenStateQueryKey(agentId),
|
|
30
|
+
enabled: (options?.enabled ?? true) && Boolean(agentId),
|
|
31
|
+
staleTime: options?.staleTime ?? 15 * 1000,
|
|
32
|
+
queryFn: () => client.agents.getTokenState(agentId),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Write an agent's token-posture override. Full replacement — a null field means
|
|
37
|
+
* "inherit the organisation default".
|
|
38
|
+
*
|
|
39
|
+
* Invalidates the token-state query on success so the inherited-vs-overridden
|
|
40
|
+
* display reflects the write without the caller wiring that up.
|
|
41
|
+
*/
|
|
42
|
+
function useSetAgentTokenPolicy(agentId) {
|
|
43
|
+
const client = (0, context_1.useDooverClient)();
|
|
44
|
+
const queryClient = (0, react_query_1.useQueryClient)();
|
|
45
|
+
return (0, react_query_1.useMutation)({
|
|
46
|
+
mutationFn: (policy) => client.agents.setTokenPolicy(agentId, policy),
|
|
47
|
+
onSuccess: () => {
|
|
48
|
+
void queryClient.invalidateQueries({
|
|
49
|
+
queryKey: agentTokenStateQueryKey(agentId),
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** Set or clear the auth kill-switch. */
|
|
55
|
+
function useSetAgentAuthLock(agentId) {
|
|
56
|
+
const client = (0, context_1.useDooverClient)();
|
|
57
|
+
const queryClient = (0, react_query_1.useQueryClient)();
|
|
58
|
+
return (0, react_query_1.useMutation)({
|
|
59
|
+
mutationFn: (locked) => client.agents.setAuthLock(agentId, locked),
|
|
60
|
+
onSuccess: () => {
|
|
61
|
+
void queryClient.invalidateQueries({
|
|
62
|
+
queryKey: agentTokenStateQueryKey(agentId),
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Mint a short-lived scoped token for ad-hoc access to an agent.
|
|
69
|
+
*
|
|
70
|
+
* Does not invalidate the token-state query: an ad-hoc token is a JWT with its
|
|
71
|
+
* own expiry and doesn't touch the revocation floor or the agent's posture, so
|
|
72
|
+
* there is nothing in that query for it to change.
|
|
73
|
+
*
|
|
74
|
+
* The resulting token is returned once and is not retrievable afterwards — hand
|
|
75
|
+
* it straight to the user and don't cache it.
|
|
76
|
+
*/
|
|
77
|
+
function useCreateAdhocToken(agentId) {
|
|
78
|
+
const client = (0, context_1.useDooverClient)();
|
|
79
|
+
return (0, react_query_1.useMutation)({
|
|
80
|
+
mutationFn: (options) => client.agents.createAdhocToken(agentId, options),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Set the revocation floor to an explicit point; pass `0` or `null` to clear it.
|
|
85
|
+
*
|
|
86
|
+
* Lowering it reverses a revocation, so this is for undoing one made by mistake
|
|
87
|
+
* rather than for recovering a device whose credentials leaked.
|
|
88
|
+
*/
|
|
89
|
+
function useSetRevocationFloor(agentId) {
|
|
90
|
+
const client = (0, context_1.useDooverClient)();
|
|
91
|
+
const queryClient = (0, react_query_1.useQueryClient)();
|
|
92
|
+
return (0, react_query_1.useMutation)({
|
|
93
|
+
mutationFn: (tokensValidFrom) => client.agents.setRevocationFloor(agentId, tokensValidFrom),
|
|
94
|
+
onSuccess: () => {
|
|
95
|
+
void queryClient.invalidateQueries({
|
|
96
|
+
queryKey: agentTokenStateQueryKey(agentId),
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Mint the device's long-lived credential for manual provisioning, returned once.
|
|
103
|
+
*
|
|
104
|
+
* Invalidates the token-state query so the revocation floor display stays honest
|
|
105
|
+
* — the mint itself is additive and doesn't move the floor, but refetching keeps
|
|
106
|
+
* this in step with anything else that has changed since the panel loaded.
|
|
107
|
+
*/
|
|
108
|
+
function useCreateDeviceToken(agentId) {
|
|
109
|
+
const client = (0, context_1.useDooverClient)();
|
|
110
|
+
const queryClient = (0, react_query_1.useQueryClient)();
|
|
111
|
+
return (0, react_query_1.useMutation)({
|
|
112
|
+
mutationFn: () => client.agents.createDeviceToken(agentId),
|
|
113
|
+
onSuccess: () => {
|
|
114
|
+
void queryClient.invalidateQueries({
|
|
115
|
+
queryKey: agentTokenStateQueryKey(agentId),
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Revoke every outstanding long-lived credential for an agent.
|
|
122
|
+
*
|
|
123
|
+
* Break-glass: the device stays offline until it is re-provisioned. Confirm with
|
|
124
|
+
* the user before calling.
|
|
125
|
+
*/
|
|
126
|
+
function useRevokeAgentTokens(agentId) {
|
|
127
|
+
const client = (0, context_1.useDooverClient)();
|
|
128
|
+
const queryClient = (0, react_query_1.useQueryClient)();
|
|
129
|
+
return (0, react_query_1.useMutation)({
|
|
130
|
+
mutationFn: () => client.agents.revokeAllTokens(agentId),
|
|
131
|
+
onSuccess: () => {
|
|
132
|
+
void queryClient.invalidateQueries({
|
|
133
|
+
queryKey: agentTokenStateQueryKey(agentId),
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
package/dist/test/auth.test.js
CHANGED
|
@@ -50,6 +50,21 @@ class MemoryProfileStore {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
// ---------------------------------------------------------------------------
|
|
53
|
+
// Helpers: cookie-auth session state
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
/** An `app.at_exp` cookie string expiring `secondsFromNow` from now. */
|
|
56
|
+
function expiryCookie(secondsFromNow) {
|
|
57
|
+
return `app.at_exp=${Math.floor(Date.now() / 1000) + secondsFromNow}`;
|
|
58
|
+
}
|
|
59
|
+
/** A refresh-capable CookieAuth over a fixed cookie string. */
|
|
60
|
+
function cookieAuth(options) {
|
|
61
|
+
return new cookie_auth_1.CookieAuth({
|
|
62
|
+
authServerUrl: "https://auth.example.com",
|
|
63
|
+
fetchImpl: (options.fetchMock ?? (0, helpers_1.createFetchMock)()),
|
|
64
|
+
cookieReader: () => options.cookie,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
53
68
|
// CookieAuth
|
|
54
69
|
// ---------------------------------------------------------------------------
|
|
55
70
|
(0, mocha_1.describe)("CookieAuth", () => {
|
|
@@ -67,6 +82,127 @@ class MemoryProfileStore {
|
|
|
67
82
|
const auth = new cookie_auth_1.CookieAuth();
|
|
68
83
|
await auth.ensureReady(); // should not throw
|
|
69
84
|
});
|
|
85
|
+
(0, mocha_1.it)("does not refresh when no authServerUrl is configured", async () => {
|
|
86
|
+
const fetchMock = (0, helpers_1.createFetchMock)();
|
|
87
|
+
const auth = new cookie_auth_1.CookieAuth({
|
|
88
|
+
fetchImpl: fetchMock,
|
|
89
|
+
cookieReader: () => expiryCookie(-60),
|
|
90
|
+
});
|
|
91
|
+
(0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false);
|
|
92
|
+
await auth.ensureReady();
|
|
93
|
+
(0, chai_1.expect)(fetchMock.callCount).to.equal(0);
|
|
94
|
+
});
|
|
95
|
+
// -- Session inspection ---------------------------------------------------
|
|
96
|
+
(0, mocha_1.it)("reads the expiry cookie", () => {
|
|
97
|
+
const auth = cookieAuth({ cookie: expiryCookie(3600) });
|
|
98
|
+
(0, chai_1.expect)(auth.isAccessTokenValid()).to.equal(true);
|
|
99
|
+
(0, chai_1.expect)(auth.isAccessTokenStale()).to.equal(false);
|
|
100
|
+
(0, chai_1.expect)(auth.getAccessTokenExpiry()).to.be.instanceOf(Date);
|
|
101
|
+
});
|
|
102
|
+
(0, mocha_1.it)("treats an expired cookie as invalid but refreshable", () => {
|
|
103
|
+
const auth = cookieAuth({ cookie: expiryCookie(-60) });
|
|
104
|
+
(0, chai_1.expect)(auth.isAccessTokenValid()).to.equal(false);
|
|
105
|
+
(0, chai_1.expect)(auth.isAccessTokenStale()).to.equal(true);
|
|
106
|
+
});
|
|
107
|
+
(0, mocha_1.it)("reports a near-expiry token as stale while still valid", () => {
|
|
108
|
+
const auth = cookieAuth({ cookie: expiryCookie(60) });
|
|
109
|
+
(0, chai_1.expect)(auth.isAccessTokenValid()).to.equal(true);
|
|
110
|
+
(0, chai_1.expect)(auth.isAccessTokenStale(5 * 60000)).to.equal(true);
|
|
111
|
+
});
|
|
112
|
+
(0, mocha_1.it)("reports no session when the cookie is absent", () => {
|
|
113
|
+
const auth = cookieAuth({ cookie: "other=1" });
|
|
114
|
+
(0, chai_1.expect)(auth.getAccessTokenExpiry()).to.equal(null);
|
|
115
|
+
(0, chai_1.expect)(auth.isAccessTokenValid()).to.equal(false);
|
|
116
|
+
// Nothing to refresh — must not read as "stale".
|
|
117
|
+
(0, chai_1.expect)(auth.isAccessTokenStale()).to.equal(false);
|
|
118
|
+
});
|
|
119
|
+
(0, mocha_1.it)("ignores a non-numeric expiry cookie", () => {
|
|
120
|
+
const auth = cookieAuth({ cookie: "app.at_exp=garbage" });
|
|
121
|
+
(0, chai_1.expect)(auth.getAccessTokenExpiry()).to.equal(null);
|
|
122
|
+
(0, chai_1.expect)(auth.isAccessTokenValid()).to.equal(false);
|
|
123
|
+
});
|
|
124
|
+
(0, mocha_1.it)("honours a custom expiry cookie name", () => {
|
|
125
|
+
const auth = new cookie_auth_1.CookieAuth({
|
|
126
|
+
authServerUrl: "https://auth.example.com",
|
|
127
|
+
accessTokenExpiryCookieName: "custom.exp",
|
|
128
|
+
cookieReader: () => `custom.exp=${Math.floor(Date.now() / 1000) + 3600}`,
|
|
129
|
+
});
|
|
130
|
+
(0, chai_1.expect)(auth.isAccessTokenValid()).to.equal(true);
|
|
131
|
+
});
|
|
132
|
+
// -- Refresh behaviour ----------------------------------------------------
|
|
133
|
+
(0, mocha_1.it)("refreshes an expired token via the hosted-backend endpoint", async () => {
|
|
134
|
+
const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}));
|
|
135
|
+
const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
|
|
136
|
+
(0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(true);
|
|
137
|
+
(0, chai_1.expect)(fetchMock.callCount).to.equal(1);
|
|
138
|
+
(0, chai_1.expect)(fetchMock.firstCall.args[0]).to.equal("https://auth.example.com/app/refresh/");
|
|
139
|
+
const init = fetchMock.firstCall.args[1];
|
|
140
|
+
(0, chai_1.expect)(init.method).to.equal("POST");
|
|
141
|
+
(0, chai_1.expect)(init.credentials).to.equal("include");
|
|
142
|
+
});
|
|
143
|
+
(0, mocha_1.it)("honours a custom refresh path", async () => {
|
|
144
|
+
const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}));
|
|
145
|
+
const auth = new cookie_auth_1.CookieAuth({
|
|
146
|
+
authServerUrl: "https://auth.example.com",
|
|
147
|
+
refreshPath: "/proxy/renew",
|
|
148
|
+
fetchImpl: fetchMock,
|
|
149
|
+
cookieReader: () => expiryCookie(-60),
|
|
150
|
+
});
|
|
151
|
+
await auth.refreshAccessToken();
|
|
152
|
+
(0, chai_1.expect)(fetchMock.firstCall.args[0]).to.equal("https://auth.example.com/proxy/renew");
|
|
153
|
+
});
|
|
154
|
+
(0, mocha_1.it)("reports failure when the refresh endpoint rejects", async () => {
|
|
155
|
+
const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}, { status: 400 }));
|
|
156
|
+
const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
|
|
157
|
+
(0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false);
|
|
158
|
+
});
|
|
159
|
+
(0, mocha_1.it)("reports failure when the refresh request throws", async () => {
|
|
160
|
+
const fetchMock = sinon_1.default.stub().rejects(new Error("offline"));
|
|
161
|
+
const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
|
|
162
|
+
(0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false);
|
|
163
|
+
});
|
|
164
|
+
(0, mocha_1.it)("skips the refresh call when there is no session cookie", async () => {
|
|
165
|
+
const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}));
|
|
166
|
+
const auth = cookieAuth({ cookie: "", fetchMock });
|
|
167
|
+
(0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false);
|
|
168
|
+
(0, chai_1.expect)(fetchMock.callCount).to.equal(0);
|
|
169
|
+
});
|
|
170
|
+
(0, mocha_1.it)("collapses concurrent refreshes onto one request", async () => {
|
|
171
|
+
const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}));
|
|
172
|
+
const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
|
|
173
|
+
const results = await Promise.all([
|
|
174
|
+
auth.handleUnauthorized(),
|
|
175
|
+
auth.handleUnauthorized(),
|
|
176
|
+
auth.handleUnauthorized(),
|
|
177
|
+
]);
|
|
178
|
+
(0, chai_1.expect)(results).to.deep.equal([true, true, true]);
|
|
179
|
+
(0, chai_1.expect)(fetchMock.callCount).to.equal(1);
|
|
180
|
+
});
|
|
181
|
+
(0, mocha_1.it)("reuses a recent failure instead of retrying in a loop", async () => {
|
|
182
|
+
const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}, { status: 400 }));
|
|
183
|
+
const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
|
|
184
|
+
(0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false);
|
|
185
|
+
(0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false);
|
|
186
|
+
(0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false);
|
|
187
|
+
(0, chai_1.expect)(fetchMock.callCount).to.equal(1);
|
|
188
|
+
});
|
|
189
|
+
(0, mocha_1.it)("ensureReady refreshes an expired token before the request goes out", async () => {
|
|
190
|
+
const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}));
|
|
191
|
+
const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
|
|
192
|
+
await auth.ensureReady();
|
|
193
|
+
(0, chai_1.expect)(fetchMock.callCount).to.equal(1);
|
|
194
|
+
});
|
|
195
|
+
(0, mocha_1.it)("ensureReady leaves a valid token alone", async () => {
|
|
196
|
+
const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}));
|
|
197
|
+
const auth = cookieAuth({ cookie: expiryCookie(30), fetchMock });
|
|
198
|
+
await auth.ensureReady();
|
|
199
|
+
(0, chai_1.expect)(fetchMock.callCount).to.equal(0);
|
|
200
|
+
});
|
|
201
|
+
(0, mocha_1.it)("ensureReady does not reject when the refresh fails", async () => {
|
|
202
|
+
const fetchMock = sinon_1.default.stub().rejects(new Error("offline"));
|
|
203
|
+
const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
|
|
204
|
+
await auth.ensureReady(); // should not throw
|
|
205
|
+
});
|
|
70
206
|
});
|
|
71
207
|
// ---------------------------------------------------------------------------
|
|
72
208
|
// DooverTokenAuth
|
|
@@ -310,6 +446,24 @@ class MemoryProfileStore {
|
|
|
310
446
|
(0, mocha_1.it)("builds CookieAuth when no token-related input is present", () => {
|
|
311
447
|
(0, chai_1.expect)((0, build_auth_1.buildAuth)({})).to.be.instanceOf(cookie_auth_1.CookieAuth);
|
|
312
448
|
});
|
|
449
|
+
(0, mocha_1.it)("builds a refresh-capable CookieAuth from authServerUrl alone", async () => {
|
|
450
|
+
const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({}));
|
|
451
|
+
const auth = (0, build_auth_1.buildAuth)({
|
|
452
|
+
authServerUrl: "https://auth.example.com",
|
|
453
|
+
fetchImpl: fetchMock,
|
|
454
|
+
});
|
|
455
|
+
// authServerUrl says where to refresh; it is not token material, so this
|
|
456
|
+
// stays cookie auth rather than becoming a tokenless DooverTokenAuth.
|
|
457
|
+
(0, chai_1.expect)(auth).to.be.instanceOf(cookie_auth_1.CookieAuth);
|
|
458
|
+
(0, chai_1.expect)(await auth.handleUnauthorized()).to.equal(false); // no session cookie
|
|
459
|
+
});
|
|
460
|
+
(0, mocha_1.it)("still builds DooverTokenAuth when token material accompanies authServerUrl", () => {
|
|
461
|
+
const auth = (0, build_auth_1.buildAuth)({
|
|
462
|
+
authServerUrl: "https://auth.example.com",
|
|
463
|
+
refreshToken: "rt",
|
|
464
|
+
});
|
|
465
|
+
(0, chai_1.expect)(auth).to.be.instanceOf(doover_token_auth_1.DooverTokenAuth);
|
|
466
|
+
});
|
|
313
467
|
(0, mocha_1.it)("builds DooverTokenAuth when token is provided", () => {
|
|
314
468
|
const auth = (0, build_auth_1.buildAuth)({ token: "tok" });
|
|
315
469
|
(0, chai_1.expect)(auth).to.be.instanceOf(doover_token_auth_1.DooverTokenAuth);
|
|
@@ -506,6 +660,39 @@ class MemoryProfileStore {
|
|
|
506
660
|
(0, chai_1.expect)(headers.get("Authorization")).to.equal(null);
|
|
507
661
|
(0, chai_1.expect)(init?.credentials).to.equal("include");
|
|
508
662
|
});
|
|
663
|
+
(0, mocha_1.it)("refreshes and replays a 401 with refresh-capable cookie auth", async () => {
|
|
664
|
+
let itemsCalls = 0;
|
|
665
|
+
const fetchMock = (0, helpers_1.createFetchMock)((url) => {
|
|
666
|
+
if (url === "https://auth.example.com/app/refresh/") {
|
|
667
|
+
return (0, helpers_1.createJsonResponse)({});
|
|
668
|
+
}
|
|
669
|
+
itemsCalls += 1;
|
|
670
|
+
// First attempt uses the dead cookie; the replay succeeds.
|
|
671
|
+
return itemsCalls === 1
|
|
672
|
+
? (0, helpers_1.createJsonResponse)({ detail: "expired" }, { status: 401 })
|
|
673
|
+
: (0, helpers_1.createJsonResponse)({ items: [] });
|
|
674
|
+
});
|
|
675
|
+
const auth = cookieAuth({ cookie: expiryCookie(-60), fetchMock });
|
|
676
|
+
const client = new rest_client_1.RestClient({
|
|
677
|
+
dataRestUrl: "https://api.example.com",
|
|
678
|
+
controlApiUrl: "https://control.example.com",
|
|
679
|
+
dataWssUrl: "wss://ws.example.com",
|
|
680
|
+
fetchImpl: fetchMock,
|
|
681
|
+
}, auth);
|
|
682
|
+
const result = await client.get("/items");
|
|
683
|
+
(0, chai_1.expect)(result).to.deep.equal({ items: [] });
|
|
684
|
+
(0, chai_1.expect)(itemsCalls).to.equal(2);
|
|
685
|
+
});
|
|
686
|
+
(0, mocha_1.it)("surfaces the 401 when cookie auth cannot refresh", async () => {
|
|
687
|
+
const fetchMock = (0, helpers_1.createFetchMock)(() => (0, helpers_1.createJsonResponse)({ detail: "expired" }, { status: 401 }));
|
|
688
|
+
const client = new rest_client_1.RestClient({
|
|
689
|
+
dataRestUrl: "https://api.example.com",
|
|
690
|
+
controlApiUrl: "https://control.example.com",
|
|
691
|
+
dataWssUrl: "wss://ws.example.com",
|
|
692
|
+
fetchImpl: fetchMock,
|
|
693
|
+
}, new cookie_auth_1.CookieAuth());
|
|
694
|
+
await (0, chai_1.expect)(client.get("/items")).to.be.rejected;
|
|
695
|
+
});
|
|
509
696
|
(0, mocha_1.it)("uses credentials: include when no auth is provided (backward compat)", async () => {
|
|
510
697
|
const fetchMock = (0, helpers_1.createFetchMock)();
|
|
511
698
|
const client = new rest_client_1.RestClient({
|