Skip to content

Commit 1d7e8a4

Browse files
committed
Add support for PKCE to OIDC integration
1 parent 7f88bd7 commit 1d7e8a4

3 files changed

Lines changed: 304 additions & 6 deletions

File tree

.changeset/oidc-pkce-support.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@gitbook/integration-oidc': minor
3+
---
4+
5+
Add support for PKCE to the OIDC visitor authentication flow.

integrations/oidc/src/index.tsx

Lines changed: 132 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ import {
1313
ExposableError,
1414
} from '@gitbook/runtime';
1515

16+
import {
17+
clearPKCECookie,
18+
computePKCECodeChallenge,
19+
encryptPKCEVerifier,
20+
generatePKCECodeVerifier,
21+
getPKCECodeVerifierFromCookie,
22+
serializePKCECookie,
23+
} from './pkce';
24+
1625
const logger = Logger('oidc.visitor-auth');
1726

1827
type OIDCRuntimeEnvironment = RuntimeEnvironment<{}, OIDCSiteInstallationConfiguration>;
@@ -25,6 +34,7 @@ type OIDCSiteInstallationConfiguration = {
2534
access_token_endpoint?: string;
2635
client_secret?: string;
2736
scope?: string;
37+
use_pkce?: boolean;
2838
};
2939

3040
type OIDCState = OIDCSiteInstallationConfiguration;
@@ -71,6 +81,7 @@ const configBlock = createComponent<OIDCProps, OIDCState, OIDCAction, OIDCRuntim
7181
access_token_endpoint: siteInstallation.configuration?.access_token_endpoint || '',
7282
client_secret: siteInstallation.configuration?.client_secret || '',
7383
scope: siteInstallation.configuration?.scope || '',
84+
use_pkce: siteInstallation.configuration?.use_pkce ?? false,
7485
};
7586
},
7687
action: async (element, action, context) => {
@@ -91,6 +102,7 @@ const configBlock = createComponent<OIDCProps, OIDCState, OIDCAction, OIDCRuntim
91102
element.state.access_token_endpoint ?? '',
92103
),
93104
scope: element.state.scope ? normalizeScopes(element.state.scope) : undefined,
105+
use_pkce: element.state.use_pkce ?? false,
94106
};
95107
await api.integrations.updateIntegrationSiteInstallation(
96108
siteInstallation.integration,
@@ -211,6 +223,26 @@ const configBlock = createComponent<OIDCProps, OIDCState, OIDCAction, OIDCRuntim
211223
}
212224
element={<textinput state="scope" placeholder="Scopes" />}
213225
/>
226+
227+
<input
228+
label="Use PKCE"
229+
hint={
230+
<text>
231+
Enable Proof Key for Code Exchange (PKCE) for the authorization code
232+
flow. Turn this on if your authentication provider requires or
233+
recommends PKCE.
234+
<link
235+
target={{
236+
url: 'https://datatracker.ietf.org/doc/html/rfc7636',
237+
}}
238+
>
239+
{' '}
240+
More Details
241+
</link>
242+
</text>
243+
}
244+
element={<switch state="use_pkce" />}
245+
/>
214246
<divider size="medium" />
215247
<hint>
216248
<text style="bold">
@@ -428,6 +460,27 @@ const handleFetchEvent: FetchEventCallback<OIDCRuntimeContext> = async (request,
428460
redirect_uri: `${installationURL}/visitor-auth/response`,
429461
});
430462

463+
// If PKCE is enabled, read the code verifier back from the first-party cookie
464+
// set when the flow started and include it in the token request.
465+
if (siteInstallation.configuration.use_pkce) {
466+
const signingSecret = context.environment.signingSecrets.siteInstallation;
467+
if (!signingSecret) {
468+
return new Response('Error: Missing signing secret required for PKCE', {
469+
status: 400,
470+
});
471+
}
472+
const codeVerifier = await getPKCECodeVerifierFromCookie(
473+
request.headers.get('Cookie'),
474+
signingSecret,
475+
);
476+
if (!codeVerifier) {
477+
return new Response('Error: Missing or invalid PKCE verifier cookie', {
478+
status: 400,
479+
});
480+
}
481+
searchParams.append('code_verifier', codeVerifier);
482+
}
483+
431484
const tokenResp = await fetchTokenFromUpstreamAuth(
432485
accessTokenEndpoint,
433486
searchParams,
@@ -517,6 +570,17 @@ const handleFetchEvent: FetchEventCallback<OIDCRuntimeContext> = async (request,
517570
);
518571
url.searchParams.append('jwt_token', jwtToken);
519572

573+
// Clear the PKCE verifier cookie now that the flow is complete.
574+
if (siteInstallation.configuration.use_pkce) {
575+
return new Response(null, {
576+
status: 302,
577+
headers: {
578+
Location: url.toString(),
579+
'Set-Cookie': clearPKCECookie(new URL(installationURL).pathname),
580+
},
581+
});
582+
}
583+
520584
return Response.redirect(url.toString());
521585
}
522586
});
@@ -568,14 +632,76 @@ export default createIntegration({
568632
}
569633

570634
const location = event.location ? event.location : '';
635+
const redirectURI = `${installationURL}/visitor-auth/response`;
636+
637+
// With PKCE enabled, generate the code verifier here and store it in a first-party
638+
// cookie. This response is served from the integration's own domain, the same domain
639+
// as the `/visitor-auth/response` callback, so the cookie is sent back to the callback
640+
// where the verifier is needed for the token exchange.
641+
if (configuration.use_pkce) {
642+
const signingSecret = environment.signingSecrets.siteInstallation;
643+
if (!signingSecret) {
644+
throw new ExposableError('Missing signing secret required for PKCE');
645+
}
646+
647+
const codeVerifier = generatePKCECodeVerifier();
648+
const codeChallenge = await computePKCECodeChallenge(codeVerifier);
649+
const encryptedVerifier = await encryptPKCEVerifier(signingSecret, codeVerifier);
650+
651+
const url = buildAuthorizeURL({
652+
authorizationEndpoint,
653+
clientId,
654+
redirectURI,
655+
scope,
656+
state: `oidcstate-${location}`,
657+
codeChallenge,
658+
});
571659

572-
const url = new URL(authorizationEndpoint);
573-
url.searchParams.append('client_id', clientId);
574-
url.searchParams.append('response_type', 'code');
575-
url.searchParams.append('redirect_uri', `${installationURL}/visitor-auth/response`);
576-
url.searchParams.append('scope', scope.toLowerCase());
577-
url.searchParams.append('state', `oidcstate-${location}`);
660+
return new Response(null, {
661+
status: 302,
662+
headers: {
663+
Location: url.toString(),
664+
'Set-Cookie': serializePKCECookie(
665+
encryptedVerifier,
666+
new URL(installationURL).pathname,
667+
),
668+
},
669+
});
670+
}
671+
672+
const url = buildAuthorizeURL({
673+
authorizationEndpoint,
674+
clientId,
675+
redirectURI,
676+
scope,
677+
state: `oidcstate-${location}`,
678+
});
578679

579680
return Response.redirect(url.toString());
580681
},
581682
});
683+
684+
/**
685+
* Build the authorization endpoint URL for the OAuth redirect, optionally
686+
* including the PKCE code challenge.
687+
*/
688+
function buildAuthorizeURL(params: {
689+
authorizationEndpoint: string;
690+
clientId: string;
691+
redirectURI: string;
692+
scope: string;
693+
state: string;
694+
codeChallenge?: string;
695+
}): URL {
696+
const url = new URL(params.authorizationEndpoint);
697+
url.searchParams.append('client_id', params.clientId);
698+
url.searchParams.append('response_type', 'code');
699+
url.searchParams.append('redirect_uri', params.redirectURI);
700+
url.searchParams.append('scope', params.scope.toLowerCase());
701+
url.searchParams.append('state', params.state);
702+
if (params.codeChallenge) {
703+
url.searchParams.append('code_challenge', params.codeChallenge);
704+
url.searchParams.append('code_challenge_method', 'S256');
705+
}
706+
return url;
707+
}

integrations/oidc/src/pkce.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* PKCE (Proof Key for Code Exchange, RFC 7636) helpers for the OIDC visitor
3+
* authentication flow.
4+
*/
5+
6+
const PKCE_COOKIE_NAME = 'gitbook-oidc-pkce-verifier';
7+
8+
/**
9+
* How long (in seconds) the PKCE verifier cookie is valid. The visitor only
10+
* needs it for the brief round-trip to the authentication provider and back.
11+
*/
12+
const PKCE_COOKIE_MAX_AGE = 600;
13+
14+
/**
15+
* Generate a cryptographically random PKCE code verifier (RFC 7636 §4.1):
16+
* 32 random bytes encoded as a 43-character base64url string.
17+
*/
18+
export function generatePKCECodeVerifier(): string {
19+
return base64url(crypto.getRandomValues(new Uint8Array(32)));
20+
}
21+
22+
/**
23+
* Compute the PKCE code challenge (S256) from a code verifier.
24+
*/
25+
export async function computePKCECodeChallenge(codeVerifier: string): Promise<string> {
26+
const encoder = new TextEncoder();
27+
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(codeVerifier));
28+
return base64url(digest);
29+
}
30+
31+
/**
32+
* Encrypt the PKCE verifier with AES-GCM. The random IV is prepended to the
33+
* ciphertext and the result is base64url-encoded for cookie storage.
34+
*/
35+
export async function encryptPKCEVerifier(
36+
signingSecret: string,
37+
plaintext: string,
38+
): Promise<string> {
39+
const key = await importPKCECookieKey(signingSecret);
40+
const iv = crypto.getRandomValues(new Uint8Array(12));
41+
const ciphertext = await crypto.subtle.encrypt(
42+
{ name: 'AES-GCM', iv },
43+
key,
44+
new TextEncoder().encode(plaintext),
45+
);
46+
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
47+
combined.set(iv, 0);
48+
combined.set(new Uint8Array(ciphertext), iv.length);
49+
return base64url(combined);
50+
}
51+
52+
/**
53+
* Decrypt a value produced by {@link encryptPKCEVerifier}. Throws if the cookie was
54+
* tampered with (AES-GCM authentication failure) or is malformed.
55+
*/
56+
async function decryptPKCEVerifier(signingSecret: string, value: string): Promise<string> {
57+
const key = await importPKCECookieKey(signingSecret);
58+
const combined = base64urlDecode(value);
59+
const iv = combined.slice(0, 12);
60+
const ciphertext = combined.slice(12);
61+
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
62+
return new TextDecoder().decode(plaintext);
63+
}
64+
65+
/**
66+
* Serialize the Set-Cookie header carrying the encrypted PKCE verifier. The
67+
* cookie is first-party (HttpOnly, Secure), scoped to the integration's own
68+
* path, and uses SameSite=Lax so it is still sent on the top-level redirect
69+
* back from the authentication provider.
70+
*/
71+
export function serializePKCECookie(value: string, path: string): string {
72+
return [
73+
`${PKCE_COOKIE_NAME}=${value}`,
74+
`Path=${path}`,
75+
`Max-Age=${PKCE_COOKIE_MAX_AGE}`,
76+
'HttpOnly',
77+
'Secure',
78+
'SameSite=Lax',
79+
].join('; ');
80+
}
81+
82+
/**
83+
* Serialize a Set-Cookie header that clears the PKCE verifier cookie.
84+
*/
85+
export function clearPKCECookie(path: string): string {
86+
return `${PKCE_COOKIE_NAME}=; Path=${path}; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
87+
}
88+
89+
/**
90+
* Read the encrypted PKCE verifier cookie from a request Cookie header and
91+
* decrypt it back into the code verifier. Returns `undefined` when the cookie is
92+
* absent or cannot be decrypted (e.g. it was tampered with, or the signing
93+
* secret rotated since the flow started).
94+
*/
95+
export async function getPKCECodeVerifierFromCookie(
96+
cookieHeader: string | null,
97+
signingSecret: string,
98+
): Promise<string | undefined> {
99+
const encryptedVerifier = readCookie(cookieHeader, PKCE_COOKIE_NAME);
100+
if (!encryptedVerifier) {
101+
return undefined;
102+
}
103+
try {
104+
return await decryptPKCEVerifier(signingSecret, encryptedVerifier);
105+
} catch {
106+
return undefined;
107+
}
108+
}
109+
110+
/**
111+
* Derive an AES-GCM key from the site installation signing secret, used to
112+
* encrypt the PKCE verifier before storing it in a cookie so the value stays
113+
* confidential and tamper-proof even if the cookie were to leak.
114+
*/
115+
async function importPKCECookieKey(signingSecret: string): Promise<CryptoKey> {
116+
const keyMaterial = await crypto.subtle.digest(
117+
'SHA-256',
118+
new TextEncoder().encode(signingSecret),
119+
);
120+
return crypto.subtle.importKey('raw', keyMaterial, { name: 'AES-GCM' }, false, [
121+
'encrypt',
122+
'decrypt',
123+
]);
124+
}
125+
126+
/**
127+
* Read a single cookie value from a request Cookie header.
128+
*/
129+
function readCookie(cookieHeader: string | null, name: string): string | undefined {
130+
if (!cookieHeader) {
131+
return undefined;
132+
}
133+
for (const part of cookieHeader.split(';')) {
134+
const [key, ...rest] = part.trim().split('=');
135+
if (key === name) {
136+
return rest.join('=');
137+
}
138+
}
139+
return undefined;
140+
}
141+
142+
/**
143+
* Encode bytes as a base64url string (RFC 4648 §5, unpadded), the encoding
144+
* required for PKCE code verifiers and challenges.
145+
*/
146+
function base64url(data: ArrayBuffer | Uint8Array): string {
147+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
148+
let binary = '';
149+
for (const byte of bytes) {
150+
binary += String.fromCharCode(byte);
151+
}
152+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
153+
}
154+
155+
/**
156+
* Decode a base64url string back into bytes.
157+
*/
158+
function base64urlDecode(input: string): Uint8Array {
159+
const b64 = input.replace(/-/g, '+').replace(/_/g, '/');
160+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
161+
const binary = atob(padded);
162+
const bytes = new Uint8Array(binary.length);
163+
for (let i = 0; i < binary.length; i++) {
164+
bytes[i] = binary.charCodeAt(i);
165+
}
166+
return bytes;
167+
}

0 commit comments

Comments
 (0)