Skip to content

Commit 44c35c0

Browse files
committed
Add methods in visitor util to return visitor unsigned claims and token
1 parent 5a69692 commit 44c35c0

File tree

4 files changed

+296
-17
lines changed

4 files changed

+296
-17
lines changed

packages/gitbook-v2/src/middleware.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import {
1010
type ResponseCookies,
1111
getPathScopedCookieName,
1212
getResponseCookiesForVisitorAuth,
13-
getVisitorToken,
13+
getVisitorPayload,
1414
normalizeVisitorAuthURL,
15-
} from '@/lib/visitor-token';
15+
} from '@/lib/visitors';
1616
import { serveResizedImage } from '@/routes/image';
1717
import {
1818
DataFetcherError,
@@ -85,8 +85,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
8585
//
8686
// Detect and extract the visitor authentication token from the request
8787
//
88-
// @ts-ignore - request typing
89-
const visitorToken = getVisitorToken({
88+
const { visitorToken } = getVisitorPayload({
9089
cookies: request.cookies.getAll(),
9190
url: siteRequestURL,
9291
});

packages/gitbook/src/lib/visitor-token.test.ts renamed to packages/gitbook/src/lib/visitors.test.ts

+138-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
getVisitorAuthCookieName,
77
getVisitorAuthCookieValue,
88
getVisitorToken,
9-
} from './visitor-token';
9+
getVisitorUnsignedClaims,
10+
} from './visitors';
1011

1112
describe('getVisitorAuthToken', () => {
1213
it('should return the token from the query parameters', () => {
@@ -158,3 +159,139 @@ function assertVisitorAuthCookieValue(
158159

159160
throw new Error('Expected a VisitorAuthCookieValue');
160161
}
162+
163+
describe('getVisitorPublicClaims', () => {
164+
it('should merge claims from multiple public cookies', () => {
165+
const cookies = [
166+
{
167+
name: 'gitbook-visitor-public-bucket',
168+
value: JSON.stringify({ bucket: { flags: { SITE_AI: true, SITE_PREVIEW: true } } }),
169+
},
170+
{
171+
name: 'gitbook-visitor-public-launchdarkly',
172+
value: JSON.stringify({
173+
launchdarkly: { flags: { ALPHA: true, API: true } },
174+
}),
175+
},
176+
];
177+
178+
const url = new URL('https://example.com/');
179+
const claims = getVisitorUnsignedClaims({ cookies, url });
180+
181+
expect(claims).toStrictEqual({
182+
bucket: { flags: { SITE_AI: true, SITE_PREVIEW: true } },
183+
launchdarkly: { flags: { ALPHA: true, API: true } },
184+
});
185+
});
186+
187+
it('should parse visitor.* query params with simple types', () => {
188+
const url = new URL(
189+
'https://example.com/?visitor.isEnterprise=true&visitor.language=fr&visitor.country=fr'
190+
);
191+
192+
const claims = getVisitorUnsignedClaims({ cookies: [], url });
193+
194+
expect(claims).toStrictEqual({
195+
isEnterprise: true,
196+
language: 'fr',
197+
country: 'fr',
198+
});
199+
});
200+
201+
it('should ignore params that do not match visitor.* convention', () => {
202+
const url = new URL('https://example.com/?visitor.isEnterprise=true&otherParam=true');
203+
204+
const claims = getVisitorUnsignedClaims({ cookies: [], url });
205+
206+
expect(claims).toStrictEqual({
207+
isEnterprise: true,
208+
// otherParam is not present
209+
});
210+
});
211+
212+
it('should ignore public cookies not present in the allowed list', () => {
213+
const url = new URL('https://example.com/');
214+
215+
const claims = getVisitorUnsignedClaims({
216+
cookies: [
217+
{
218+
name: 'gitbook-visitor-public',
219+
value: JSON.stringify({ role: 'admin', language: 'fr' }),
220+
},
221+
// The claims in this cookie should be ignored
222+
{
223+
name: 'gitbook-visitor-public-disallowed',
224+
value: JSON.stringify({
225+
disallowed: { flags: { SITE_AI: true, SITE_PREVIEW: true } },
226+
}),
227+
},
228+
],
229+
url,
230+
});
231+
232+
expect(claims).toStrictEqual({
233+
role: 'admin',
234+
language: 'fr',
235+
});
236+
});
237+
238+
it('should support nested query param keys via dot notation', () => {
239+
const url = new URL(
240+
'https://example.com/?visitor.isEnterprise=true&visitor.flags.ALPHA=true&visitor.flags.API=false'
241+
);
242+
243+
const claims = getVisitorUnsignedClaims({ cookies: [], url });
244+
245+
expect(claims).toStrictEqual({
246+
isEnterprise: true,
247+
flags: {
248+
ALPHA: true,
249+
API: false,
250+
},
251+
});
252+
});
253+
254+
it('should ignore invalid JSON in cookie values', () => {
255+
const cookies = [
256+
{
257+
name: 'gitbook-visitor-public',
258+
value: '{not: "json"}',
259+
},
260+
];
261+
const url = new URL('https://example.com/');
262+
const claims = getVisitorUnsignedClaims({ cookies, url });
263+
264+
expect(claims).toStrictEqual({});
265+
});
266+
267+
it('should merge claims from cookies and visitor.* query params', () => {
268+
const cookies = [
269+
{
270+
name: 'gitbook-visitor-public',
271+
value: JSON.stringify({ role: 'admin', language: 'fr' }),
272+
},
273+
{
274+
name: 'gitbook-visitor-public-bucket',
275+
value: JSON.stringify({ bucket: { flags: { SITE_AI: true, SITE_PREVIEW: true } } }),
276+
},
277+
];
278+
const url = new URL(
279+
'https://example.com/?visitor.isEnterprise=true&visitor.flags.ALPHA=true&visitor.flags.API=false'
280+
);
281+
282+
const claims = getVisitorUnsignedClaims({ cookies, url });
283+
284+
expect(claims).toStrictEqual({
285+
role: 'admin',
286+
language: 'fr',
287+
bucket: {
288+
flags: { SITE_AI: true, SITE_PREVIEW: true },
289+
},
290+
isEnterprise: true,
291+
flags: {
292+
ALPHA: true,
293+
API: false,
294+
},
295+
});
296+
});
297+
});

packages/gitbook/src/lib/visitor-token.ts renamed to packages/gitbook/src/lib/visitors.ts

+143
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import hash from 'object-hash';
44

55
const VISITOR_AUTH_PARAM = 'jwt_token';
66
export const VISITOR_TOKEN_COOKIE = 'gitbook-visitor-token';
7+
const VISITOR_UNSIGNED_CLAIM_COOKIES = [
8+
'gitbook-visitor-public',
9+
'gitbook-visitor-public-bucket',
10+
'gitbook-visitor-public-launchdarkly',
11+
];
712

813
/**
914
* Typing for a cookie, matching the internal type of Next.js.
@@ -30,6 +35,25 @@ type VisitorAuthCookieValue = {
3035
token: string;
3136
};
3237

38+
type ClaimPrimitive =
39+
| string
40+
| number
41+
| boolean
42+
| null
43+
| undefined
44+
| { [key: string]: ClaimPrimitive }
45+
| ClaimPrimitive[];
46+
47+
/**
48+
* The result of a visitor info lookup that can include:
49+
* - a visitor token (JWT)
50+
* - a record of visitor public/unsigned claims (JSON object)
51+
*/
52+
export type VisitorPayload = {
53+
visitorToken: VisitorTokenLookup;
54+
unsignedClaims: Record<string, ClaimPrimitive>;
55+
};
56+
3357
/**
3458
* The result of a visitor token lookup.
3559
*/
@@ -53,6 +77,25 @@ export type VisitorTokenLookup =
5377
/** Not visitor token was found */
5478
| undefined;
5579

80+
/**
81+
* Get the visitor info for the request including its token and/or unsigned claims when present.
82+
*/
83+
export function getVisitorPayload({
84+
cookies,
85+
url,
86+
}: {
87+
cookies: RequestCookies;
88+
url: URL | NextRequest['nextUrl'];
89+
}): VisitorPayload {
90+
const visitorToken = getVisitorToken({ cookies, url });
91+
const unsignedClaims = getVisitorUnsignedClaims({ cookies, url });
92+
93+
return {
94+
visitorToken,
95+
unsignedClaims,
96+
};
97+
}
98+
5699
/**
57100
* Get the visitor token for the request. This token can either be in the
58101
* query parameters or stored as a cookie.
@@ -82,6 +125,106 @@ export function getVisitorToken({
82125
}
83126
}
84127

128+
/**
129+
* Get the visitor unsigned/public claims for the request. They can either be in `visitor.` query
130+
* parameters or stored in special `gitbook-visitor-public-*` cookies.
131+
*/
132+
export function getVisitorUnsignedClaims(args: {
133+
cookies: RequestCookies;
134+
url: URL | NextRequest['nextUrl'];
135+
}): Record<string, ClaimPrimitive> {
136+
const { cookies, url } = args;
137+
const claims: Record<string, ClaimPrimitive> = {};
138+
139+
for (const cookie of cookies) {
140+
if (VISITOR_UNSIGNED_CLAIM_COOKIES.includes(cookie.name)) {
141+
try {
142+
const parsed = JSON.parse(cookie.value);
143+
if (typeof parsed === 'object' && parsed !== null) {
144+
Object.assign(claims, parsed);
145+
}
146+
} catch (_err) {
147+
console.warn(`Invalid JSON in unsigned claim cookie "${cookie.name}"`);
148+
}
149+
}
150+
}
151+
152+
for (const [key, value] of url.searchParams.entries()) {
153+
if (key.startsWith('visitor.')) {
154+
const claimPath = key.substring('visitor.'.length);
155+
const claimValue = parseVisitorQueryParamValue(value);
156+
setVisitorClaimByPath(claims, claimPath, claimValue);
157+
}
158+
}
159+
160+
return claims;
161+
}
162+
163+
/**
164+
* Set the value of claims in a claims object at a specific path.
165+
*/
166+
function setVisitorClaimByPath(
167+
claims: Record<string, ClaimPrimitive>,
168+
keyPath: string,
169+
value: ClaimPrimitive
170+
): void {
171+
const keys = keyPath.split('.');
172+
let current = claims;
173+
174+
for (let index = 0; index < keys.length; index++) {
175+
const key = keys[index];
176+
177+
if (index === keys.length - 1) {
178+
current[key] = value;
179+
} else {
180+
if (!(key in current) || !isClaimPrimitiveObject(current[key])) {
181+
current[key] = {};
182+
}
183+
184+
current = current[key];
185+
}
186+
}
187+
}
188+
189+
function isClaimPrimitiveObject(value: unknown): value is Record<string, ClaimPrimitive> {
190+
return typeof value === 'object' && value !== null;
191+
}
192+
193+
/**
194+
* Parse the value expected in a `visitor.` URL query parameter.
195+
*/
196+
function parseVisitorQueryParamValue(value: string): ClaimPrimitive {
197+
if (value === 'true') {
198+
return true;
199+
}
200+
201+
if (value === 'false') {
202+
return false;
203+
}
204+
205+
if (value === 'null') {
206+
return null;
207+
}
208+
209+
if (value === 'undefined') {
210+
return undefined;
211+
}
212+
213+
const num = Number(value);
214+
if (!Number.isNaN(num) && value.trim() !== '') {
215+
return num;
216+
}
217+
218+
try {
219+
const parsed = JSON.parse(value);
220+
if (typeof parsed === 'object' && parsed !== null) {
221+
return parsed;
222+
}
223+
} catch {}
224+
225+
return value;
226+
}
227+
85228
/**
86229
* Return the lookup result for content served with visitor auth.
87230
*/

0 commit comments

Comments
 (0)