@assembly-js/app-foundation 0.0.41 → 0.0.43
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 +172 -0
- package/CLAUDE.md +279 -23
- package/bin/check-paint-beacon.mjs +8 -9
- package/bin/check-proxy-csrf.mjs +387 -0
- package/dist/proxy.d.ts +11 -0
- package/dist/proxy.js +669 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/fixes.json +64 -0
- package/package.json +5 -3
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
// The app's root proxy (Next middleware): first-load session-token exchange, the
|
|
2
|
+
// bearer cookie, the CSRF gate, and the cookieless fallback. It lives in the
|
|
3
|
+
// package rather than in each app because it is security-critical code an agent
|
|
4
|
+
// must never re-author; an app's proxy.ts only calls createAssemblyProxy and
|
|
5
|
+
// adds its own extras through onForward.
|
|
6
|
+
import { assemblyApi } from '@assembly-js/node-sdk';
|
|
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';
|
|
9
|
+
import { CLIENT_FLAGS_COOKIE_NAME } from './origin-bootstrap.js';
|
|
10
|
+
import { handlePaintedBeacon, isPaintedBeaconPath } from './paint-beacon.js';
|
|
11
|
+
import { fetchSessionFlags, mintBearerFromAppKey, mintBearerFromSessionToken, } from './mint-bearer.js';
|
|
12
|
+
// Freshness knob for the client-readable flags cookie: how long a flag flip
|
|
13
|
+
// can take to reach an app (at its next document load) without a re-mint.
|
|
14
|
+
const FLAGS_COOKIE_TTL_SECONDS = 300;
|
|
15
|
+
// Trusted API origin for this stack (build-env supplied). Passed to the SDK so
|
|
16
|
+
// tokenless/bearer calls reach the right stack; empty in prod where the SDK
|
|
17
|
+
// default is correct.
|
|
18
|
+
const proxyBaseUrl = process.env.ASSEMBLY_API_BASE_URL || undefined;
|
|
19
|
+
// Decrypts a session token into a raw SDK client for the proxy's own use
|
|
20
|
+
// (reading the payload, driving the exchange). Deliberately bypasses the
|
|
21
|
+
// foundation's strict getAssembly(): the proxy runs in edge middleware where
|
|
22
|
+
// next/headers isn't available, and it must be able to decrypt even a token
|
|
23
|
+
// that strict auth would later reject — that rejection is the app's job, not
|
|
24
|
+
// the exchange's.
|
|
25
|
+
async function decryptSessionToken(token) {
|
|
26
|
+
return assemblyApi({
|
|
27
|
+
apiKey: process.env.ASSEMBLY_API_KEY ?? '',
|
|
28
|
+
token,
|
|
29
|
+
baseUrl: proxyBaseUrl,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const STATIC_FRAME_ANCESTORS = [
|
|
33
|
+
"'self'",
|
|
34
|
+
'https://*.assembly.com',
|
|
35
|
+
'https://*.assembly-staging.com',
|
|
36
|
+
'https://*.myassembly.com',
|
|
37
|
+
'https://*.myassembly-staging.com',
|
|
38
|
+
'https://*.copilot.app',
|
|
39
|
+
'https://*.copilot-staging.app',
|
|
40
|
+
'http://localhost:*',
|
|
41
|
+
'http://127.0.0.1:*',
|
|
42
|
+
];
|
|
43
|
+
// Short TTL for workspace theme/label data — independent of bearer session TTL.
|
|
44
|
+
const WORKSPACE_TTL_MS = 5 * 60 * 1_000;
|
|
45
|
+
// Bound for the non-destructive workspace-theme fetch: on timeout the render
|
|
46
|
+
// just falls back to neutral branding and re-attempts next request. Generous
|
|
47
|
+
// enough to survive a cold platform-api lambda / normal network latency.
|
|
48
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
49
|
+
const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
50
|
+
// Identity/branding headers the proxy sets from its own resolution and the
|
|
51
|
+
// layout reads. Stripped from every inbound request so they can't be spoofed.
|
|
52
|
+
const PROXY_OWNED_IDENTITY_HEADERS = [
|
|
53
|
+
'x-assembly-is-client',
|
|
54
|
+
'x-assembly-color-accent',
|
|
55
|
+
'x-assembly-font',
|
|
56
|
+
'x-assembly-label-client-one',
|
|
57
|
+
'x-assembly-label-client-many',
|
|
58
|
+
'x-assembly-label-company-one',
|
|
59
|
+
'x-assembly-label-company-many',
|
|
60
|
+
'x-assembly-distinct-id',
|
|
61
|
+
'x-assembly-workspace-id',
|
|
62
|
+
];
|
|
63
|
+
function stripProxyOwnedHeaders(headers) {
|
|
64
|
+
headers.delete(BEARER_HEADER_NAME);
|
|
65
|
+
headers.delete(CLIENT_BEARER_HEADER);
|
|
66
|
+
for (const header of PROXY_OWNED_IDENTITY_HEADERS) {
|
|
67
|
+
headers.delete(header);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Neutral branding for the exchange path when the workspace fetch fails but the
|
|
71
|
+
// bearer exchange itself succeeds: the session token is single-use, so we must
|
|
72
|
+
// keep the minted bearer and return neutral branding rather than drop it. The
|
|
73
|
+
// legacy path instead returns null and caches nothing (pre-AI-260 behavior).
|
|
74
|
+
const NO_BRANDING = {
|
|
75
|
+
portalUrl: null,
|
|
76
|
+
colorAccent: null,
|
|
77
|
+
font: null,
|
|
78
|
+
labelClientOne: 'Client',
|
|
79
|
+
labelClientMany: 'Clients',
|
|
80
|
+
labelCompanyOne: 'Company',
|
|
81
|
+
labelCompanyMany: 'Companies',
|
|
82
|
+
};
|
|
83
|
+
const MAX_WORKSPACE_THEME_CACHE_SIZE = 500;
|
|
84
|
+
const MAX_LEGACY_TOKEN_CACHE_SIZE = 1_000;
|
|
85
|
+
// Workspace theme cache keyed by workspace ID (NOT by any credential). Shared
|
|
86
|
+
// across users in the same portal; a miss just re-fetches, so a cold serverless
|
|
87
|
+
// instance degrades to one extra theme read, never to wrong auth.
|
|
88
|
+
const workspaceThemeCache = new Map();
|
|
89
|
+
// Bearer-first workspace branding read with an app-scoped bearer fallback.
|
|
90
|
+
// New apps run with session exchange on; a viewer bearer can fail
|
|
91
|
+
// retrieveWorkspace while auth still succeeds (client-scoped tokens often
|
|
92
|
+
// cannot read workspace branding). assemblyApi({ apiKey }) throws on current
|
|
93
|
+
// SDK versions outside local/staging ("Unable to authorize Assembly SDK"), so
|
|
94
|
+
// the old key-only fallback was a landmine: any viewer miss constructed that
|
|
95
|
+
// client and 500'd the render. Mint an app bearer instead — the same
|
|
96
|
+
// primitive getOfflineAssembly uses — then read with it.
|
|
97
|
+
//
|
|
98
|
+
// Branding is cosmetic and this runs AFTER the single-use session token was
|
|
99
|
+
// consumed by the mint, so nothing here may throw: an uncaught throw fails the
|
|
100
|
+
// whole SSR render as a 500, the Set-Cookie is lost, and the burned token makes
|
|
101
|
+
// every retry render the null-identity fallback. Client construction sits
|
|
102
|
+
// inside the same catch as its read because assemblyApi itself throws when it
|
|
103
|
+
// cannot derive an auth key.
|
|
104
|
+
async function fetchWorkspaceThemeWithBearer(bearer, workspaceId) {
|
|
105
|
+
try {
|
|
106
|
+
const client = await assemblyApi({ bearer, baseUrl: proxyBaseUrl });
|
|
107
|
+
return await fetchWorkspaceTheme(client, workspaceId);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
async function fetchWorkspaceThemeForIdentity(workspaceId, bearer) {
|
|
114
|
+
if (bearer) {
|
|
115
|
+
const fromViewer = await fetchWorkspaceThemeWithBearer(bearer, workspaceId);
|
|
116
|
+
if (fromViewer)
|
|
117
|
+
return fromViewer;
|
|
118
|
+
}
|
|
119
|
+
// Viewer miss: mint an app-scoped bearer. mintBearerFromAppKey already
|
|
120
|
+
// fails closed (null), but the path must stay throw-proof even if a mock
|
|
121
|
+
// or future change surfaces an exception.
|
|
122
|
+
try {
|
|
123
|
+
const minted = await mintBearerFromAppKey();
|
|
124
|
+
if (!minted?.bearer)
|
|
125
|
+
return null;
|
|
126
|
+
return await fetchWorkspaceThemeWithBearer(minted.bearer, workspaceId);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function fetchWorkspaceTheme(assembly, workspaceId) {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const cached = workspaceThemeCache.get(workspaceId);
|
|
135
|
+
if (cached && cached.expiresAt > now)
|
|
136
|
+
return cached;
|
|
137
|
+
let timeoutId;
|
|
138
|
+
try {
|
|
139
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
140
|
+
timeoutId = setTimeout(() => reject(new Error('retrieveWorkspace timeout')), FETCH_TIMEOUT_MS);
|
|
141
|
+
});
|
|
142
|
+
const workspace = await Promise.race([assembly.retrieveWorkspace(), timeoutPromise]);
|
|
143
|
+
clearTimeout(timeoutId);
|
|
144
|
+
const rawPortalUrl = workspace.portalUrl ?? null;
|
|
145
|
+
const theme = {
|
|
146
|
+
portalUrl: rawPortalUrl ? rawPortalUrl.replace(/^https?:\/\//, '') : null,
|
|
147
|
+
colorAccent: workspace.colorAccent ?? null,
|
|
148
|
+
font: workspace.font ?? null,
|
|
149
|
+
labelClientOne: workspace.labels?.individualTerm || 'Client',
|
|
150
|
+
labelClientMany: workspace.labels?.individualTermPlural || 'Clients',
|
|
151
|
+
labelCompanyOne: workspace.labels?.groupTerm || 'Company',
|
|
152
|
+
labelCompanyMany: workspace.labels?.groupTermPlural || 'Companies',
|
|
153
|
+
expiresAt: now + WORKSPACE_TTL_MS,
|
|
154
|
+
};
|
|
155
|
+
if (workspaceThemeCache.size >= MAX_WORKSPACE_THEME_CACHE_SIZE) {
|
|
156
|
+
const oldest = workspaceThemeCache.keys().next().value;
|
|
157
|
+
if (oldest !== undefined)
|
|
158
|
+
workspaceThemeCache.delete(oldest);
|
|
159
|
+
}
|
|
160
|
+
workspaceThemeCache.set(workspaceId, theme);
|
|
161
|
+
return theme;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Fetch failed — cache nothing and return null so the next request re-attempts,
|
|
165
|
+
// exactly as the pre-AI-260 proxy did. We never persist placeholder branding.
|
|
166
|
+
clearTimeout(timeoutId);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Performs the server-side token exchange on the first request from the iframe:
|
|
171
|
+
// trades the session token for a short-lived bearer (via mintBearerFromSessionToken)
|
|
172
|
+
// and fetches the workspace theme WITH that bearer. Returns the identity + theme
|
|
173
|
+
// for THIS response's headers/cookie only — nothing is cached per-bearer; every
|
|
174
|
+
// later request re-derives identity by decoding the bearer cookie.
|
|
175
|
+
async function resolveAndExchange(token, cookieSessionId) {
|
|
176
|
+
const minted = await mintBearerFromSessionToken(token, { cookieSessionId });
|
|
177
|
+
// No bearer: undecodable, not exchange-capable (no tokenId), a consumed/
|
|
178
|
+
// replayed/expired single-use token, or an exchange that could not be
|
|
179
|
+
// completed. Fail closed — a bearer-capable app never renders from a raw
|
|
180
|
+
// token. The reason travels with the rejection because it decides whether a
|
|
181
|
+
// warm cookie may answer for this token — as does the viewer verdict the
|
|
182
|
+
// exchange returns when it was told which session the cookie came from.
|
|
183
|
+
if (!minted.ok) {
|
|
184
|
+
return {
|
|
185
|
+
kind: 'reject',
|
|
186
|
+
reason: minted.reason,
|
|
187
|
+
rejectedViewer: minted.rejectedViewer,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const { bearer, bearerExpiresAt, session } = minted;
|
|
191
|
+
// Theme is cosmetic: a viewer-bearer miss falls back to an app-scoped mint
|
|
192
|
+
// (or NO_BRANDING) inside fetchWorkspaceThemeForIdentity. The viewer bearer
|
|
193
|
+
// is kept regardless — the single-use session token was already consumed.
|
|
194
|
+
const theme = await fetchWorkspaceThemeForIdentity(session.workspaceId, bearer);
|
|
195
|
+
return { kind: 'exchanged', session, theme, bearer, bearerExpiresAt };
|
|
196
|
+
}
|
|
197
|
+
// Legacy resolve path for session tokens that do not carry a tokenId (older
|
|
198
|
+
// SDK versions). Keeps behaviour identical to pre-AI-260 for those callers.
|
|
199
|
+
const legacyTokenCache = new Map();
|
|
200
|
+
// Deduplicates concurrent first-load requests that carry the same session token
|
|
201
|
+
// so only one /v1/session call is made per token.
|
|
202
|
+
const exchangeInFlight = new Map();
|
|
203
|
+
async function resolveLegacyToken(token) {
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const cached = legacyTokenCache.get(token);
|
|
206
|
+
if (cached && cached.expiresAt > now)
|
|
207
|
+
return cached;
|
|
208
|
+
if (cached)
|
|
209
|
+
legacyTokenCache.delete(token);
|
|
210
|
+
let assembly;
|
|
211
|
+
try {
|
|
212
|
+
assembly = await decryptSessionToken(token);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
let workspaceId = null;
|
|
218
|
+
let clientId;
|
|
219
|
+
let internalUserId;
|
|
220
|
+
let companyId;
|
|
221
|
+
if (typeof assembly.getTokenPayload === 'function') {
|
|
222
|
+
try {
|
|
223
|
+
const p = await assembly.getTokenPayload();
|
|
224
|
+
workspaceId = p.workspaceId ?? null;
|
|
225
|
+
clientId = p.clientId || undefined;
|
|
226
|
+
internalUserId = p.internalUserId || undefined;
|
|
227
|
+
companyId = p.companyId || undefined;
|
|
228
|
+
}
|
|
229
|
+
catch { }
|
|
230
|
+
}
|
|
231
|
+
if (!workspaceId)
|
|
232
|
+
return null;
|
|
233
|
+
const theme = await fetchWorkspaceTheme(assembly, workspaceId);
|
|
234
|
+
// Branding fetch failed — return null without caching so the next request
|
|
235
|
+
// re-attempts, matching the pre-AI-260 proxy (no placeholder branding served).
|
|
236
|
+
if (!theme)
|
|
237
|
+
return null;
|
|
238
|
+
const data = {
|
|
239
|
+
workspaceId,
|
|
240
|
+
clientId,
|
|
241
|
+
internalUserId,
|
|
242
|
+
companyId,
|
|
243
|
+
...theme,
|
|
244
|
+
expiresAt: now + WORKSPACE_TTL_MS,
|
|
245
|
+
};
|
|
246
|
+
if (legacyTokenCache.size >= MAX_LEGACY_TOKEN_CACHE_SIZE) {
|
|
247
|
+
const oldest = legacyTokenCache.keys().next().value;
|
|
248
|
+
if (oldest !== undefined)
|
|
249
|
+
legacyTokenCache.delete(oldest);
|
|
250
|
+
}
|
|
251
|
+
legacyTokenCache.set(token, data);
|
|
252
|
+
return data;
|
|
253
|
+
}
|
|
254
|
+
async function decodeSessionTokenPayload(token) {
|
|
255
|
+
let assembly;
|
|
256
|
+
try {
|
|
257
|
+
assembly = await decryptSessionToken(token);
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
if (typeof assembly.getTokenPayload !== 'function')
|
|
263
|
+
return null;
|
|
264
|
+
try {
|
|
265
|
+
const payload = await assembly.getTokenPayload();
|
|
266
|
+
if (!payload?.workspaceId)
|
|
267
|
+
return null;
|
|
268
|
+
return {
|
|
269
|
+
workspaceId: payload.workspaceId,
|
|
270
|
+
sessionId: payload.tokenId || undefined,
|
|
271
|
+
clientId: payload.clientId || undefined,
|
|
272
|
+
internalUserId: payload.internalUserId || undefined,
|
|
273
|
+
companyId: payload.companyId || undefined,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// Whether this payload's identity may be compared against the cookie's at all.
|
|
281
|
+
// A payload carrying no identity claim at all had it stripped deliberately, so
|
|
282
|
+
// its missing clientId says nothing about the viewer — on a client surface that
|
|
283
|
+
// describes every single request, not a switch. Identity is only meaningful on a
|
|
284
|
+
// payload that carries some, or one with no session id and so predating session
|
|
285
|
+
// tokens entirely.
|
|
286
|
+
function payloadIdentityIsAuthoritative(decoded) {
|
|
287
|
+
return Boolean(decoded.clientId ||
|
|
288
|
+
decoded.internalUserId ||
|
|
289
|
+
decoded.companyId ||
|
|
290
|
+
!decoded.sessionId);
|
|
291
|
+
}
|
|
292
|
+
function previewIdentitiesMatch(cookieIdentity, decoded) {
|
|
293
|
+
return (cookieIdentity.workspaceId === decoded.workspaceId &&
|
|
294
|
+
(cookieIdentity.clientId ?? '') === (decoded.clientId ?? '') &&
|
|
295
|
+
(cookieIdentity.companyId ?? '') === (decoded.companyId ?? ''));
|
|
296
|
+
}
|
|
297
|
+
async function exchangeUrlToken(token, cookieSessionId) {
|
|
298
|
+
// Keyed on both inputs: the same token exchanged with a different cookie
|
|
299
|
+
// context is a different question, and must not reuse the other's verdict.
|
|
300
|
+
const key = `${cookieSessionId ?? ''}|${token}`;
|
|
301
|
+
const inFlight = exchangeInFlight.get(key);
|
|
302
|
+
if (inFlight)
|
|
303
|
+
return inFlight;
|
|
304
|
+
const promise = resolveAndExchange(token, cookieSessionId);
|
|
305
|
+
exchangeInFlight.set(key, promise);
|
|
306
|
+
try {
|
|
307
|
+
return await promise;
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
exchangeInFlight.delete(key);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Returns the app's proxy function. The app's proxy.ts must still declare its
|
|
314
|
+
// own `export const config` literal: Next reads the matcher statically from that
|
|
315
|
+
// file and ignores an imported value.
|
|
316
|
+
export function createAssemblyProxy(options = {}) {
|
|
317
|
+
return (request) => handleRequest(request, options);
|
|
318
|
+
}
|
|
319
|
+
async function handleRequest(request, options) {
|
|
320
|
+
// Before CSRF / exchange: sendBeacon is a headerless POST and must not 403.
|
|
321
|
+
// POST only — a GET/HEAD to the path (crawler, prefetch, curious user) must
|
|
322
|
+
// not mint a fake painted line; it falls through to the normal proxy path.
|
|
323
|
+
if (request.method === 'POST' && isPaintedBeaconPath(request.nextUrl.pathname)) {
|
|
324
|
+
return handlePaintedBeacon();
|
|
325
|
+
}
|
|
326
|
+
const frameAncestors = [...STATIC_FRAME_ANCESTORS];
|
|
327
|
+
const requestHeaders = new Headers(request.headers);
|
|
328
|
+
// Never trust inbound values for the headers this proxy owns — the bearer it
|
|
329
|
+
// forwards, and the identity/branding headers the layout reads. Each is set
|
|
330
|
+
// below strictly from this proxy's own exchange/cookie resolution; stripping
|
|
331
|
+
// them first means a request that resolves to no identity can't inject a
|
|
332
|
+
// spoofed viewer (e.g. forcing client accent/font on an internal render).
|
|
333
|
+
stripProxyOwnedHeaders(requestHeaders);
|
|
334
|
+
let identity = null;
|
|
335
|
+
let theme = null;
|
|
336
|
+
let newBearer = null;
|
|
337
|
+
let newBearerExpiresAt = 0;
|
|
338
|
+
let failClosedReason = null;
|
|
339
|
+
// Master switch: while off, skip the exchange entirely and behave exactly as
|
|
340
|
+
// the pre-AI-260 legacy path. This keeps every foundation snapshot dormant
|
|
341
|
+
// until the exchange is deliberately enabled + the snapshot rebuilt.
|
|
342
|
+
const exchangeEnabled = isSessionExchangeEnabled();
|
|
343
|
+
// CSRF mitigation: when exchange is enabled the bearer travels as an
|
|
344
|
+
// HttpOnly SameSite=None cookie (required for cross-site iframe embedding),
|
|
345
|
+
// which the browser attaches to every request — including forged cross-site
|
|
346
|
+
// ones. A custom header cannot be set by a cross-site request without a
|
|
347
|
+
// CORS preflight, and this app never grants one, so requiring it here is
|
|
348
|
+
// sufficient to block CSRF. Server actions are managed by Next.js with its
|
|
349
|
+
// own Origin-based protection and cannot carry a custom header.
|
|
350
|
+
//
|
|
351
|
+
// The check only protects against ambient credentials, so a request carrying
|
|
352
|
+
// no cookie at all has nothing to protect: an inbound webhook or API client
|
|
353
|
+
// sends none. Such a request is passed to the route with identity stripped
|
|
354
|
+
// (the strip above already ran), skipping the token exchange and writing no
|
|
355
|
+
// cookies, so a forged `?token=` cannot mint a session through it. Any
|
|
356
|
+
// cookie, not only the bearer, keeps the request under the check.
|
|
357
|
+
if (exchangeEnabled && STATE_CHANGING_METHODS.has(request.method.toUpperCase())) {
|
|
358
|
+
const isServerAction = request.headers.has('next-action');
|
|
359
|
+
if (!isServerAction && !request.headers.has(APP_CSRF_HEADER)) {
|
|
360
|
+
if (request.headers.get('cookie')) {
|
|
361
|
+
return new NextResponse(null, { status: 403 });
|
|
362
|
+
}
|
|
363
|
+
return NextResponse.next({ request: { headers: requestHeaders } });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
// Primary path: read bearer from the HttpOnly cookie set on first load.
|
|
367
|
+
const bearerCookie = exchangeEnabled
|
|
368
|
+
? request.cookies.get(BEARER_COOKIE_NAME)?.value
|
|
369
|
+
: undefined;
|
|
370
|
+
let clearBearerCookie = false;
|
|
371
|
+
if (bearerCookie) {
|
|
372
|
+
// Derive identity from the self-describing bearer on EVERY request — no
|
|
373
|
+
// cache (a per-instance Map is worthless across serverless instances, and
|
|
374
|
+
// caching credentials is needless). A failed decode (expired/malformed
|
|
375
|
+
// bearer) clears the cookie so the next full-page load re-exchanges via
|
|
376
|
+
// ?token= instead of looping on 401.
|
|
377
|
+
identity = decodeBearer(bearerCookie);
|
|
378
|
+
if (!identity)
|
|
379
|
+
clearBearerCookie = true;
|
|
380
|
+
}
|
|
381
|
+
// Fallback for browsers that refuse to store the cookie (Safari blocks every
|
|
382
|
+
// cross-site-iframe cookie and has no CHIPS): the client holds the bearer in
|
|
383
|
+
// memory and sends it as a header. Only consulted when the cookie yielded no
|
|
384
|
+
// identity, so a browser with a working cookie takes exactly the same path as
|
|
385
|
+
// before. An undecodable value simply leaves identity null — there is no
|
|
386
|
+
// cookie here to clear, and the resulting 401 is what drives a re-exchange.
|
|
387
|
+
const clientBearer = exchangeEnabled
|
|
388
|
+
? request.headers.get(CLIENT_BEARER_HEADER)
|
|
389
|
+
: null;
|
|
390
|
+
let servedFromClientBearer = false;
|
|
391
|
+
if (!identity && clientBearer) {
|
|
392
|
+
identity = decodeBearer(clientBearer);
|
|
393
|
+
servedFromClientBearer = Boolean(identity);
|
|
394
|
+
}
|
|
395
|
+
const urlToken = request.nextUrl.searchParams.get('token');
|
|
396
|
+
// One exchange attempt per request: the fallback below must not re-hit
|
|
397
|
+
// /v1/session for a token the block above already tried and failed on.
|
|
398
|
+
let urlTokenExchanged = false;
|
|
399
|
+
// A valid cookie does not always answer for an incoming ?token=. Two cases
|
|
400
|
+
// force a re-exchange:
|
|
401
|
+
// 1. New open: the token denotes a session we have not exchanged. Its
|
|
402
|
+
// context (notificationId/instanceId, and the viewer itself) lives on the
|
|
403
|
+
// server-side session record, so exchanging is the only way to pick it up
|
|
404
|
+
// — without this a Notification Center embed opened against a warm cookie
|
|
405
|
+
// never sees its notificationId. Anything that merely replays the URL (a
|
|
406
|
+
// reload, a server-action POST) re-sends the same session and keeps the
|
|
407
|
+
// cookie.
|
|
408
|
+
// 2. Viewer switch: the editor swaps Team/Client preview by updating iframe
|
|
409
|
+
// ?token=. Only consulted where identity is meaningful; case 1 catches the
|
|
410
|
+
// switch anyway, since a new preview always mints a new session.
|
|
411
|
+
if (exchangeEnabled && urlToken && identity && !clearBearerCookie) {
|
|
412
|
+
const decoded = await decodeSessionTokenPayload(urlToken);
|
|
413
|
+
const isNewOpen = Boolean(decoded?.sessionId && decoded.sessionId !== identity.sessionId);
|
|
414
|
+
const isViewerSwitch = Boolean(decoded &&
|
|
415
|
+
payloadIdentityIsAuthoritative(decoded) &&
|
|
416
|
+
!previewIdentitiesMatch(identity, decoded));
|
|
417
|
+
if (isNewOpen || isViewerSwitch) {
|
|
418
|
+
urlTokenExchanged = true;
|
|
419
|
+
// Send the cookie's session id with the exchange: on a slim token a
|
|
420
|
+
// viewer switch is not provable pre-exchange, so a rejected replay's
|
|
421
|
+
// consumer is compared server-side against the cookie's viewer instead.
|
|
422
|
+
const outcome = await exchangeUrlToken(urlToken, identity.sessionId);
|
|
423
|
+
if (outcome.kind === 'exchanged') {
|
|
424
|
+
identity = outcome.session;
|
|
425
|
+
theme = outcome.theme;
|
|
426
|
+
newBearer = outcome.bearer;
|
|
427
|
+
newBearerExpiresAt = outcome.bearerExpiresAt;
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
failClosedReason = outcome.reason;
|
|
431
|
+
// A different-viewer verdict on the rejection is a proven mismatch:
|
|
432
|
+
// the spent token belonged to another viewer, so the cookie's bearer
|
|
433
|
+
// must not answer for it — same handling as a pre-exchange-provable
|
|
434
|
+
// viewer switch.
|
|
435
|
+
const provenViewerSwitch = isViewerSwitch || outcome.rejectedViewer === 'different_viewer';
|
|
436
|
+
if (provenViewerSwitch || outcome.reason === 'unavailable') {
|
|
437
|
+
if (provenViewerSwitch)
|
|
438
|
+
failClosedReason = 'viewer_switch';
|
|
439
|
+
// All ways to get here mean the cookie must not answer:
|
|
440
|
+
// - a different viewer is proven and no bearer could be minted;
|
|
441
|
+
// - the exchange could not be completed, so this token may still be
|
|
442
|
+
// unspent — a genuinely new session we merely failed to establish,
|
|
443
|
+
// not a replay. Rendering as the cookie's viewer would serve the
|
|
444
|
+
// previous session (and its stale notification/instance context).
|
|
445
|
+
identity = null;
|
|
446
|
+
theme = null;
|
|
447
|
+
// Only a proven mismatch justifies telling the browser to drop its
|
|
448
|
+
// bearer; a transient exchange failure must not log a live session out.
|
|
449
|
+
clearBearerCookie = provenViewerSwitch;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// isNewOpen-only + 'rejected' with no different-viewer verdict: the
|
|
453
|
+
// token is definitively spent by this same viewer (or by an exchange too
|
|
454
|
+
// old to say), i.e. a stale remount replaying one an earlier request
|
|
455
|
+
// already exchanged. Keep the cookie bearer — platform-api re-validates
|
|
456
|
+
// its signature on every real call, so dropping it buys no safety and
|
|
457
|
+
// locks the app out until the parent pushes a fresh token.
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
// Nothing resolved from a cookie and the block above did not already try this
|
|
461
|
+
// token (first load, or an undecodable/expired bearer) — resolve from ?token=.
|
|
462
|
+
if (!identity && urlToken && !urlTokenExchanged) {
|
|
463
|
+
if (!exchangeEnabled) {
|
|
464
|
+
// Exchange disabled: legacy per-request decrypt path (identity + theme).
|
|
465
|
+
const legacy = await resolveLegacyToken(urlToken);
|
|
466
|
+
if (legacy) {
|
|
467
|
+
identity = legacy;
|
|
468
|
+
theme = legacy;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
const outcome = await exchangeUrlToken(urlToken);
|
|
473
|
+
if (outcome.kind === 'exchanged') {
|
|
474
|
+
identity = outcome.session;
|
|
475
|
+
theme = outcome.theme;
|
|
476
|
+
newBearer = outcome.bearer;
|
|
477
|
+
newBearerExpiresAt = outcome.bearerExpiresAt;
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
failClosedReason = outcome.reason;
|
|
481
|
+
}
|
|
482
|
+
// outcome.kind === 'reject': no bearer for this token (undecodable,
|
|
483
|
+
// tokenId-less, or a consumed/replayed single-use token). Leave identity
|
|
484
|
+
// null so no branding/identity headers are set and no bearer is
|
|
485
|
+
// forwarded. The app's getAssembly() then finds no bearer and
|
|
486
|
+
// re-authenticates instead of rendering.
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// Cookie path: identity came from decoding the bearer, but branding did not.
|
|
490
|
+
// Fetch the workspace theme (cache-backed) for the CSP frame-ancestors +
|
|
491
|
+
// branding headers, authenticating with the bearer cookie itself.
|
|
492
|
+
// The already-valid bearer this request arrived with, from whichever source
|
|
493
|
+
// answered above.
|
|
494
|
+
const warmBearer = servedFromClientBearer
|
|
495
|
+
? clientBearer
|
|
496
|
+
: bearerCookie && !clearBearerCookie
|
|
497
|
+
? bearerCookie
|
|
498
|
+
: null;
|
|
499
|
+
if (exchangeEnabled && identity && !theme && warmBearer) {
|
|
500
|
+
theme = await fetchWorkspaceThemeForIdentity(identity.workspaceId, warmBearer);
|
|
501
|
+
}
|
|
502
|
+
if (options.onForward) {
|
|
503
|
+
// An app hook must not fail the request: by now a single-use session token
|
|
504
|
+
// may have been spent on the bearer this response carries, and a 500 would
|
|
505
|
+
// drop its Set-Cookie and leave the viewer with nothing to retry with.
|
|
506
|
+
try {
|
|
507
|
+
await options.onForward({ request, headers: requestHeaders, session: identity });
|
|
508
|
+
}
|
|
509
|
+
catch (err) {
|
|
510
|
+
console.error('assembly proxy onForward hook threw', err);
|
|
511
|
+
}
|
|
512
|
+
// The hook may copy inbound headers wholesale; the proxy-owned ones below
|
|
513
|
+
// must still come only from this proxy, including when no viewer resolved.
|
|
514
|
+
stripProxyOwnedHeaders(requestHeaders);
|
|
515
|
+
}
|
|
516
|
+
// Only an accepted identity (a decodable bearer cookie or a successful mint)
|
|
517
|
+
// drives branding. A token that decrypts but fails the exchange leaves
|
|
518
|
+
// identity null and sets no branding/isClient headers — the render falls back
|
|
519
|
+
// to neutral, matching the fail-closed data path, rather than letting an
|
|
520
|
+
// unaccepted token pick the accent/font.
|
|
521
|
+
let portalOrigin = null;
|
|
522
|
+
if (identity) {
|
|
523
|
+
const branding = theme ?? NO_BRANDING;
|
|
524
|
+
if (branding.portalUrl) {
|
|
525
|
+
portalOrigin = 'https://' + branding.portalUrl;
|
|
526
|
+
frameAncestors.push(portalOrigin);
|
|
527
|
+
}
|
|
528
|
+
// Branding (workspace theme) isn't carried by the bearer, and the proxy
|
|
529
|
+
// already fetched it for the CSP above — forward it so the app doesn't
|
|
530
|
+
// re-fetch the theme on every render. (The layout gates custom font/accent
|
|
531
|
+
// to client views.)
|
|
532
|
+
requestHeaders.set('x-assembly-color-accent', branding.colorAccent ?? '');
|
|
533
|
+
requestHeaders.set('x-assembly-font', branding.font ?? '');
|
|
534
|
+
requestHeaders.set('x-assembly-label-client-one', branding.labelClientOne);
|
|
535
|
+
requestHeaders.set('x-assembly-label-client-many', branding.labelClientMany);
|
|
536
|
+
requestHeaders.set('x-assembly-label-company-one', branding.labelCompanyOne);
|
|
537
|
+
requestHeaders.set('x-assembly-label-company-many', branding.labelCompanyMany);
|
|
538
|
+
// isClient gates accent/font in layout SSR. Always forward it so layout can
|
|
539
|
+
// fall back when getSessionIdentity() cannot read the bearer on first paint.
|
|
540
|
+
requestHeaders.set('x-assembly-is-client', identity.clientId ? 'true' : 'false');
|
|
541
|
+
// Distinct/workspace headers are legacy-only — bearer path reads them from
|
|
542
|
+
// the self-describing bearer via getSessionIdentity().
|
|
543
|
+
if (!exchangeEnabled) {
|
|
544
|
+
requestHeaders.set('x-assembly-distinct-id', identity.clientId ?? '');
|
|
545
|
+
requestHeaders.set('x-assembly-workspace-id', identity.workspaceId ?? '');
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// Hand the active bearer to the render as a request header. On first load the
|
|
549
|
+
// bearer exists nowhere else the render can see it (the Set-Cookie below isn't
|
|
550
|
+
// visible to this same request); on later requests this simply mirrors the
|
|
551
|
+
// still-valid cookie. This is what lets a capable app render first-load from
|
|
552
|
+
// the bearer instead of decrypting the (now single-use-consumed) raw token.
|
|
553
|
+
const activeBearer = newBearer ?? (identity ? warmBearer : null);
|
|
554
|
+
if (activeBearer) {
|
|
555
|
+
requestHeaders.set(BEARER_HEADER_NAME, activeBearer);
|
|
556
|
+
}
|
|
557
|
+
// No accepted identity and no fresh bearer (an undecodable cookie, a viewer
|
|
558
|
+
// switch whose re-exchange minted nothing, an exchange that could not be
|
|
559
|
+
// completed): drop the bearer cookie from the FORWARDED request, so the
|
|
560
|
+
// render's getSessionIdentity() cannot resolve a viewer this request did not
|
|
561
|
+
// accept and bleed the previous view's data or branding into it. Keyed on
|
|
562
|
+
// identity rather than on clearBearerCookie — whether we tell the BROWSER to
|
|
563
|
+
// drop its cookie is a separate decision, and a transient exchange failure
|
|
564
|
+
// deliberately keeps the cookie while still refusing to render from it.
|
|
565
|
+
// A successful re-exchange sets BEARER_HEADER_NAME above (which the render
|
|
566
|
+
// prefers), so it is never stripped.
|
|
567
|
+
if (!identity && !newBearer) {
|
|
568
|
+
const cookieHeader = requestHeaders.get('cookie');
|
|
569
|
+
if (cookieHeader) {
|
|
570
|
+
const filtered = cookieHeader
|
|
571
|
+
.split(/; */)
|
|
572
|
+
.filter((c) => c && !c.startsWith(`${BEARER_COOKIE_NAME}=`))
|
|
573
|
+
.join('; ');
|
|
574
|
+
if (filtered)
|
|
575
|
+
requestHeaders.set('cookie', filtered);
|
|
576
|
+
else
|
|
577
|
+
requestHeaders.delete('cookie');
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
|
581
|
+
response.headers.set('Content-Security-Policy', 'frame-ancestors ' + frameAncestors.join(' '));
|
|
582
|
+
// Write the bearer cookie via raw appended Set-Cookie headers (not
|
|
583
|
+
// response.cookies.set) so the partitioned record and the legacy-unpartitioned
|
|
584
|
+
// clear are emitted as TWO separate headers — ResponseCookies keys by name and
|
|
585
|
+
// would collapse them into one. The bearer is Partitioned (CHIPS) because the
|
|
586
|
+
// app renders in a cross-site iframe, where a plain SameSite=None cookie is a
|
|
587
|
+
// third-party cookie Chrome blocks under the 3P-cookie phase-out. CHIPS does
|
|
588
|
+
// NOT rescue Safari, which implements no such thing and blocks cross-site
|
|
589
|
+
// iframe cookies outright — that browser never stores the bearer at all and is
|
|
590
|
+
// carried by the client-held bearer above instead. We always also clear the
|
|
591
|
+
// unpartitioned record so a pre-rollout legacy cookie can't shadow this one.
|
|
592
|
+
// The probe is a client-readable twin written and cleared in lockstep: it is
|
|
593
|
+
// how the client learns the bearer was dropped.
|
|
594
|
+
if (newBearer) {
|
|
595
|
+
const maxAge = Math.max(0, Math.floor((newBearerExpiresAt - Date.now()) / 1_000));
|
|
596
|
+
response.headers.append('Set-Cookie', serializeBearerCookie('', 0, false));
|
|
597
|
+
response.headers.append('Set-Cookie', serializeBearerCookie(newBearer, maxAge, true));
|
|
598
|
+
response.headers.append('Set-Cookie', serializeCookieProbe(true));
|
|
599
|
+
}
|
|
600
|
+
else if (clearBearerCookie) {
|
|
601
|
+
response.headers.append('Set-Cookie', serializeBearerCookie('', 0, false));
|
|
602
|
+
response.headers.append('Set-Cookie', serializeBearerCookie('', 0, true));
|
|
603
|
+
response.headers.append('Set-Cookie', serializeCookieProbe(true, true));
|
|
604
|
+
}
|
|
605
|
+
else if (bearerCookie && isDocumentRequest(request)) {
|
|
606
|
+
// Refresh the probe on a document served from a live cookie: it outlives the
|
|
607
|
+
// bearer, so without this it would eventually lapse on a browser whose
|
|
608
|
+
// cookies work and send that browser down the cookie-less path.
|
|
609
|
+
response.headers.append('Set-Cookie', serializeCookieProbe(true));
|
|
610
|
+
}
|
|
611
|
+
// Mirror the workspace's portal origin (the same platform-resolved value the
|
|
612
|
+
// CSP above uses) into a client-READABLE cookie: the foundation's
|
|
613
|
+
// origin-bootstrap module reads it at bundle load and registers it with the
|
|
614
|
+
// bridge allowlist. The bridge has no trust bypass, so this is the only path
|
|
615
|
+
// by which a custom portal domain becomes a valid postMessage parent.
|
|
616
|
+
// Partitioned + SameSite=None for the same CHIPS reasons as the bearer;
|
|
617
|
+
// deliberately NOT HttpOnly — being JS-readable is the point, and the value
|
|
618
|
+
// is not a secret (it is served in the CSP header of every response).
|
|
619
|
+
if (portalOrigin && isDocumentRequest(request)) {
|
|
620
|
+
response.headers.append('Set-Cookie', `assembly-portal-origin=${encodeURIComponent(portalOrigin)}; Path=/; Max-Age=86400; SameSite=None; Secure; Partitioned`);
|
|
621
|
+
}
|
|
622
|
+
// Client-readable flags cookie for foundation modules gated on a specific
|
|
623
|
+
// flag (origin bootstrap, the paint beacon) — the bearer cookie is HttpOnly,
|
|
624
|
+
// so they can't derive this themselves. Two sources, one writer:
|
|
625
|
+
// - a fresh mint: the exchange response carries the evaluated allowlist, so
|
|
626
|
+
// seeding costs no extra call;
|
|
627
|
+
// - an expired/absent cookie on a live session: re-read via
|
|
628
|
+
// GET /v1/session/flags on the existing bearer.
|
|
629
|
+
// The short Max-Age is the freshness knob: a flag flip (including OFF — this
|
|
630
|
+
// is also the rollback lever) reaches every app within one cookie TTL at its
|
|
631
|
+
// next document load, without re-minting bearers. A null re-read means
|
|
632
|
+
// "undetermined" (network blip, expired bearer) — keep whatever the browser
|
|
633
|
+
// holds rather than treating the miss as all-flags-off. Same CHIPS reasoning
|
|
634
|
+
// as the portal-origin cookie above.
|
|
635
|
+
if (isDocumentRequest(request)) {
|
|
636
|
+
let flags = null;
|
|
637
|
+
if (newBearer) {
|
|
638
|
+
flags = identity?.flags ?? [];
|
|
639
|
+
}
|
|
640
|
+
else if (identity &&
|
|
641
|
+
bearerCookie &&
|
|
642
|
+
!clearBearerCookie &&
|
|
643
|
+
!request.cookies.has(CLIENT_FLAGS_COOKIE_NAME)) {
|
|
644
|
+
flags = await fetchSessionFlags(bearerCookie);
|
|
645
|
+
}
|
|
646
|
+
if (flags !== null) {
|
|
647
|
+
response.headers.append('Set-Cookie', `${CLIENT_FLAGS_COOKIE_NAME}=${encodeURIComponent(flags.join(','))}; Path=/; Max-Age=${FLAGS_COOKIE_TTL_SECONDS}; SameSite=None; Secure; Partitioned`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (exchangeEnabled && isDocumentRequest(request)) {
|
|
651
|
+
if (newBearer) {
|
|
652
|
+
logCookieOutcome('minted');
|
|
653
|
+
}
|
|
654
|
+
else if (identity && servedFromClientBearer) {
|
|
655
|
+
logCookieOutcome('served_from_client_bearer', failClosedReason ? { rejectedToken: failClosedReason } : undefined);
|
|
656
|
+
}
|
|
657
|
+
else if (identity && bearerCookie && !clearBearerCookie) {
|
|
658
|
+
// A rejected ?token= that the warm cookie answered for is still worth
|
|
659
|
+
// surfacing (replay signal) — carry the reason instead of dropping it.
|
|
660
|
+
logCookieOutcome('served_from_cookie', failClosedReason ? { rejectedToken: failClosedReason } : undefined);
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
const reason = failClosedReason ??
|
|
664
|
+
(clearBearerCookie ? 'undecodable' : urlToken ? 'rejected' : 'no_session');
|
|
665
|
+
logCookieOutcome(`fail_closed:${reason}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return response;
|
|
669
|
+
}
|