@assembly-js/app-foundation 0.0.25-rc.3 → 0.0.25-rc.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/dist/client.d.ts +1 -0
- package/dist/client.js +2 -0
- package/dist/cookieless-bearer.d.ts +21 -0
- package/dist/cookieless-bearer.js +93 -0
- package/dist/lib-client.d.ts +8 -0
- package/dist/lib-client.js +41 -3
- package/dist/paint-beacon.js +13 -0
- package/dist/session-exchange.d.ts +6 -1
- package/dist/session-exchange.js +35 -0
- package/package.json +1 -1
package/dist/client.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from './session-exchange.js';
|
|
|
2
2
|
export * from './network-capture.js';
|
|
3
3
|
export * from './paint-beacon.js';
|
|
4
4
|
export * from './origin-bootstrap.js';
|
|
5
|
+
export * from './cookieless-bearer.js';
|
|
5
6
|
export * from './client-companies.js';
|
|
6
7
|
export * from './pagination.js';
|
|
7
8
|
export * from './theme.js';
|
package/dist/client.js
CHANGED
|
@@ -12,6 +12,8 @@ export * from './paint-beacon.js';
|
|
|
12
12
|
// origin with the bridge allowlist, and schedule the post-paint beacon. See
|
|
13
13
|
// each module's comment.
|
|
14
14
|
export * from './origin-bootstrap.js';
|
|
15
|
+
// In-memory bearer for browsers that refuse the cookie; inert everywhere else.
|
|
16
|
+
export * from './cookieless-bearer.js';
|
|
15
17
|
export * from './client-companies.js';
|
|
16
18
|
export * from './pagination.js';
|
|
17
19
|
export * from './theme.js';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the browser is refusing to store the bearer cookie. Either the probe
|
|
3
|
+
* or the portal-origin cookie being readable proves the jar works — proxies that
|
|
4
|
+
* predate the probe write only the latter. A false "blocked" costs at most one
|
|
5
|
+
* mint, since a working cookie keeps authenticating regardless. Off-browser it
|
|
6
|
+
* returns false: no cookie jar is not the same as a blocked one.
|
|
7
|
+
*/
|
|
8
|
+
export declare function cookiesAreBlocked(cookieString?: string): boolean;
|
|
9
|
+
/** The in-memory bearer, or null when the cookie path is carrying auth. */
|
|
10
|
+
export declare function getCookielessBearer(): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* Adopt a freshly minted bearer for the cookie-less path and make sure the
|
|
13
|
+
* fetch patch is live. Called after a re-exchange returns one in its body.
|
|
14
|
+
*/
|
|
15
|
+
export declare function setCookielessBearer(value: string | null): void;
|
|
16
|
+
/**
|
|
17
|
+
* Headers that ask the re-exchange route to return the bearer in its body.
|
|
18
|
+
* Shared with the renewal path in lib-client so both describe the cookie-less
|
|
19
|
+
* request the same way.
|
|
20
|
+
*/
|
|
21
|
+
export declare function cookielessReexchangeHeaders(): Record<string, string>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
// Carries the bearer for browsers that refuse to store it: Safari blocks every
|
|
3
|
+
// cross-site-iframe cookie and has no CHIPS, so the cookie is dropped on write
|
|
4
|
+
// and only the first paint (which gets the bearer as a request header) works.
|
|
5
|
+
//
|
|
6
|
+
// window.fetch is the patch point because Next routes RSC navigations, server
|
|
7
|
+
// actions and app fetches through it alike, and server actions cannot carry a
|
|
8
|
+
// custom header any other way. The bearer stays in memory so it cannot outlive
|
|
9
|
+
// the document or leak into history.
|
|
10
|
+
import { APP_CSRF_HEADER, BEARER_IN_BODY_HEADER, CLIENT_BEARER_HEADER, COOKIE_PROBE_NAME, } from './session-exchange.js';
|
|
11
|
+
import { PORTAL_ORIGIN_COOKIE_NAME, readCookie } from './origin-bootstrap.js';
|
|
12
|
+
let bearer = null;
|
|
13
|
+
let patched = false;
|
|
14
|
+
/**
|
|
15
|
+
* Whether the browser is refusing to store the bearer cookie. Either the probe
|
|
16
|
+
* or the portal-origin cookie being readable proves the jar works — proxies that
|
|
17
|
+
* predate the probe write only the latter. A false "blocked" costs at most one
|
|
18
|
+
* mint, since a working cookie keeps authenticating regardless. Off-browser it
|
|
19
|
+
* returns false: no cookie jar is not the same as a blocked one.
|
|
20
|
+
*/
|
|
21
|
+
export function cookiesAreBlocked(cookieString) {
|
|
22
|
+
if (cookieString === undefined && typeof document === 'undefined') {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return (readCookie(COOKIE_PROBE_NAME, cookieString) === null &&
|
|
26
|
+
readCookie(PORTAL_ORIGIN_COOKIE_NAME, cookieString) === null);
|
|
27
|
+
}
|
|
28
|
+
/** The in-memory bearer, or null when the cookie path is carrying auth. */
|
|
29
|
+
export function getCookielessBearer() {
|
|
30
|
+
return bearer;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Adopt a freshly minted bearer for the cookie-less path and make sure the
|
|
34
|
+
* fetch patch is live. Called after a re-exchange returns one in its body.
|
|
35
|
+
*/
|
|
36
|
+
export function setCookielessBearer(value) {
|
|
37
|
+
bearer = value || null;
|
|
38
|
+
if (bearer)
|
|
39
|
+
patchFetch();
|
|
40
|
+
}
|
|
41
|
+
// Same-origin only: the bearer must never ride a request to a third party the
|
|
42
|
+
// app happens to call. A relative URL resolves against the document, so it is
|
|
43
|
+
// same-origin by construction.
|
|
44
|
+
function isSameOrigin(input) {
|
|
45
|
+
if (typeof window === 'undefined')
|
|
46
|
+
return false;
|
|
47
|
+
try {
|
|
48
|
+
const raw = typeof input === 'string'
|
|
49
|
+
? input
|
|
50
|
+
: input instanceof URL
|
|
51
|
+
? input.href
|
|
52
|
+
: input.url;
|
|
53
|
+
return new URL(raw, window.location.href).origin === window.location.origin;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Patched once, and only after the cookie has been proven unavailable. A
|
|
60
|
+
// Request argument carries immutable headers, so it is rebuilt rather than
|
|
61
|
+
// mutated. The CSRF header rides along because a patched request may be a
|
|
62
|
+
// server action, which cannot set it itself.
|
|
63
|
+
function patchFetch() {
|
|
64
|
+
if (patched || typeof window === 'undefined')
|
|
65
|
+
return;
|
|
66
|
+
patched = true;
|
|
67
|
+
const originalFetch = window.fetch.bind(window);
|
|
68
|
+
window.fetch = function assemblyCookielessFetch(input, init) {
|
|
69
|
+
if (!bearer || !isSameOrigin(input)) {
|
|
70
|
+
return originalFetch(input, init);
|
|
71
|
+
}
|
|
72
|
+
if (input instanceof Request && !init) {
|
|
73
|
+
const headers = new Headers(input.headers);
|
|
74
|
+
headers.set(CLIENT_BEARER_HEADER, bearer);
|
|
75
|
+
headers.set(APP_CSRF_HEADER, '1');
|
|
76
|
+
return originalFetch(new Request(input, { headers }));
|
|
77
|
+
}
|
|
78
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
79
|
+
headers.set(CLIENT_BEARER_HEADER, bearer);
|
|
80
|
+
headers.set(APP_CSRF_HEADER, '1');
|
|
81
|
+
return originalFetch(input, { ...init, headers });
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Headers that ask the re-exchange route to return the bearer in its body.
|
|
86
|
+
* Shared with the renewal path in lib-client so both describe the cookie-less
|
|
87
|
+
* request the same way.
|
|
88
|
+
*/
|
|
89
|
+
export function cookielessReexchangeHeaders() {
|
|
90
|
+
return bearer
|
|
91
|
+
? { [CLIENT_BEARER_HEADER]: bearer, [BEARER_IN_BODY_HEADER]: '1' }
|
|
92
|
+
: { [BEARER_IN_BODY_HEADER]: '1' };
|
|
93
|
+
}
|
package/dist/lib-client.d.ts
CHANGED
|
@@ -1,2 +1,10 @@
|
|
|
1
1
|
export declare function reexchangeBearer(): Promise<boolean>;
|
|
2
2
|
export declare function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
3
|
+
/**
|
|
4
|
+
* Mint an in-memory bearer at load when the browser is refusing to store the
|
|
5
|
+
* cookie, so the first client-side navigation is already authenticated instead
|
|
6
|
+
* of 404ing its way into a renewal. A no-op wherever the cookie works.
|
|
7
|
+
*
|
|
8
|
+
* Exported for tests; runs once at module load in the browser.
|
|
9
|
+
*/
|
|
10
|
+
export declare function bootstrapCookielessBearer(): Promise<boolean>;
|
package/dist/lib-client.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// (proxy.ts pulls constants from that barrel).
|
|
7
7
|
import { AssemblyBridge } from '@assembly-js/app-bridge';
|
|
8
8
|
import { APP_CSRF_HEADER, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
|
|
9
|
+
import { cookielessReexchangeHeaders, cookiesAreBlocked, getCookielessBearer, setCookielessBearer, } from './cookieless-bearer.js';
|
|
9
10
|
const REEXCHANGE_PATH = '/api/assembly-reexchange';
|
|
10
11
|
// Single-flight gate for renewals. Session tokens are single-use for exchange:
|
|
11
12
|
// when the 60-minute bearer expires, every in-flight fetch on the page 401s at
|
|
@@ -33,6 +34,10 @@ async function renewBearer() {
|
|
|
33
34
|
const previousUrlToken = typeof window === 'undefined'
|
|
34
35
|
? null
|
|
35
36
|
: new URL(window.location.href).searchParams.get('token');
|
|
37
|
+
// Re-read per attempt rather than caching: the first call on a cookie-less
|
|
38
|
+
// browser is the bootstrap (no bearer yet), and the probe is the only thing
|
|
39
|
+
// that distinguishes it from a browser whose cookie simply expired.
|
|
40
|
+
const cookieless = cookiesAreBlocked();
|
|
36
41
|
let token = AssemblyBridge.sessionToken.getCurrent()?.token ?? '';
|
|
37
42
|
// Proactively refresh: we're recovering from an expiry, so avoid re-minting
|
|
38
43
|
// from a session token that may itself be stale.
|
|
@@ -46,18 +51,33 @@ async function renewBearer() {
|
|
|
46
51
|
bridgeRefreshFailed = true;
|
|
47
52
|
}
|
|
48
53
|
if (!token) {
|
|
49
|
-
logRenewOutcome('no_token', { bridgeRefreshFailed });
|
|
54
|
+
logRenewOutcome('no_token', { bridgeRefreshFailed, cookieless });
|
|
50
55
|
return false;
|
|
51
56
|
}
|
|
52
57
|
try {
|
|
53
58
|
const res = await fetch(REEXCHANGE_PATH, {
|
|
54
59
|
method: 'POST',
|
|
55
|
-
headers: {
|
|
60
|
+
headers: {
|
|
61
|
+
'x-assembly-token': token,
|
|
62
|
+
[APP_CSRF_HEADER]: '1',
|
|
63
|
+
...(cookieless ? cookielessReexchangeHeaders() : {}),
|
|
64
|
+
},
|
|
56
65
|
});
|
|
57
66
|
logRenewOutcome(res.ok ? 'renewed' : 'rejected', {
|
|
58
67
|
bridgeRefreshFailed,
|
|
59
68
|
status: res.status,
|
|
69
|
+
cookieless,
|
|
60
70
|
});
|
|
71
|
+
// On the cookie-less path the fresh bearer comes back in the body, because
|
|
72
|
+
// the Set-Cookie beside it is dropped exactly like the first one was. A body
|
|
73
|
+
// without one is a route that predates this path answering the ordinary way:
|
|
74
|
+
// the cookie was renewed if this browser keeps cookies, so the route's own
|
|
75
|
+
// verdict stands rather than failing a renewal that succeeded.
|
|
76
|
+
if (res.ok && cookieless) {
|
|
77
|
+
const body = await res.json().catch(() => null);
|
|
78
|
+
if (body?.bearer)
|
|
79
|
+
setCookielessBearer(body.bearer);
|
|
80
|
+
}
|
|
61
81
|
if (res.ok && previousUrlToken !== null) {
|
|
62
82
|
try {
|
|
63
83
|
const url = new URL(window.location.href);
|
|
@@ -74,7 +94,7 @@ async function renewBearer() {
|
|
|
74
94
|
return res.ok;
|
|
75
95
|
}
|
|
76
96
|
catch {
|
|
77
|
-
logRenewOutcome('unavailable', { bridgeRefreshFailed });
|
|
97
|
+
logRenewOutcome('unavailable', { bridgeRefreshFailed, cookieless });
|
|
78
98
|
return false;
|
|
79
99
|
}
|
|
80
100
|
}
|
|
@@ -106,3 +126,21 @@ export async function authFetch(input, init) {
|
|
|
106
126
|
}
|
|
107
127
|
return fetch(input, { ...init, headers });
|
|
108
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Mint an in-memory bearer at load when the browser is refusing to store the
|
|
131
|
+
* cookie, so the first client-side navigation is already authenticated instead
|
|
132
|
+
* of 404ing its way into a renewal. A no-op wherever the cookie works.
|
|
133
|
+
*
|
|
134
|
+
* Exported for tests; runs once at module load in the browser.
|
|
135
|
+
*/
|
|
136
|
+
export function bootstrapCookielessBearer() {
|
|
137
|
+
if (typeof window === 'undefined')
|
|
138
|
+
return Promise.resolve(false);
|
|
139
|
+
if (!cookiesAreBlocked() || getCookielessBearer()) {
|
|
140
|
+
return Promise.resolve(false);
|
|
141
|
+
}
|
|
142
|
+
return reexchangeBearer();
|
|
143
|
+
}
|
|
144
|
+
// Self-running, mirroring origin-bootstrap: module evaluation precedes
|
|
145
|
+
// hydration, so the bearer is in hand before any component's first fetch.
|
|
146
|
+
void bootstrapCookielessBearer();
|
package/dist/paint-beacon.js
CHANGED
|
@@ -17,9 +17,22 @@ export const PAINTED_BEACON_PATH = '/__assembly/painted';
|
|
|
17
17
|
export const PAINTED_BEACON_EVENT = 'assembly.painted';
|
|
18
18
|
let scheduled = false;
|
|
19
19
|
let fired = false;
|
|
20
|
+
// Set by the app's error boundaries while they are the document's content.
|
|
21
|
+
// A paint of the error boundary is not a paint of the app, and the parent
|
|
22
|
+
// treats this beacon as proof the app is up — so it stays quiet and releases
|
|
23
|
+
// the schedule latch, so the boundary can re-install it once the app has
|
|
24
|
+
// rendered in its place.
|
|
25
|
+
const ERROR_DOCUMENT_FLAG = '__assemblyErrorDocument';
|
|
26
|
+
function isErrorDocument() {
|
|
27
|
+
return window[ERROR_DOCUMENT_FLAG] === true;
|
|
28
|
+
}
|
|
20
29
|
function firePaintBeacon() {
|
|
21
30
|
if (fired)
|
|
22
31
|
return;
|
|
32
|
+
if (isErrorDocument()) {
|
|
33
|
+
scheduled = false;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
23
36
|
fired = true;
|
|
24
37
|
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
|
25
38
|
navigator.sendBeacon(PAINTED_BEACON_PATH);
|
|
@@ -5,13 +5,17 @@ export declare function classifyExchangeFailure(status: number): MintFailure;
|
|
|
5
5
|
export declare function serializeBearerCookie(value: string, maxAgeSeconds: number, partitioned: boolean): string;
|
|
6
6
|
export declare const BEARER_HEADER_NAME = "x-assembly-request-bearer";
|
|
7
7
|
export declare const APP_CSRF_HEADER = "x-assembly-app";
|
|
8
|
+
export declare const CLIENT_BEARER_HEADER = "x-assembly-client-bearer";
|
|
9
|
+
export declare const BEARER_IN_BODY_HEADER = "x-assembly-bearer-in-body";
|
|
10
|
+
export declare const COOKIE_PROBE_NAME = "assembly-cookie-probe";
|
|
11
|
+
export declare function serializeCookieProbe(partitioned: boolean, clear?: boolean): string;
|
|
8
12
|
export declare const TOKEN_EXPIRED_HEADER = "x-assembly-token-expired";
|
|
9
13
|
export declare const EXCHANGE_REJECTION_HEADER = "x-assembly-exchange-rejection";
|
|
10
14
|
export type ExchangeRejectedViewer = 'same_viewer' | 'different_viewer';
|
|
11
15
|
export declare function parseExchangeRejection(value: string | null): ExchangeRejectedViewer | undefined;
|
|
12
16
|
export declare function isSessionExchangeEnabled(): boolean;
|
|
13
17
|
export declare const COOKIE_OUTCOME_EVENT = "assembly.cookie_outcome";
|
|
14
|
-
export type CookieOutcome = 'served_from_cookie' | 'minted' | `fail_closed:${string}`;
|
|
18
|
+
export type CookieOutcome = 'served_from_cookie' | 'served_from_client_bearer' | 'minted' | `fail_closed:${string}`;
|
|
15
19
|
export declare function logCookieOutcome(outcome: CookieOutcome, detail?: {
|
|
16
20
|
rejectedToken?: string;
|
|
17
21
|
}): void;
|
|
@@ -20,6 +24,7 @@ export type RenewOutcome = 'renewed' | 'no_token' | 'rejected' | 'unavailable';
|
|
|
20
24
|
export declare function logRenewOutcome(outcome: RenewOutcome, detail?: {
|
|
21
25
|
bridgeRefreshFailed?: boolean;
|
|
22
26
|
status?: number;
|
|
27
|
+
cookieless?: boolean;
|
|
23
28
|
}): void;
|
|
24
29
|
export declare function isDocumentRequest(request: {
|
|
25
30
|
method: string;
|
package/dist/session-exchange.js
CHANGED
|
@@ -47,6 +47,38 @@ export const BEARER_HEADER_NAME = 'x-assembly-request-bearer';
|
|
|
47
47
|
// presence is sufficient: a cross-site request physically cannot carry a
|
|
48
48
|
// custom header without a CORS preflight, and this app grants none.
|
|
49
49
|
export const APP_CSRF_HEADER = 'x-assembly-app';
|
|
50
|
+
// Bearer carried by the client instead of the cookie, for browsers that refuse
|
|
51
|
+
// to store it: Safari blocks every cross-site-iframe cookie and does not
|
|
52
|
+
// implement CHIPS, so nothing the proxy sets is ever sent back. Distinct from
|
|
53
|
+
// BEARER_HEADER_NAME so an inbound value can never be mistaken for the one the
|
|
54
|
+
// proxy forwards to the render; both are stripped from every inbound request.
|
|
55
|
+
export const CLIENT_BEARER_HEADER = 'x-assembly-client-bearer';
|
|
56
|
+
// Asks the re-exchange route to return the minted bearer in its response body.
|
|
57
|
+
// Sent only by a client that has proven the cookie is unavailable, so a browser
|
|
58
|
+
// whose cookie works keeps the original response shape and never receives a
|
|
59
|
+
// bearer anywhere a script could read it.
|
|
60
|
+
export const BEARER_IN_BODY_HEADER = 'x-assembly-bearer-in-body';
|
|
61
|
+
// Client-readable twin of the bearer cookie: same attributes, no HttpOnly, no
|
|
62
|
+
// value worth having. The bearer itself is HttpOnly, so its absence is
|
|
63
|
+
// invisible to the client; a browser that dropped this one dropped the bearer
|
|
64
|
+
// beside it, which is what the client tests to detect cookie-less mode.
|
|
65
|
+
export const COOKIE_PROBE_NAME = 'assembly-cookie-probe';
|
|
66
|
+
// The probe answers "can this browser store our cookies at all", which does not
|
|
67
|
+
// stop being true when the bearer expires. Giving it the bearer's TTL would make
|
|
68
|
+
// a cookie-capable browser look cookie-less at every renewal, so it outlives the
|
|
69
|
+
// bearer and is cleared explicitly instead.
|
|
70
|
+
const COOKIE_PROBE_TTL_SECONDS = 24 * 60 * 60;
|
|
71
|
+
// Serialize the probe to accompany a bearer write or clear. Attributes mirror
|
|
72
|
+
// serializeBearerCookie (minus HttpOnly, and minus its TTL) so the two share a
|
|
73
|
+
// fate in any browser that refuses the cookie outright — a probe that survived
|
|
74
|
+
// where the bearer was refused would make the detection lie.
|
|
75
|
+
export function serializeCookieProbe(partitioned, clear = false) {
|
|
76
|
+
const maxAge = clear ? 0 : COOKIE_PROBE_TTL_SECONDS;
|
|
77
|
+
let cookie = `${COOKIE_PROBE_NAME}=1; Path=/; Secure; SameSite=None; Max-Age=${maxAge}`;
|
|
78
|
+
if (partitioned)
|
|
79
|
+
cookie += '; Partitioned';
|
|
80
|
+
return cookie;
|
|
81
|
+
}
|
|
50
82
|
// Marker header on 401s that mean "the bearer expired — re-exchange and retry",
|
|
51
83
|
// emitted by requireAuth / getAssemblyForRequest / the re-exchange route. Its
|
|
52
84
|
// absence on a denial is what lets authFetch skip renewal for genuine
|
|
@@ -99,12 +131,15 @@ export function logRenewOutcome(outcome,
|
|
|
99
131
|
// `bridgeRefreshFailed` records that the pre-renewal token refresh threw and
|
|
100
132
|
// we fell back to the token already in hand — on its own that is survivable,
|
|
101
133
|
// but paired with a rejection it names the parent as the failing side.
|
|
134
|
+
// `cookieless` marks a renewal on the header-borne path, so its success rate
|
|
135
|
+
// can be read separately from the cookie population's.
|
|
102
136
|
detail) {
|
|
103
137
|
console.log(JSON.stringify({
|
|
104
138
|
event_type: RENEW_OUTCOME_EVENT,
|
|
105
139
|
outcome,
|
|
106
140
|
...(detail?.bridgeRefreshFailed ? { bridge_refresh_failed: true } : {}),
|
|
107
141
|
...(typeof detail?.status === 'number' ? { status: detail.status } : {}),
|
|
142
|
+
...(detail?.cookieless ? { cookieless: true } : {}),
|
|
108
143
|
}));
|
|
109
144
|
}
|
|
110
145
|
// Two-tier detection: Sec-Fetch-Dest when the browser sends it (all evergreen
|