-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathspacePreviewToken.ts
More file actions
102 lines (94 loc) · 3.32 KB
/
Copy pathspacePreviewToken.ts
File metadata and controls
102 lines (94 loc) · 3.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import { JWTUtils } from './jwtUtils';
const PREVIEW_PURPOSE = 'space_preview';
export const SPACE_PREVIEW_TOKEN_TTL_SECONDS = 30 * 60;
export const SPACE_PREVIEW_COOKIE_NAME = '__space_preview';
export interface SpacePreviewClaims {
spaceName: string;
branch: string;
userId: string;
/**
* The app's `previewVersion` at mint time. Compared against the app's
* current value at preview time so a visibility toggle (which bumps the
* version) revokes this token.
*/
previewVersion: number;
}
export async function signSpacePreviewToken(
env: { JWT_SECRET: string },
claims: SpacePreviewClaims,
): Promise<string> {
const jwt = JWTUtils.getInstance(env);
return jwt.signPayload({ ...claims, purpose: PREVIEW_PURPOSE }, SPACE_PREVIEW_TOKEN_TTL_SECONDS);
}
export async function verifySpacePreviewToken(
env: { JWT_SECRET: string },
token: string,
expectedSpaceName: string,
expectedBranch: string,
): Promise<SpacePreviewClaims | null> {
const jwt = JWTUtils.getInstance(env);
const payload = await jwt.verifyPayload(token);
if (!payload) return null;
if (payload.purpose !== PREVIEW_PURPOSE) return null;
if (typeof payload.spaceName !== 'string' || payload.spaceName !== expectedSpaceName) return null;
if (typeof payload.branch !== 'string' || payload.branch !== expectedBranch) return null;
if (typeof payload.userId !== 'string' || payload.userId.trim() === '') return null;
if (typeof payload.previewVersion !== 'number' || !Number.isFinite(payload.previewVersion)) return null;
return {
spaceName: payload.spaceName,
branch: payload.branch,
userId: payload.userId,
previewVersion: payload.previewVersion,
};
}
/**
* Path the preview cookie is scoped to. No trailing slash so it prefix-matches
* `/space/<name>/preview/<branch>/...` but not a sibling like `<branch>2`.
*/
export function buildSpacePreviewCookiePath(spaceName: string, branch: string): string {
return `/space/${encodeURIComponent(spaceName)}/preview/${encodeURIComponent(branch)}`;
}
/**
* Build the `Set-Cookie` value for the path-scoped HttpOnly preview cookie.
* - Separate preview domain (cross-site iframe): `SameSite=None; Secure; Partitioned`.
* - Same-origin (dev/main domain): `SameSite=Lax` (+ `Secure` when served over https).
*/
export function buildPreviewCookie(opts: {
token: string;
spaceName: string;
branch: string;
crossSite: boolean;
secure: boolean;
ttlSeconds?: number;
}): string {
const { token, spaceName, branch, crossSite, secure } = opts;
const maxAge = opts.ttlSeconds ?? SPACE_PREVIEW_TOKEN_TTL_SECONDS;
const path = buildSpacePreviewCookiePath(spaceName, branch);
const parts = [
`${SPACE_PREVIEW_COOKIE_NAME}=${token}`,
`Path=${path}`,
`Max-Age=${maxAge}`,
'HttpOnly',
];
if (crossSite) {
parts.push('SameSite=None', 'Secure', 'Partitioned');
} else {
parts.push('SameSite=Lax');
if (secure) parts.push('Secure');
}
return parts.join('; ');
}
/** Read the raw preview-cookie value from a request's `Cookie` header. */
export function readPreviewCookie(request: Request): string | null {
const header = request.headers.get('Cookie');
if (!header) return null;
for (const pair of header.split(';')) {
const idx = pair.indexOf('=');
if (idx === -1) continue;
const name = pair.slice(0, idx).trim();
if (name === SPACE_PREVIEW_COOKIE_NAME) {
return pair.slice(idx + 1).trim();
}
}
return null;
}