@bravely-studios/account-web 0.3.9 → 0.4.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 +134 -5
- package/dist/ActivationStateMachine.d.ts +1 -1
- package/dist/ActivationStateMachine.d.ts.map +1 -1
- package/dist/ActivationStateMachine.js +6 -2
- package/dist/ActivationStateMachine.js.map +1 -1
- package/dist/BravelyAccountManager.d.ts +132 -2
- package/dist/BravelyAccountManager.d.ts.map +1 -1
- package/dist/BravelyAccountManager.js +429 -30
- package/dist/BravelyAccountManager.js.map +1 -1
- package/dist/components/BravelyProviderButtons.d.ts.map +1 -1
- package/dist/components/BravelyProviderButtons.js.map +1 -1
- package/dist/components/BravelySignInScreen.d.ts +55 -0
- package/dist/components/BravelySignInScreen.d.ts.map +1 -0
- package/dist/components/BravelySignInScreen.js +141 -0
- package/dist/components/BravelySignInScreen.js.map +1 -0
- package/dist/components/OfferSlotGrid.d.ts +204 -0
- package/dist/components/OfferSlotGrid.d.ts.map +1 -0
- package/dist/components/OfferSlotGrid.js +490 -0
- package/dist/components/OfferSlotGrid.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/installMetrics.d.ts +40 -0
- package/dist/installMetrics.d.ts.map +1 -0
- package/dist/installMetrics.js +128 -0
- package/dist/installMetrics.js.map +1 -0
- package/dist/oauth.d.ts +18 -0
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +3 -0
- package/dist/oauth.js.map +1 -1
- package/dist/storage.d.ts +23 -10
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +52 -16
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +23 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
|
@@ -20,9 +20,18 @@ const KEY_BAS = "bas";
|
|
|
20
20
|
const KEY_REFRESH = "refresh_token";
|
|
21
21
|
const KEY_BA_ID = "ba_id";
|
|
22
22
|
const KEY_EMAIL = "email";
|
|
23
|
+
const KEY_EXPIRES_AT = "expires_at";
|
|
23
24
|
const KEY_DPOP_JKT = "dpop_jkt_thumbprint";
|
|
24
25
|
const KEY_PKCE_VERIFIER = "pkce_verifier";
|
|
25
26
|
const KEY_PKCE_STATE = "pkce_state";
|
|
27
|
+
/**
|
|
28
|
+
* Proactive refresh window (D4): when the BAS has less than this much life
|
|
29
|
+
* left (or is already past `expires_at`), the manager rotates the session in
|
|
30
|
+
* the background via `grant_type=refresh_token` instead of waiting for a 401.
|
|
31
|
+
* BAS access tokens live 14 days; 48h is comfortably inside that while still
|
|
32
|
+
* rotating well before expiry for any returning visitor.
|
|
33
|
+
*/
|
|
34
|
+
export const PRE_EXPIRY_REFRESH_WINDOW_MS = 48 * 60 * 60 * 1000;
|
|
26
35
|
export class BravelyAccountManager {
|
|
27
36
|
cfg;
|
|
28
37
|
storage;
|
|
@@ -40,6 +49,23 @@ export class BravelyAccountManager {
|
|
|
40
49
|
libVersionPolicy = null;
|
|
41
50
|
dpopKeypairPromise = null;
|
|
42
51
|
dpopJktThumbprint = null;
|
|
52
|
+
/**
|
|
53
|
+
* PHASE-2 install→account reconciliation id (optional). When set, it is
|
|
54
|
+
* forwarded as `install_id` on BOTH the OAuth authorize URL and the
|
|
55
|
+
* `/oauth/token` exchange so the auth server can reconcile the install onto
|
|
56
|
+
* the resolved `ba_id`. `null` ⇒ nothing is sent and both legs stay
|
|
57
|
+
* byte-identical to the pre-`install_id` flow. Settable post-construction
|
|
58
|
+
* via `setInstallId()` because the host often learns the id asynchronously
|
|
59
|
+
* (e.g. after reading it from native bridge / first-launch storage).
|
|
60
|
+
*/
|
|
61
|
+
installId = null;
|
|
62
|
+
/**
|
|
63
|
+
* Single-flight guard for the refresh grant: concurrent 401s / proactive
|
|
64
|
+
* refreshes join the in-flight rotation instead of racing the one-time-use
|
|
65
|
+
* refresh token (a second concurrent presentation of the same token would
|
|
66
|
+
* trip the server's family reuse detection and revoke the session).
|
|
67
|
+
*/
|
|
68
|
+
refreshInFlight = null;
|
|
43
69
|
constructor(config) {
|
|
44
70
|
if (!config.authority)
|
|
45
71
|
throw new Error("authority is required");
|
|
@@ -59,9 +85,13 @@ export class BravelyAccountManager {
|
|
|
59
85
|
redirectUri,
|
|
60
86
|
scope: config.scope ?? DEFAULT_SCOPE,
|
|
61
87
|
libName: config.libName ?? "bravely-account-web",
|
|
62
|
-
libVersion: config.libVersion ?? "0.
|
|
88
|
+
libVersion: config.libVersion ?? "0.4.0",
|
|
63
89
|
};
|
|
64
90
|
this.storage = config.storage ?? defaultStorage();
|
|
91
|
+
// Optional PHASE-2 install→account reconciliation id. When the host omits
|
|
92
|
+
// it (the common case), `installId` stays null and no `install_id` rides
|
|
93
|
+
// on the authorize URL or token exchange — byte-identical legacy behavior.
|
|
94
|
+
this.installId = normalizeInstallId(config.installId);
|
|
65
95
|
// No-op when the host injects nothing — preserves prior silent behavior.
|
|
66
96
|
this.log = config.log ?? (() => undefined);
|
|
67
97
|
// Thread the same seam into the entitlement cache so its
|
|
@@ -101,10 +131,38 @@ export class BravelyAccountManager {
|
|
|
101
131
|
getLibVersionPolicy() {
|
|
102
132
|
return this.libVersionPolicy ? { ...this.libVersionPolicy } : null;
|
|
103
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Set (or clear) the PHASE-2 install→account reconciliation id. Idempotent.
|
|
136
|
+
* Pass the per-install identifier the native client minted at
|
|
137
|
+
* `app_first_opened`; the manager forwards it as `install_id` on the next
|
|
138
|
+
* OAuth authorize redirect AND on the token exchange, so the auth server's
|
|
139
|
+
* existing reconcile path can stamp the resolved `ba_id` onto that install.
|
|
140
|
+
*
|
|
141
|
+
* Pass `undefined`/`null`/empty to clear it — after which the flow is again
|
|
142
|
+
* byte-identical to the pre-`install_id` behavior (no param sent). Purely
|
|
143
|
+
* additive: existing consumers that never call this send no `install_id`.
|
|
144
|
+
*/
|
|
145
|
+
setInstallId(installId) {
|
|
146
|
+
this.installId = normalizeInstallId(installId);
|
|
147
|
+
}
|
|
148
|
+
/** Current install→account reconciliation id, or `null` if none is set. */
|
|
149
|
+
getInstallId() {
|
|
150
|
+
return this.installId;
|
|
151
|
+
}
|
|
104
152
|
/**
|
|
105
153
|
* Hydrate from persistent storage and (if a BAS is present) verify it
|
|
106
154
|
* against the identity API. Call once on page load before rendering UI
|
|
107
155
|
* that depends on `getState()`.
|
|
156
|
+
*
|
|
157
|
+
* D4 session-forever behavior:
|
|
158
|
+
* - A stored BAS yields `signed_in` immediately (cache-backed); the
|
|
159
|
+
* entitlement check runs in the background. Transport failures NEVER
|
|
160
|
+
* wipe tokens or flip the state to signed_out.
|
|
161
|
+
* - A BAS near/past `expires_at` triggers a background refresh-grant
|
|
162
|
+
* rotation (`PRE_EXPIRY_REFRESH_WINDOW_MS`).
|
|
163
|
+
* - No BAS but a stored refresh token → the session is resurrected via
|
|
164
|
+
* `grant_type=refresh_token` before giving up (sessions survive even
|
|
165
|
+
* if the access token row was lost).
|
|
108
166
|
*/
|
|
109
167
|
async restore() {
|
|
110
168
|
await this.cache.hydrate();
|
|
@@ -112,6 +170,25 @@ export class BravelyAccountManager {
|
|
|
112
170
|
const baId = await this.storage.get(KEY_BA_ID);
|
|
113
171
|
const email = await this.storage.get(KEY_EMAIL);
|
|
114
172
|
if (!bas || !baId || !email) {
|
|
173
|
+
// No usable BAS triple — but a durable refresh token can still
|
|
174
|
+
// resurrect the session without any user interaction.
|
|
175
|
+
const refresh = await this.storage.get(KEY_REFRESH);
|
|
176
|
+
if (refresh) {
|
|
177
|
+
this.activation.transition("app_launched", { bas_present: true });
|
|
178
|
+
const outcome = await this.refreshSession();
|
|
179
|
+
if (outcome === "refreshed") {
|
|
180
|
+
// persistRefreshedToken set the signed_in state; run the
|
|
181
|
+
// authoritative entitlement check in the background.
|
|
182
|
+
void this.refreshEntitlements().catch(() => undefined);
|
|
183
|
+
return this.getState();
|
|
184
|
+
}
|
|
185
|
+
// definitive_failure already cleared storage; transient failures
|
|
186
|
+
// KEEP the refresh token for the next launch (never wipe on
|
|
187
|
+
// transport failure) — but without a BAS there is no session to
|
|
188
|
+
// serve, so surface signed_out + pending_user_action.
|
|
189
|
+
this.activation.transition("bas_invalid");
|
|
190
|
+
return this.setState({ kind: "signed_out" });
|
|
191
|
+
}
|
|
115
192
|
this.activation.transition("app_launched_no_session", { bas_present: false });
|
|
116
193
|
return this.setState({ kind: "signed_out" });
|
|
117
194
|
}
|
|
@@ -128,6 +205,12 @@ export class BravelyAccountManager {
|
|
|
128
205
|
this.activation.force("fresh_launch_restoration");
|
|
129
206
|
}
|
|
130
207
|
this.setState({ kind: "signed_in", ba_id: baId, email, bas, entitlements: cachedEntitlements });
|
|
208
|
+
// Pre-expiry rotation: refresh in the background when the BAS is inside
|
|
209
|
+
// the proactive window (or already expired). Single-flight, so the
|
|
210
|
+
// entitlement refresh below joining a 401-triggered rotation is safe.
|
|
211
|
+
if (await this.shouldProactivelyRefresh()) {
|
|
212
|
+
void this.refreshSession().catch(() => undefined);
|
|
213
|
+
}
|
|
131
214
|
// Refresh in the background; failures fall back to cache.
|
|
132
215
|
void this.refreshEntitlements().catch(() => {
|
|
133
216
|
// Already handled by transition logic; nothing else to do.
|
|
@@ -169,7 +252,8 @@ export class BravelyAccountManager {
|
|
|
169
252
|
return this.setState({ kind: "failed", error: errorMessage(err) });
|
|
170
253
|
}
|
|
171
254
|
}
|
|
172
|
-
// Otherwise, kick off the redirect.
|
|
255
|
+
// Otherwise, kick off the redirect. `installId` rides along only when the
|
|
256
|
+
// host has set one (PHASE-2 reconcile); absent ⇒ legacy authorize URL.
|
|
173
257
|
const { verifier, state, authorizeUrl } = await preparePkce({
|
|
174
258
|
authority: this.cfg.authority,
|
|
175
259
|
clientId: this.cfg.appSlug,
|
|
@@ -177,6 +261,7 @@ export class BravelyAccountManager {
|
|
|
177
261
|
scope: this.cfg.scope,
|
|
178
262
|
loginHint: opts.loginHint,
|
|
179
263
|
provider: opts.provider,
|
|
264
|
+
installId: this.installId ?? undefined,
|
|
180
265
|
});
|
|
181
266
|
await this.storage.set(KEY_PKCE_VERIFIER, verifier);
|
|
182
267
|
await this.storage.set(KEY_PKCE_STATE, state);
|
|
@@ -189,6 +274,7 @@ export class BravelyAccountManager {
|
|
|
189
274
|
async signOut() {
|
|
190
275
|
try {
|
|
191
276
|
const bas = await this.storage.get(KEY_BAS);
|
|
277
|
+
const refresh = await this.storage.get(KEY_REFRESH);
|
|
192
278
|
if (bas) {
|
|
193
279
|
// Fire-and-forget revoke; if the OAuth server doesn't have a revoke
|
|
194
280
|
// endpoint yet (Gate 1), this is a no-op. Wrapped in try/catch so
|
|
@@ -196,7 +282,11 @@ export class BravelyAccountManager {
|
|
|
196
282
|
await this.fetchWithDeprecation(`${this.cfg.authority}/oauth/revoke`, {
|
|
197
283
|
method: "POST",
|
|
198
284
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
199
|
-
body: new URLSearchParams({
|
|
285
|
+
body: new URLSearchParams({
|
|
286
|
+
token: bas,
|
|
287
|
+
token_type_hint: "access_token",
|
|
288
|
+
client_id: this.cfg.appSlug,
|
|
289
|
+
}).toString(),
|
|
200
290
|
}).catch((err) => {
|
|
201
291
|
// Best-effort revoke; local sign-out proceeds regardless. Breadcrumb
|
|
202
292
|
// the cause (never the token) so a server-side revoke regression is
|
|
@@ -204,19 +294,50 @@ export class BravelyAccountManager {
|
|
|
204
294
|
this.log("signout_revoke_failed", { step: "oauth_revoke", error: errorMessage(err) });
|
|
205
295
|
});
|
|
206
296
|
}
|
|
297
|
+
// With sliding-365d refresh families (D4), explicit sign-out should
|
|
298
|
+
// also revoke the durable refresh family — RFC 7009 form, best-effort
|
|
299
|
+
// (the router's revocation endpoint is still pending; sending now means
|
|
300
|
+
// already-shipped libs revoke correctly the day it lands).
|
|
301
|
+
if (refresh) {
|
|
302
|
+
await this.fetchWithDeprecation(`${this.cfg.authority}/oauth/revoke`, {
|
|
303
|
+
method: "POST",
|
|
304
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
305
|
+
body: new URLSearchParams({
|
|
306
|
+
token: refresh,
|
|
307
|
+
token_type_hint: "refresh_token",
|
|
308
|
+
client_id: this.cfg.appSlug,
|
|
309
|
+
}).toString(),
|
|
310
|
+
}).catch((err) => {
|
|
311
|
+
this.log("signout_revoke_failed", {
|
|
312
|
+
step: "oauth_revoke_refresh",
|
|
313
|
+
error: errorMessage(err),
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
}
|
|
207
317
|
}
|
|
208
318
|
finally {
|
|
209
|
-
await this.
|
|
210
|
-
await this.storage.remove(KEY_REFRESH);
|
|
211
|
-
await this.storage.remove(KEY_BA_ID);
|
|
212
|
-
await this.storage.remove(KEY_EMAIL);
|
|
213
|
-
await this.storage.remove(KEY_DPOP_JKT);
|
|
214
|
-
await this.cache.invalidate();
|
|
215
|
-
this.dpopKeypairPromise = null;
|
|
216
|
-
this.dpopJktThumbprint = null;
|
|
217
|
-
this.setState({ kind: "signed_out" });
|
|
319
|
+
await this.clearLocalSession();
|
|
218
320
|
}
|
|
219
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* Drop every locally-persisted session artifact and flip to `signed_out`.
|
|
324
|
+
* The DESTRUCTIVE half of sign-out — only ever called from explicit user
|
|
325
|
+
* sign-out or a DEFINITIVE auth failure (`invalid_grant` on the refresh
|
|
326
|
+
* grant, or a 401 that survived a successful refresh-and-retry). Transport
|
|
327
|
+
* failures must never reach this.
|
|
328
|
+
*/
|
|
329
|
+
async clearLocalSession() {
|
|
330
|
+
await this.storage.remove(KEY_BAS);
|
|
331
|
+
await this.storage.remove(KEY_REFRESH);
|
|
332
|
+
await this.storage.remove(KEY_BA_ID);
|
|
333
|
+
await this.storage.remove(KEY_EMAIL);
|
|
334
|
+
await this.storage.remove(KEY_EXPIRES_AT);
|
|
335
|
+
await this.storage.remove(KEY_DPOP_JKT);
|
|
336
|
+
await this.cache.invalidate();
|
|
337
|
+
this.dpopKeypairPromise = null;
|
|
338
|
+
this.dpopJktThumbprint = null;
|
|
339
|
+
this.setState({ kind: "signed_out" });
|
|
340
|
+
}
|
|
220
341
|
/**
|
|
221
342
|
* Read entitlements, serving the cached row if fresh and refreshing
|
|
222
343
|
* opportunistically. Always returns an array (possibly empty).
|
|
@@ -319,8 +440,9 @@ export class BravelyAccountManager {
|
|
|
319
440
|
* introduced in the Mac/iOS v2 onboarding port.
|
|
320
441
|
*
|
|
321
442
|
* Throws if the user is not signed in (`not_signed_in`) or if the server
|
|
322
|
-
* returns a non-2xx status.
|
|
323
|
-
*
|
|
443
|
+
* returns a non-2xx status. A definitive 401 (refresh-and-retry exhausted)
|
|
444
|
+
* is surfaced as `not_signed_in`; a 401 riding a transient refresh failure
|
|
445
|
+
* keeps the session and throws a plain request failure.
|
|
324
446
|
*/
|
|
325
447
|
async getAccountEntitlements() {
|
|
326
448
|
const bas = await this.requireBas();
|
|
@@ -328,8 +450,12 @@ export class BravelyAccountManager {
|
|
|
328
450
|
method: "GET",
|
|
329
451
|
});
|
|
330
452
|
if (res.status === 401) {
|
|
331
|
-
|
|
332
|
-
|
|
453
|
+
// `fetchWithBas` already refreshed-and-retried; a surviving 401 either
|
|
454
|
+
// cleared the session (definitive ⇒ not_signed_in) or rode a transient
|
|
455
|
+
// refresh failure (session kept ⇒ plain request failure).
|
|
456
|
+
if (await this.sessionWasCleared())
|
|
457
|
+
throw new Error("not_signed_in");
|
|
458
|
+
throw new Error("getAccountEntitlements failed: 401");
|
|
333
459
|
}
|
|
334
460
|
if (!res.ok) {
|
|
335
461
|
throw new Error(`getAccountEntitlements failed: ${res.status}`);
|
|
@@ -379,7 +505,11 @@ export class BravelyAccountManager {
|
|
|
379
505
|
try {
|
|
380
506
|
const url = new URL("/api/entitlements", this.cfg.identityOrigin);
|
|
381
507
|
url.searchParams.set("app", this.cfg.appSlug);
|
|
382
|
-
|
|
508
|
+
// Re-read the BAS each attempt: a mid-poll refresh rotation (401 →
|
|
509
|
+
// refresh-then-retry inside fetchWithBas) replaces the stored token,
|
|
510
|
+
// and polling on with the stale capture would 401 every round.
|
|
511
|
+
const currentBas = (await this.storage.get(KEY_BAS)) ?? bas;
|
|
512
|
+
const res = await this.fetchWithBas(url.toString(), currentBas, {
|
|
383
513
|
method: "GET",
|
|
384
514
|
});
|
|
385
515
|
if (res.ok) {
|
|
@@ -455,6 +585,12 @@ export class BravelyAccountManager {
|
|
|
455
585
|
code_verifier: verifier,
|
|
456
586
|
client_id: this.cfg.appSlug,
|
|
457
587
|
});
|
|
588
|
+
// PHASE-2 reconcile: carry the install id on the exchange too, so the
|
|
589
|
+
// server reconciles even if it keys off the token-leg value rather than
|
|
590
|
+
// (or in addition to) the value persisted from the authorize leg. Only
|
|
591
|
+
// appended when set — absent ⇒ the body is byte-identical to legacy.
|
|
592
|
+
if (this.installId)
|
|
593
|
+
body.set("install_id", this.installId);
|
|
458
594
|
const res = await this.fetchWithDeprecation(`${this.cfg.authority}/oauth/token`, {
|
|
459
595
|
method: "POST",
|
|
460
596
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
@@ -474,7 +610,12 @@ export class BravelyAccountManager {
|
|
|
474
610
|
await this.persistToken(data);
|
|
475
611
|
return this.getState();
|
|
476
612
|
}
|
|
477
|
-
|
|
613
|
+
/**
|
|
614
|
+
* Persist the token + identity rows shared by the sign-in and refresh
|
|
615
|
+
* legs (BAS, rotated refresh token, ba_id, email, expires_at, lib version
|
|
616
|
+
* policy). Returns the inline entitlement snapshot.
|
|
617
|
+
*/
|
|
618
|
+
async persistSessionCore(data) {
|
|
478
619
|
const bas = "access_token" in data ? data.access_token : data.bas;
|
|
479
620
|
const refresh = data.refresh_token ?? null;
|
|
480
621
|
await this.storage.set(KEY_BAS, bas);
|
|
@@ -482,11 +623,19 @@ export class BravelyAccountManager {
|
|
|
482
623
|
await this.storage.set(KEY_REFRESH, refresh);
|
|
483
624
|
await this.storage.set(KEY_BA_ID, data.bravely_account_id);
|
|
484
625
|
await this.storage.set(KEY_EMAIL, data.email);
|
|
626
|
+
// Track the BAS expiry client-side so the proactive refresh loop can
|
|
627
|
+
// rotate before a 401 ever happens (D4).
|
|
628
|
+
if (data.expires_at) {
|
|
629
|
+
await this.storage.set(KEY_EXPIRES_AT, data.expires_at);
|
|
630
|
+
}
|
|
485
631
|
if ("bravely_lib_version" in data && data.bravely_lib_version) {
|
|
486
632
|
this.libVersionPolicy = data.bravely_lib_version;
|
|
487
633
|
}
|
|
634
|
+
return { bas, entitlements: (data.active_entitlements ?? []) };
|
|
635
|
+
}
|
|
636
|
+
async persistToken(data) {
|
|
637
|
+
const { bas, entitlements } = await this.persistSessionCore(data);
|
|
488
638
|
// Active entitlements ride along on Gate 1 BAS exchange (B6).
|
|
489
|
-
const entitlements = (data.active_entitlements ?? []);
|
|
490
639
|
await this.cache.update({ entitlements });
|
|
491
640
|
this.setState({
|
|
492
641
|
kind: "signed_in",
|
|
@@ -503,6 +652,172 @@ export class BravelyAccountManager {
|
|
|
503
652
|
this.activation.transition("entitlement_check_success_none");
|
|
504
653
|
}
|
|
505
654
|
}
|
|
655
|
+
/**
|
|
656
|
+
* Persist a `grant_type=refresh_token` rotation result. Differs from the
|
|
657
|
+
* sign-in leg deliberately:
|
|
658
|
+
* - The inline entitlement snapshot is best-effort on the server (an RC
|
|
659
|
+
* hiccup yields `[]` so rotation never fails) — so an EMPTY snapshot
|
|
660
|
+
* must not downgrade the 72h offline cache mid-session. Non-empty
|
|
661
|
+
* snapshots update cache + state; empty ones leave the authoritative
|
|
662
|
+
* `/api/entitlements` check (which runs on every restore) in charge.
|
|
663
|
+
* - No `entitlement_check_*` activation events are fired here for the
|
|
664
|
+
* same reason; only `bas_found` advances the restore lane.
|
|
665
|
+
*/
|
|
666
|
+
async persistRefreshedToken(data) {
|
|
667
|
+
const { bas, entitlements } = await this.persistSessionCore(data);
|
|
668
|
+
// Contract enforcement (router ≥1.6.0 always rotates): if a 200 refresh
|
|
669
|
+
// response somehow lacks the rotated token, the OLD token is spent
|
|
670
|
+
// server-side — presenting it again later would read as reuse and revoke
|
|
671
|
+
// the family. Drop it: the fresh BAS still serves its full window, and
|
|
672
|
+
// the next 401 walks the no_refresh_token lane instead of a false-reuse
|
|
673
|
+
// revoke.
|
|
674
|
+
if (!data.refresh_token) {
|
|
675
|
+
await this.storage.remove(KEY_REFRESH);
|
|
676
|
+
this.log("session_refresh_missing_rotated_token", { step: "oauth_refresh" });
|
|
677
|
+
}
|
|
678
|
+
if (entitlements.length > 0) {
|
|
679
|
+
await this.cache.update({ entitlements, dpopJktThumbprint: this.dpopJktThumbprint });
|
|
680
|
+
}
|
|
681
|
+
const effective = entitlements.length > 0
|
|
682
|
+
? entitlements
|
|
683
|
+
: this.state.kind === "signed_in"
|
|
684
|
+
? this.state.entitlements
|
|
685
|
+
: (this.cache.raw()?.entitlements ?? []);
|
|
686
|
+
this.setState({
|
|
687
|
+
kind: "signed_in",
|
|
688
|
+
ba_id: data.bravely_account_id,
|
|
689
|
+
email: data.email,
|
|
690
|
+
bas,
|
|
691
|
+
entitlements: effective,
|
|
692
|
+
});
|
|
693
|
+
this.activation.transition("bas_found");
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* True when the stored BAS is inside the proactive-refresh window (or past
|
|
697
|
+
* expiry). Unknown expiry (legacy rows) ⇒ false — the 401-retry path
|
|
698
|
+
* covers those sessions.
|
|
699
|
+
*/
|
|
700
|
+
async shouldProactivelyRefresh() {
|
|
701
|
+
const expiresAt = await this.storage.get(KEY_EXPIRES_AT);
|
|
702
|
+
if (!expiresAt)
|
|
703
|
+
return false;
|
|
704
|
+
const parsed = Date.parse(expiresAt);
|
|
705
|
+
if (Number.isNaN(parsed))
|
|
706
|
+
return false;
|
|
707
|
+
return parsed - Date.now() < PRE_EXPIRY_REFRESH_WINDOW_MS;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Rotate the session via `POST {authority}/oauth/token` with
|
|
711
|
+
* `grant_type=refresh_token` (router ≥1.6.0). Single-flight: concurrent
|
|
712
|
+
* callers join the in-flight rotation (the refresh token is one-time-use;
|
|
713
|
+
* racing it would trip server-side reuse detection).
|
|
714
|
+
*
|
|
715
|
+
* Cross-TAB races are handled by two layers: (a) where available, a Web
|
|
716
|
+
* Lock (`bravely:session_refresh:<slug>`) serializes rotations across tabs
|
|
717
|
+
* sharing the same IndexedDB token; (b) the token is re-read inside the
|
|
718
|
+
* critical section, and if another tab already rotated (stored BAS changed
|
|
719
|
+
* while we waited) the rotation is skipped. Browsers without Web Locks
|
|
720
|
+
* fall back to the server's 30s idempotent-retry grace for near-
|
|
721
|
+
* simultaneous presentations of the same token.
|
|
722
|
+
*
|
|
723
|
+
* Destructive ONLY on a definitive rejection (`invalid_grant` / 401):
|
|
724
|
+
* transport failures, 5xx, and 429 keep every token in place.
|
|
725
|
+
*/
|
|
726
|
+
refreshSession() {
|
|
727
|
+
if (!this.refreshInFlight) {
|
|
728
|
+
this.refreshInFlight = this.refreshSessionCrossTab().finally(() => {
|
|
729
|
+
this.refreshInFlight = null;
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
return this.refreshInFlight;
|
|
733
|
+
}
|
|
734
|
+
async refreshSessionCrossTab() {
|
|
735
|
+
const basBefore = await this.storage.get(KEY_BAS);
|
|
736
|
+
const run = async () => {
|
|
737
|
+
// Another tab may have rotated while we waited on the lock — adopt its
|
|
738
|
+
// result instead of spending a second rotation.
|
|
739
|
+
if (basBefore) {
|
|
740
|
+
const basNow = await this.storage.get(KEY_BAS);
|
|
741
|
+
if (basNow && basNow !== basBefore)
|
|
742
|
+
return "refreshed";
|
|
743
|
+
}
|
|
744
|
+
return this.doRefreshSession();
|
|
745
|
+
};
|
|
746
|
+
const locks = globalThis.navigator?.locks;
|
|
747
|
+
if (locks?.request) {
|
|
748
|
+
try {
|
|
749
|
+
return await locks.request(`bravely:session_refresh:${this.cfg.appSlug}`, run);
|
|
750
|
+
}
|
|
751
|
+
catch (error) {
|
|
752
|
+
// Lock machinery failure (not a refresh verdict) — fall through to
|
|
753
|
+
// the unlocked path rather than failing the rotation.
|
|
754
|
+
this.log("session_refresh_lock_error", {
|
|
755
|
+
step: "oauth_refresh",
|
|
756
|
+
error: errorMessage(error),
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return run();
|
|
761
|
+
}
|
|
762
|
+
async doRefreshSession() {
|
|
763
|
+
const refresh = await this.storage.get(KEY_REFRESH);
|
|
764
|
+
if (!refresh)
|
|
765
|
+
return "no_refresh_token";
|
|
766
|
+
try {
|
|
767
|
+
const res = await this.fetchWithDeprecation(`${this.cfg.authority}/oauth/token`, {
|
|
768
|
+
method: "POST",
|
|
769
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
770
|
+
body: new URLSearchParams({
|
|
771
|
+
grant_type: "refresh_token",
|
|
772
|
+
refresh_token: refresh,
|
|
773
|
+
client_id: this.cfg.appSlug,
|
|
774
|
+
}).toString(),
|
|
775
|
+
});
|
|
776
|
+
if (res.ok) {
|
|
777
|
+
const data = (await res.json());
|
|
778
|
+
await this.persistRefreshedToken(data);
|
|
779
|
+
this.log("session_refreshed", { step: "oauth_refresh" });
|
|
780
|
+
return "refreshed";
|
|
781
|
+
}
|
|
782
|
+
let err = {};
|
|
783
|
+
try {
|
|
784
|
+
err = (await res.json());
|
|
785
|
+
}
|
|
786
|
+
catch {
|
|
787
|
+
/* ignore non-JSON error bodies */
|
|
788
|
+
}
|
|
789
|
+
// `invalid_grant` covers invalid / revoked / reuse-detected tokens —
|
|
790
|
+
// the server's uniform rejection. 401 = invalid_client-class failures.
|
|
791
|
+
// Both are definitive: the session cannot be recovered.
|
|
792
|
+
if (err.error === "invalid_grant" || res.status === 401) {
|
|
793
|
+
this.log("session_refresh_rejected", {
|
|
794
|
+
step: "oauth_refresh",
|
|
795
|
+
status: res.status,
|
|
796
|
+
error: err.error ?? null,
|
|
797
|
+
});
|
|
798
|
+
await this.clearLocalSession();
|
|
799
|
+
return "definitive_failure";
|
|
800
|
+
}
|
|
801
|
+
// 429 / 5xx / anything else: server-side trouble, NOT a verdict on the
|
|
802
|
+
// token. Keep the session and retry later.
|
|
803
|
+
this.log("session_refresh_http_error", {
|
|
804
|
+
step: "oauth_refresh",
|
|
805
|
+
status: res.status,
|
|
806
|
+
error: err.error ?? null,
|
|
807
|
+
});
|
|
808
|
+
return "transient_failure";
|
|
809
|
+
}
|
|
810
|
+
catch (error) {
|
|
811
|
+
// Network failure (offline) or the 426 kill-switch. Neither is a
|
|
812
|
+
// verdict on the refresh token — tokens are NEVER wiped on transport
|
|
813
|
+
// failure (D4).
|
|
814
|
+
this.log("session_refresh_network_error", {
|
|
815
|
+
step: "oauth_refresh",
|
|
816
|
+
error: errorMessage(error),
|
|
817
|
+
});
|
|
818
|
+
return "transient_failure";
|
|
819
|
+
}
|
|
820
|
+
}
|
|
506
821
|
async refreshEntitlements() {
|
|
507
822
|
const bas = await this.storage.get(KEY_BAS);
|
|
508
823
|
if (!bas)
|
|
@@ -514,13 +829,26 @@ export class BravelyAccountManager {
|
|
|
514
829
|
method: "GET",
|
|
515
830
|
});
|
|
516
831
|
if (res.status === 401) {
|
|
517
|
-
//
|
|
518
|
-
|
|
832
|
+
// `fetchWithBas` already ran refresh-then-retry-once and cleared the
|
|
833
|
+
// session iff the failure was definitive. Only mirror that verdict —
|
|
834
|
+
// never sign out on a 401 that rode a transient refresh failure.
|
|
835
|
+
if (await this.sessionWasCleared()) {
|
|
836
|
+
this.log("entitlement_refresh_unauthorized", {
|
|
837
|
+
step: "refresh_entitlements",
|
|
838
|
+
status: 401,
|
|
839
|
+
});
|
|
840
|
+
return [];
|
|
841
|
+
}
|
|
842
|
+
const cached = this.cache.getCached();
|
|
843
|
+
this.log("entitlement_refresh_unauthorized_transient", {
|
|
519
844
|
step: "refresh_entitlements",
|
|
520
845
|
status: 401,
|
|
846
|
+
entitlement_cache_present: !!cached,
|
|
521
847
|
});
|
|
522
|
-
|
|
523
|
-
|
|
848
|
+
this.activation.transition("entitlement_check_failure", {
|
|
849
|
+
entitlement_cache_present: !!cached,
|
|
850
|
+
});
|
|
851
|
+
return cached?.entitlements ?? [];
|
|
524
852
|
}
|
|
525
853
|
if (!res.ok) {
|
|
526
854
|
const cached = this.cache.getCached();
|
|
@@ -569,15 +897,74 @@ export class BravelyAccountManager {
|
|
|
569
897
|
* the current router accepts Bearer on shared BAS endpoints, but attach a
|
|
570
898
|
* real RFC 9449 proof in the `DPoP` header so Gate 2 traffic is already
|
|
571
899
|
* exercising proof generation.
|
|
900
|
+
*
|
|
901
|
+
* D4 401 handling — refresh-then-retry-once: a 401 triggers ONE refresh
|
|
902
|
+
* grant; on success the request is retried with the new BAS. Destructive
|
|
903
|
+
* sign-out happens ONLY when the failure is definitive:
|
|
904
|
+
* - the refresh grant itself was rejected (`invalid_grant`/401), or
|
|
905
|
+
* - there is no refresh token to present, or
|
|
906
|
+
* - the retried request STILL came back 401 after a successful refresh.
|
|
907
|
+
* A transient refresh failure (offline/5xx/429) returns the original 401
|
|
908
|
+
* response WITHOUT touching the stored session — callers must treat a 401
|
|
909
|
+
* with the session still present as a transient failure (serve cache).
|
|
572
910
|
*/
|
|
573
911
|
async fetchWithBas(input, bas, init = {}) {
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
912
|
+
const doFetch = async (token) => {
|
|
913
|
+
const method = init.method ?? "GET";
|
|
914
|
+
const headers = new Headers(init.headers);
|
|
915
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
916
|
+
const proof = await this.dpopProof(method, input, token);
|
|
917
|
+
if (proof)
|
|
918
|
+
headers.set("DPoP", proof);
|
|
919
|
+
return this.fetchWithDeprecation(input, { ...init, method, headers });
|
|
920
|
+
};
|
|
921
|
+
const res = await doFetch(bas);
|
|
922
|
+
if (res.status !== 401)
|
|
923
|
+
return res;
|
|
924
|
+
// Another caller may have already rotated the session while this request
|
|
925
|
+
// was in flight (the caller captured a now-stale BAS). Retry with the
|
|
926
|
+
// current stored token before spending a rotation.
|
|
927
|
+
const current = await this.storage.get(KEY_BAS);
|
|
928
|
+
if (current && current !== bas) {
|
|
929
|
+
const retriedWithCurrent = await doFetch(current);
|
|
930
|
+
if (retriedWithCurrent.status !== 401)
|
|
931
|
+
return retriedWithCurrent;
|
|
932
|
+
// Still 401 — fall through to a real refresh with the live token.
|
|
933
|
+
}
|
|
934
|
+
const outcome = await this.refreshSession();
|
|
935
|
+
if (outcome === "refreshed") {
|
|
936
|
+
const rotated = await this.storage.get(KEY_BAS);
|
|
937
|
+
if (!rotated)
|
|
938
|
+
return res; // defensive; persistRefreshedToken always writes it
|
|
939
|
+
const retried = await doFetch(rotated);
|
|
940
|
+
if (retried.status === 401) {
|
|
941
|
+
// Fresh BAS minted seconds ago and still rejected — definitive.
|
|
942
|
+
this.log("bas_unauthorized_after_refresh", { step: "fetch_with_bas", status: 401 });
|
|
943
|
+
await this.clearLocalSession();
|
|
944
|
+
}
|
|
945
|
+
return retried;
|
|
946
|
+
}
|
|
947
|
+
if (outcome === "no_refresh_token" || outcome === "definitive_failure") {
|
|
948
|
+
// 401 with no recovery path (definitive_failure already cleared
|
|
949
|
+
// storage inside the refresh; the no-token path clears here).
|
|
950
|
+
if (outcome === "no_refresh_token") {
|
|
951
|
+
this.log("bas_unauthorized_no_refresh_token", { step: "fetch_with_bas", status: 401 });
|
|
952
|
+
await this.clearLocalSession();
|
|
953
|
+
}
|
|
954
|
+
return res;
|
|
955
|
+
}
|
|
956
|
+
// transient_failure: keep the session; surface the original 401.
|
|
957
|
+
return res;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* True when a 401 response should be treated as a signed-out verdict:
|
|
961
|
+
* `fetchWithBas` has already run the refresh-then-retry dance and cleared
|
|
962
|
+
* local storage iff the failure was definitive. If the BAS row is still
|
|
963
|
+
* present, the 401 rode a TRANSIENT refresh failure — the session is kept
|
|
964
|
+
* and callers must degrade gracefully (serve cache) instead of signing out.
|
|
965
|
+
*/
|
|
966
|
+
async sessionWasCleared() {
|
|
967
|
+
return (await this.storage.get(KEY_BAS)) === null;
|
|
581
968
|
}
|
|
582
969
|
async dpopProof(method, url, accessToken) {
|
|
583
970
|
const keypair = await this.getDpopKeypair();
|
|
@@ -673,6 +1060,18 @@ export class BravelyAccountManager {
|
|
|
673
1060
|
function stripTrailingSlash(s) {
|
|
674
1061
|
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
675
1062
|
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Normalize an optional install id: trim whitespace and collapse empty /
|
|
1065
|
+
* missing values to `null` so the manager's "is one set?" check is a simple
|
|
1066
|
+
* truthiness test. A non-empty value is passed through verbatim (the auth
|
|
1067
|
+
* server applies its own length cap on the `app_auth_codes` write).
|
|
1068
|
+
*/
|
|
1069
|
+
function normalizeInstallId(value) {
|
|
1070
|
+
if (typeof value !== "string")
|
|
1071
|
+
return null;
|
|
1072
|
+
const trimmed = value.trim();
|
|
1073
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
1074
|
+
}
|
|
676
1075
|
function supportsDpopCrypto() {
|
|
677
1076
|
return (typeof globalThis !== "undefined" &&
|
|
678
1077
|
typeof globalThis.crypto !== "undefined" &&
|