@assembly-js/app-foundation 0.5.2 → 0.5.4
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/CHANGELOG.md +6 -0
- package/bin/foundation-exports.mjs +3 -3
- package/dist/lib-client.d.ts +8 -0
- package/dist/lib-client.js +162 -9
- package/dist/origin-bootstrap.d.ts +6 -0
- package/dist/origin-bootstrap.js +13 -0
- package/dist/proxy.js +3 -1
- package/dist/reexchange.js +3 -1
- package/dist/session-exchange.d.ts +2 -0
- package/dist/session-exchange.js +10 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,12 @@ A patch release never changes what an app's build requires; a minor release may
|
|
|
19
19
|
not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
|
|
20
20
|
`bump: minor` change file.
|
|
21
21
|
|
|
22
|
+
## 0.5.4
|
|
23
|
+
- [fix] A tab left open renews its sign-in before it expires, so clicking into a page after an hour no longer shows the signed-out screen.
|
|
24
|
+
|
|
25
|
+
## 0.5.3
|
|
26
|
+
- [fix] Sign-in renewal is now bounded to one attempt per 30 seconds per tab, so a burst of expired requests renews once and a late response can no longer undo a fresh renewal.
|
|
27
|
+
|
|
22
28
|
## 0.5.2
|
|
23
29
|
- [fix] The builder preview no longer counts a page marked as an error document in its server HTML as the app having painted, even when it streams in before the page finishes hydrating.
|
|
24
30
|
|
|
@@ -21,7 +21,7 @@ export const NON_CLIENT_EXPORTS = new Set([
|
|
|
21
21
|
'ActionErrorStatus', 'AirtableFieldSchema', 'AppProxyCaller', 'AppProxyOptions', 'AirtableFormattedField',
|
|
22
22
|
'AirtableFormattedRecord', 'AirtableRecord', 'AirtableSchemaResult', 'AirtableTableSchema',
|
|
23
23
|
'AssemblyProxyForwardContext', 'AssemblyProxyOptions', 'AuthUnavailableError',
|
|
24
|
-
'BEARER_COOKIE_NAME', 'BEARER_HEADER_NAME', 'BEARER_IN_BODY_HEADER', 'BEARER_TTL_MS',
|
|
24
|
+
'BEARER_COOKIE_NAME', 'BEARER_EXPIRY_COOKIE_NAME', 'BEARER_HEADER_NAME', 'BEARER_IN_BODY_HEADER', 'BEARER_TTL_MS',
|
|
25
25
|
'CLIENT_BEARER_HEADER', 'CLIENT_FLAGS_COOKIE_NAME', 'COOKIE_OUTCOME_EVENT',
|
|
26
26
|
'COOKIE_PROBE_NAME', 'Caller', 'ClearClientNotificationsInput', 'ClientAccessScope',
|
|
27
27
|
'ConnectionStatus', 'ConnectorError', 'ContractDocumentError', 'ConnectorErrorCode', 'CookieOutcome', 'CronHandler',
|
|
@@ -53,9 +53,9 @@ export const NON_CLIENT_EXPORTS = new Set([
|
|
|
53
53
|
'isValidHexColor', 'listAll', 'logCookieOutcome', 'logFailures', 'logPaintedBeacon',
|
|
54
54
|
'logRenewOutcome', 'makeTestActionContext', 'mintBearerFromAppKey',
|
|
55
55
|
'mintBearerFromSessionToken', 'nextRunAfter', 'normalizeResourcePage', 'notFound', 'notifyInternalUsers',
|
|
56
|
-
'parseExchangeRejection', 'parseUuid', 'principalKind', 'readCookie', 'recordNetworkCapture',
|
|
56
|
+
'parseExchangeRejection', 'parseUuid', 'principalKind', 'readBearerExpiry', 'readCookie', 'recordNetworkCapture',
|
|
57
57
|
'redactUrl', 'replaceChildRows', 'requireAuth', 'requireCompanyScope', 'resolveFont',
|
|
58
|
-
'resolveResource', 'sendThrottled', 'serializeBearerCookie', 'serializeCookieProbe',
|
|
58
|
+
'resolveResource', 'sendThrottled', 'serializeBearerCookie', 'serializeBearerExpiryCookie', 'serializeCookieProbe',
|
|
59
59
|
'setCookielessBearer', 'slackErrorCode', 'slackFindChannelId', 'slackListChannels',
|
|
60
60
|
'slackPostMessage', 'slackResolveMentions', 'snippetFromRequestBody', 'verifyBearer',
|
|
61
61
|
]);
|
package/dist/lib-client.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NavigateDestination, NavigateToOptions } from './bridge-vnext.js';
|
|
2
|
+
export declare function resetRenewalStateForTests(): void;
|
|
2
3
|
export declare function reexchangeBearer(): Promise<boolean>;
|
|
3
4
|
export declare function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
4
5
|
export declare function downloadViaBlob(url: string, filename: string): Promise<void>;
|
|
@@ -10,6 +11,13 @@ export declare function downloadViaBlob(url: string, filename: string): Promise<
|
|
|
10
11
|
* Exported for tests; runs once at module load in the browser.
|
|
11
12
|
*/
|
|
12
13
|
export declare function bootstrapCookielessBearer(): Promise<boolean>;
|
|
14
|
+
/**
|
|
15
|
+
* Arms one timer for RENEWAL_LEAD_MS before the expiry in the cookie, replacing
|
|
16
|
+
* any pending one. Exported for tests; runs at module load and, with
|
|
17
|
+
* afterRenewal, after every successful renewal.
|
|
18
|
+
*/
|
|
19
|
+
export declare function scheduleProactiveRenewal(afterRenewal?: boolean): void;
|
|
20
|
+
export declare function handleVisibilityChangeForTests(): void;
|
|
13
21
|
/**
|
|
14
22
|
* Returns an onClick handler that navigates the parent Assembly portal to the
|
|
15
23
|
* given typed destination. Use in button or anchor onClick props.
|
package/dist/lib-client.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// never be dragged into the Edge middleware bundle that imports ./client
|
|
6
6
|
// (proxy.ts pulls constants from that barrel).
|
|
7
7
|
import { AssemblyBridge } from '@assembly-js/app-bridge';
|
|
8
|
-
import { APP_CSRF_HEADER, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
|
|
8
|
+
import { APP_CSRF_HEADER, COOKIE_PROBE_NAME, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
|
|
9
|
+
import { readBearerExpiry, readCookie } from './origin-bootstrap.js';
|
|
9
10
|
import { cookielessReexchangeHeaders, cookiesAreBlocked, getCookielessBearer, setCookielessBearer, } from './cookieless-bearer.js';
|
|
10
11
|
const REEXCHANGE_PATH = '/api/assembly-reexchange';
|
|
11
12
|
// Single-flight gate for renewals. Session tokens are single-use for exchange:
|
|
@@ -13,18 +14,72 @@ const REEXCHANGE_PATH = '/api/assembly-reexchange';
|
|
|
13
14
|
// once, and uncoordinated renewals would race each other for the token — the
|
|
14
15
|
// first mints the bearer and the losers surface their original 401 even though
|
|
15
16
|
// a valid bearer was just installed. All concurrent callers share this one
|
|
16
|
-
// promise instead
|
|
17
|
+
// promise instead.
|
|
17
18
|
let renewalInFlight = null;
|
|
19
|
+
// After a renewal POST settles, its verdict stands for this long so a burst of
|
|
20
|
+
// 401s (and a failing route that keeps answering 401) costs one mint, not one
|
|
21
|
+
// per response.
|
|
22
|
+
const RENEWAL_WINDOW_MS = 30_000;
|
|
23
|
+
let lastVerdict = null;
|
|
24
|
+
let probeEverSet = false;
|
|
25
|
+
const probeIsSet = () => {
|
|
26
|
+
const set = readCookie(COOKIE_PROBE_NAME) !== null;
|
|
27
|
+
if (set)
|
|
28
|
+
probeEverSet = true;
|
|
29
|
+
return set;
|
|
30
|
+
};
|
|
31
|
+
// The probe is gone by the time the first 401 triggers a renewal, so it has to
|
|
32
|
+
// be seen while the page still holds a valid bearer: at load and before each
|
|
33
|
+
// request.
|
|
34
|
+
probeIsSet();
|
|
35
|
+
// Test seam: module state otherwise outlives a single test.
|
|
36
|
+
export function resetRenewalStateForTests() {
|
|
37
|
+
renewalInFlight = null;
|
|
38
|
+
lastVerdict = null;
|
|
39
|
+
probeEverSet = false;
|
|
40
|
+
clearProactiveRenewal();
|
|
41
|
+
scheduledExpiry = null;
|
|
42
|
+
expiryEverSeen = false;
|
|
43
|
+
}
|
|
44
|
+
// A late 401 response carries the proxy's cookie deletion and can erase the
|
|
45
|
+
// bearer a sibling call just renewed. The probe is cleared together with the
|
|
46
|
+
// bearer and is readable from script (the bearer is HttpOnly), so its absence
|
|
47
|
+
// is the only signal that the cached success no longer holds.
|
|
48
|
+
function verdictInWindow() {
|
|
49
|
+
if (!lastVerdict || Date.now() - lastVerdict.at >= RENEWAL_WINDOW_MS) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (lastVerdict.ok && lastVerdict.probeSeen && !probeIsSet())
|
|
53
|
+
return null;
|
|
54
|
+
return lastVerdict.ok;
|
|
55
|
+
}
|
|
18
56
|
// Silently renews the bearer cookie after a 60-minute expiry. Asks the bridge
|
|
19
57
|
// for the freshest session token (the parent keeps it alive) and posts it to the
|
|
20
58
|
// re-exchange route, which mints a new bearer and sets the HttpOnly cookie — no
|
|
21
|
-
// page reload. Returns true iff a fresh bearer
|
|
22
|
-
//
|
|
23
|
-
//
|
|
59
|
+
// page reload. Returns true iff a fresh bearer is in place. Inside the window
|
|
60
|
+
// after a POST it answers from that POST's outcome without sending another, so
|
|
61
|
+
// authFetch gets false for the window after a failed renewal instead of a fresh
|
|
62
|
+
// mint per 401. A no_token outcome sends nothing and starts no window. This is
|
|
63
|
+
// the only place app code touches the raw session token, to re-mint and keep the
|
|
64
|
+
// URL aligned with the cookie; it is never sent to the platform API.
|
|
24
65
|
export function reexchangeBearer() {
|
|
25
66
|
if (renewalInFlight)
|
|
26
67
|
return renewalInFlight;
|
|
27
|
-
|
|
68
|
+
probeIsSet();
|
|
69
|
+
const cached = verdictInWindow();
|
|
70
|
+
if (cached !== null)
|
|
71
|
+
return Promise.resolve(cached);
|
|
72
|
+
const renewal = renewBearer()
|
|
73
|
+
.then(({ ok, sent }) => {
|
|
74
|
+
probeIsSet();
|
|
75
|
+
lastVerdict = sent ? { at: Date.now(), ok, probeSeen: probeEverSet } : null;
|
|
76
|
+
// The route re-set the expiry cookie, so the next proactive renewal is
|
|
77
|
+
// timed from it whichever path asked for this one.
|
|
78
|
+
if (ok)
|
|
79
|
+
scheduleProactiveRenewal(true);
|
|
80
|
+
return ok;
|
|
81
|
+
})
|
|
82
|
+
.finally(() => {
|
|
28
83
|
renewalInFlight = null;
|
|
29
84
|
});
|
|
30
85
|
renewalInFlight = renewal;
|
|
@@ -52,7 +107,7 @@ async function renewBearer() {
|
|
|
52
107
|
}
|
|
53
108
|
if (!token) {
|
|
54
109
|
logRenewOutcome('no_token', { bridgeRefreshFailed, cookieless });
|
|
55
|
-
return false;
|
|
110
|
+
return { ok: false, sent: false };
|
|
56
111
|
}
|
|
57
112
|
try {
|
|
58
113
|
const res = await fetch(REEXCHANGE_PATH, {
|
|
@@ -91,11 +146,11 @@ async function renewBearer() {
|
|
|
91
146
|
// A history update failure must not invalidate the cookie just renewed.
|
|
92
147
|
}
|
|
93
148
|
}
|
|
94
|
-
return res.ok;
|
|
149
|
+
return { ok: res.ok, sent: true };
|
|
95
150
|
}
|
|
96
151
|
catch {
|
|
97
152
|
logRenewOutcome('unavailable', { bridgeRefreshFailed, cookieless });
|
|
98
|
-
return false;
|
|
153
|
+
return { ok: false, sent: true };
|
|
99
154
|
}
|
|
100
155
|
}
|
|
101
156
|
// Whether a denial is worth a renew-and-retry. A 401 is an authentication
|
|
@@ -116,6 +171,7 @@ function shouldRenewBearer(res) {
|
|
|
116
171
|
// once via reexchangeBearer and retries; a genuine forbidden just returns.
|
|
117
172
|
export async function authFetch(input, init) {
|
|
118
173
|
const headers = { ...(init?.headers ?? {}), [APP_CSRF_HEADER]: '1' };
|
|
174
|
+
probeIsSet();
|
|
119
175
|
const res = await fetch(input, { ...init, headers });
|
|
120
176
|
if (!shouldRenewBearer(res)) {
|
|
121
177
|
return res;
|
|
@@ -166,6 +222,103 @@ export function bootstrapCookielessBearer() {
|
|
|
166
222
|
// Self-running, mirroring origin-bootstrap: module evaluation precedes
|
|
167
223
|
// hydration, so the bearer is in hand before any component's first fetch.
|
|
168
224
|
void bootstrapCookielessBearer();
|
|
225
|
+
// Proactive renewal. The reactive paths above only mint a bearer after a fetch
|
|
226
|
+
// or action has already failed on its expiry; a server-rendered navigation
|
|
227
|
+
// never fails that way, it renders as no viewer. So a tab that stays open
|
|
228
|
+
// renews shortly before the expiry the proxy mirrors into the client-readable
|
|
229
|
+
// cookie, while it is visible. Without that cookie (an older proxy, the
|
|
230
|
+
// cookieless path) nothing is scheduled and the reactive paths stay the only
|
|
231
|
+
// renewal, as before.
|
|
232
|
+
const RENEWAL_LEAD_MS = 2 * 60_000;
|
|
233
|
+
const MIN_RENEWAL_DELAY_MS = 5_000;
|
|
234
|
+
// A timed renewal that fails (a network blip at minute 58) is retried at this
|
|
235
|
+
// pace until the bearer's expiry passes, so a brief outage inside the lead does
|
|
236
|
+
// not cost the tab its sign-in. Matches the renewal window, so each retry is a
|
|
237
|
+
// real POST rather than the cached verdict.
|
|
238
|
+
const RENEWAL_RETRY_MS = RENEWAL_WINDOW_MS;
|
|
239
|
+
let renewalTimer = null;
|
|
240
|
+
// The expiry the pending (or last fired) timer was taken from, so a successful
|
|
241
|
+
// renewal whose route never refreshed the cookie (a hand-written reexchange
|
|
242
|
+
// route) does not re-arm a timer on the same stale value every few seconds.
|
|
243
|
+
let scheduledExpiry = null;
|
|
244
|
+
// Whether this page has ever read the cookie. Only then can its absence mean
|
|
245
|
+
// the bearer lapsed (a laptop closed past expiry); a proxy that predates the
|
|
246
|
+
// cookie never sets it, and must not be renewed on every tab switch.
|
|
247
|
+
let expiryEverSeen = false;
|
|
248
|
+
function clearProactiveRenewal() {
|
|
249
|
+
if (renewalTimer !== null)
|
|
250
|
+
clearTimeout(renewalTimer);
|
|
251
|
+
renewalTimer = null;
|
|
252
|
+
}
|
|
253
|
+
const tabIsVisible = () => typeof document === 'undefined' || document.visibilityState !== 'hidden';
|
|
254
|
+
/**
|
|
255
|
+
* Arms one timer for RENEWAL_LEAD_MS before the expiry in the cookie, replacing
|
|
256
|
+
* any pending one. Exported for tests; runs at module load and, with
|
|
257
|
+
* afterRenewal, after every successful renewal.
|
|
258
|
+
*/
|
|
259
|
+
export function scheduleProactiveRenewal(afterRenewal = false) {
|
|
260
|
+
if (typeof window === 'undefined')
|
|
261
|
+
return;
|
|
262
|
+
clearProactiveRenewal();
|
|
263
|
+
const expiresAt = readBearerExpiry();
|
|
264
|
+
if (expiresAt === null)
|
|
265
|
+
return;
|
|
266
|
+
expiryEverSeen = true;
|
|
267
|
+
if (afterRenewal && expiresAt === scheduledExpiry)
|
|
268
|
+
return;
|
|
269
|
+
scheduledExpiry = expiresAt;
|
|
270
|
+
const delay = Math.max(MIN_RENEWAL_DELAY_MS, expiresAt - RENEWAL_LEAD_MS - Date.now());
|
|
271
|
+
renewalTimer = setTimeout(renewOnTimer, delay);
|
|
272
|
+
}
|
|
273
|
+
function renewOnTimer() {
|
|
274
|
+
renewalTimer = null;
|
|
275
|
+
// A hidden tab has no bridge parent to refresh the session token from and
|
|
276
|
+
// nothing to render; the visibility handler renews it on return.
|
|
277
|
+
if (!tabIsVisible())
|
|
278
|
+
return;
|
|
279
|
+
void reexchangeBearer().then((renewed) => {
|
|
280
|
+
// Success re-armed the timer from the refreshed cookie. A failure retries
|
|
281
|
+
// while the current bearer is still alive; once it has expired the
|
|
282
|
+
// reactive paths and the visibility handler take over, as before.
|
|
283
|
+
if (renewed || renewalTimer !== null)
|
|
284
|
+
return;
|
|
285
|
+
const expiresAt = readBearerExpiry();
|
|
286
|
+
// A retry inside the window would only get the cached verdict back.
|
|
287
|
+
if (expiresAt === null || expiresAt - Date.now() <= RENEWAL_RETRY_MS)
|
|
288
|
+
return;
|
|
289
|
+
renewalTimer = setTimeout(renewOnTimer, RENEWAL_RETRY_MS);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
// A tab that comes back after the lead, or after the cookie itself lapsed, gets
|
|
293
|
+
// a fresh bearer now. The page it shows keeps its stale render until the next
|
|
294
|
+
// navigation, which then lands signed in; forcing a reload is deliberately not
|
|
295
|
+
// done here.
|
|
296
|
+
function renewOnVisible() {
|
|
297
|
+
if (!tabIsVisible())
|
|
298
|
+
return;
|
|
299
|
+
const expiresAt = readBearerExpiry();
|
|
300
|
+
if (expiresAt === null) {
|
|
301
|
+
if (expiryEverSeen)
|
|
302
|
+
void reexchangeBearer();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
expiryEverSeen = true;
|
|
306
|
+
if (expiresAt - Date.now() < RENEWAL_LEAD_MS) {
|
|
307
|
+
void reexchangeBearer();
|
|
308
|
+
}
|
|
309
|
+
else if (renewalTimer === null) {
|
|
310
|
+
scheduleProactiveRenewal();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
314
|
+
scheduleProactiveRenewal();
|
|
315
|
+
document.addEventListener('visibilitychange', renewOnVisible);
|
|
316
|
+
}
|
|
317
|
+
// Test seam for the visibility handler, which is otherwise reachable only
|
|
318
|
+
// through the real document event.
|
|
319
|
+
export function handleVisibilityChangeForTests() {
|
|
320
|
+
renewOnVisible();
|
|
321
|
+
}
|
|
169
322
|
/**
|
|
170
323
|
* Returns an onClick handler that navigates the parent Assembly portal to the
|
|
171
324
|
* given typed destination. Use in button or anchor onClick props.
|
|
@@ -11,6 +11,12 @@ export declare function readCookie(name: string, cookieString?: string): string
|
|
|
11
11
|
* Exported so client modules can gate on a flag without re-parsing the cookie.
|
|
12
12
|
*/
|
|
13
13
|
export declare function hasClientFlag(key: string, cookieString?: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* When the current bearer expires, as epoch milliseconds, from the cookie the
|
|
16
|
+
* proxy and the reexchange route mirror beside it. Null when no such cookie is
|
|
17
|
+
* readable (older proxy, cookieless browser, or already expired).
|
|
18
|
+
*/
|
|
19
|
+
export declare function readBearerExpiry(cookieString?: string): number | null;
|
|
14
20
|
/**
|
|
15
21
|
* Register the workspace's portal origin with the bridge allowlist — from the
|
|
16
22
|
* proxy-set cookie when it is readable, from the framing origin otherwise.
|
package/dist/origin-bootstrap.js
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// domains are built into the bridge's defaults). Nothing resolvable → no-op:
|
|
21
21
|
// the app still works on every default-allowlisted surface.
|
|
22
22
|
import { AssemblyBridge } from '@assembly-js/app-bridge';
|
|
23
|
+
import { BEARER_EXPIRY_COOKIE_NAME } from './session-exchange.js';
|
|
23
24
|
export const PORTAL_ORIGIN_COOKIE_NAME = 'assembly-portal-origin';
|
|
24
25
|
// Mirrors the bearer's `flags` claim (see session-exchange.ts's
|
|
25
26
|
// ExchangedSession.flags / hasFlag) — the bearer itself is HttpOnly and
|
|
@@ -56,6 +57,18 @@ export function hasClientFlag(key, cookieString) {
|
|
|
56
57
|
return false;
|
|
57
58
|
return raw.split(',').includes(key);
|
|
58
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* When the current bearer expires, as epoch milliseconds, from the cookie the
|
|
62
|
+
* proxy and the reexchange route mirror beside it. Null when no such cookie is
|
|
63
|
+
* readable (older proxy, cookieless browser, or already expired).
|
|
64
|
+
*/
|
|
65
|
+
export function readBearerExpiry(cookieString) {
|
|
66
|
+
const raw = readCookie(BEARER_EXPIRY_COOKIE_NAME, cookieString);
|
|
67
|
+
if (!raw)
|
|
68
|
+
return null;
|
|
69
|
+
const seconds = Number(raw);
|
|
70
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1_000 : null;
|
|
71
|
+
}
|
|
59
72
|
/**
|
|
60
73
|
* Framing-origin resolution for when the portal-origin cookie is unreadable: `location.ancestorOrigins`
|
|
61
74
|
* (browser-attested; Chromium/WebKit), falling back to `document.referrer`
|
package/dist/proxy.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// adds its own extras through onForward.
|
|
6
6
|
import { assemblyApi } from '@assembly-js/node-sdk';
|
|
7
7
|
import { NextResponse } from 'next/server';
|
|
8
|
-
import { APP_CSRF_HEADER, BEARER_COOKIE_NAME, BEARER_HEADER_NAME, CLIENT_BEARER_HEADER, decodeBearer, isDocumentRequest, isSessionExchangeEnabled, logCookieOutcome, serializeBearerCookie, serializeCookieProbe, } from './session-exchange.js';
|
|
8
|
+
import { APP_CSRF_HEADER, BEARER_COOKIE_NAME, BEARER_HEADER_NAME, CLIENT_BEARER_HEADER, decodeBearer, isDocumentRequest, isSessionExchangeEnabled, logCookieOutcome, serializeBearerCookie, serializeBearerExpiryCookie, serializeCookieProbe, } from './session-exchange.js';
|
|
9
9
|
import { CLIENT_FLAGS_COOKIE_NAME } from './origin-bootstrap.js';
|
|
10
10
|
import { handlePaintedBeacon, isPaintedBeaconPath } from './paint-beacon.js';
|
|
11
11
|
import { fetchSessionFlags, mintBearerFromAppKey, mintBearerFromSessionToken, } from './mint-bearer.js';
|
|
@@ -594,11 +594,13 @@ async function handleRequest(request, options) {
|
|
|
594
594
|
response.headers.append('Set-Cookie', serializeBearerCookie('', 0, false));
|
|
595
595
|
response.headers.append('Set-Cookie', serializeBearerCookie(newBearer, maxAge, true));
|
|
596
596
|
response.headers.append('Set-Cookie', serializeCookieProbe(true));
|
|
597
|
+
response.headers.append('Set-Cookie', serializeBearerExpiryCookie(newBearerExpiresAt, maxAge));
|
|
597
598
|
}
|
|
598
599
|
else if (clearBearerCookie) {
|
|
599
600
|
response.headers.append('Set-Cookie', serializeBearerCookie('', 0, false));
|
|
600
601
|
response.headers.append('Set-Cookie', serializeBearerCookie('', 0, true));
|
|
601
602
|
response.headers.append('Set-Cookie', serializeCookieProbe(true, true));
|
|
603
|
+
response.headers.append('Set-Cookie', serializeBearerExpiryCookie(0, 0));
|
|
602
604
|
}
|
|
603
605
|
else if (bearerCookie && isDocumentRequest(request)) {
|
|
604
606
|
// Refresh the probe on a document served from a live cookie: it outlives the
|
package/dist/reexchange.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// each app because it is security-critical code an agent must never re-author;
|
|
3
3
|
// an app's route file only calls createAssemblyReexchangeHandler.
|
|
4
4
|
import { NextResponse } from 'next/server';
|
|
5
|
-
import { APP_CSRF_HEADER, BEARER_IN_BODY_HEADER, isSessionExchangeEnabled, serializeBearerCookie, serializeCookieProbe, } from './session-exchange.js';
|
|
5
|
+
import { APP_CSRF_HEADER, BEARER_IN_BODY_HEADER, isSessionExchangeEnabled, serializeBearerCookie, serializeBearerExpiryCookie, serializeCookieProbe, } from './session-exchange.js';
|
|
6
6
|
import { mintBearerFromSessionToken } from './mint-bearer.js';
|
|
7
7
|
// The only header any route may read a session token from.
|
|
8
8
|
const SESSION_TOKEN_HEADER = 'x-assembly-token';
|
|
@@ -50,6 +50,8 @@ export function createAssemblyReexchangeHandler() {
|
|
|
50
50
|
response.headers.append('Set-Cookie', serializeBearerCookie('', 0, false));
|
|
51
51
|
response.headers.append('Set-Cookie', serializeBearerCookie(minted.bearer, maxAge, true));
|
|
52
52
|
response.headers.append('Set-Cookie', serializeCookieProbe(true));
|
|
53
|
+
// The client schedules its next proactive renewal from this.
|
|
54
|
+
response.headers.append('Set-Cookie', serializeBearerExpiryCookie(minted.bearerExpiresAt, maxAge));
|
|
53
55
|
return response;
|
|
54
56
|
};
|
|
55
57
|
}
|
|
@@ -9,6 +9,8 @@ export declare const CLIENT_BEARER_HEADER = "x-assembly-client-bearer";
|
|
|
9
9
|
export declare const BEARER_IN_BODY_HEADER = "x-assembly-bearer-in-body";
|
|
10
10
|
export declare const COOKIE_PROBE_NAME = "assembly-cookie-probe";
|
|
11
11
|
export declare function serializeCookieProbe(partitioned: boolean, clear?: boolean): string;
|
|
12
|
+
export declare const BEARER_EXPIRY_COOKIE_NAME = "assembly-bearer-exp";
|
|
13
|
+
export declare function serializeBearerExpiryCookie(expiresAtMs: number, maxAgeSeconds: number): string;
|
|
12
14
|
export declare const TOKEN_EXPIRED_HEADER = "x-assembly-token-expired";
|
|
13
15
|
export declare const EXCHANGE_REJECTION_HEADER = "x-assembly-exchange-rejection";
|
|
14
16
|
export type ExchangeRejectedViewer = 'same_viewer' | 'different_viewer';
|
package/dist/session-exchange.js
CHANGED
|
@@ -77,6 +77,16 @@ export function serializeCookieProbe(partitioned, clear = false) {
|
|
|
77
77
|
cookie += '; Partitioned';
|
|
78
78
|
return cookie;
|
|
79
79
|
}
|
|
80
|
+
// Client-readable twin of the bearer's expiry. The bearer is HttpOnly, so this
|
|
81
|
+
// is how the browser learns when to renew it before a server-rendered
|
|
82
|
+
// navigation would find it expired. Epoch seconds, not a secret; written and
|
|
83
|
+
// cleared in lockstep with the bearer, attributes as the other client-readable
|
|
84
|
+
// cookies (Partitioned, no HttpOnly).
|
|
85
|
+
export const BEARER_EXPIRY_COOKIE_NAME = 'assembly-bearer-exp';
|
|
86
|
+
export function serializeBearerExpiryCookie(expiresAtMs, maxAgeSeconds) {
|
|
87
|
+
const value = maxAgeSeconds > 0 ? String(Math.floor(expiresAtMs / 1_000)) : '';
|
|
88
|
+
return `${BEARER_EXPIRY_COOKIE_NAME}=${value}; Path=/; Secure; SameSite=None; Max-Age=${maxAgeSeconds}; Partitioned`;
|
|
89
|
+
}
|
|
80
90
|
// Marker header on 401s that mean "the bearer expired — re-exchange and retry",
|
|
81
91
|
// emitted by requireAuth / getAssemblyForRequest / the re-exchange route. Its
|
|
82
92
|
// absence on a denial is what lets authFetch skip renewal for genuine
|