Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/oidc-pkce-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gitbook/integration-oidc': minor
---

Add support for PKCE to the OIDC visitor authentication flow.
138 changes: 132 additions & 6 deletions integrations/oidc/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ import {
ExposableError,
} from '@gitbook/runtime';

import {
clearPKCECookie,
computePKCECodeChallenge,
encryptPKCEVerifier,
generatePKCECodeVerifier,
getPKCECodeVerifierFromCookie,
serializePKCECookie,
} from './pkce';

const logger = Logger('oidc.visitor-auth');

type OIDCRuntimeEnvironment = RuntimeEnvironment<{}, OIDCSiteInstallationConfiguration>;
Expand All @@ -25,6 +34,7 @@ type OIDCSiteInstallationConfiguration = {
access_token_endpoint?: string;
client_secret?: string;
scope?: string;
use_pkce?: boolean;
};

type OIDCState = OIDCSiteInstallationConfiguration;
Expand Down Expand Up @@ -71,6 +81,7 @@ const configBlock = createComponent<OIDCProps, OIDCState, OIDCAction, OIDCRuntim
access_token_endpoint: siteInstallation.configuration?.access_token_endpoint || '',
client_secret: siteInstallation.configuration?.client_secret || '',
scope: siteInstallation.configuration?.scope || '',
use_pkce: siteInstallation.configuration?.use_pkce ?? false,
};
},
action: async (element, action, context) => {
Expand All @@ -91,6 +102,7 @@ const configBlock = createComponent<OIDCProps, OIDCState, OIDCAction, OIDCRuntim
element.state.access_token_endpoint ?? '',
),
scope: element.state.scope ? normalizeScopes(element.state.scope) : undefined,
use_pkce: element.state.use_pkce ?? false,
};
await api.integrations.updateIntegrationSiteInstallation(
siteInstallation.integration,
Expand Down Expand Up @@ -211,6 +223,26 @@ const configBlock = createComponent<OIDCProps, OIDCState, OIDCAction, OIDCRuntim
}
element={<textinput state="scope" placeholder="Scopes" />}
/>

<input
label="Use PKCE"
hint={
<text>
Enable Proof Key for Code Exchange (PKCE) for the authorization code
flow. Turn this on if your authentication provider requires or
recommends PKCE.
<link
target={{
url: 'https://datatracker.ietf.org/doc/html/rfc7636',
}}
>
{' '}
More Details
</link>
</text>
}
element={<switch state="use_pkce" />}
/>
<divider size="medium" />
<hint>
<text style="bold">
Expand Down Expand Up @@ -428,6 +460,27 @@ const handleFetchEvent: FetchEventCallback<OIDCRuntimeContext> = async (request,
redirect_uri: `${installationURL}/visitor-auth/response`,
});

// If PKCE is enabled, read the code verifier back from the first-party cookie
// set when the flow started and include it in the token request.
if (siteInstallation.configuration.use_pkce) {
const signingSecret = context.environment.signingSecrets.siteInstallation;
if (!signingSecret) {
return new Response('Error: Missing signing secret required for PKCE', {
status: 400,
});
}
const codeVerifier = await getPKCECodeVerifierFromCookie(
request.headers.get('Cookie'),
signingSecret,
);
if (!codeVerifier) {
return new Response('Error: Missing or invalid PKCE verifier cookie', {
status: 400,
});
}
searchParams.append('code_verifier', codeVerifier);
}

const tokenResp = await fetchTokenFromUpstreamAuth(
accessTokenEndpoint,
searchParams,
Expand Down Expand Up @@ -517,6 +570,17 @@ const handleFetchEvent: FetchEventCallback<OIDCRuntimeContext> = async (request,
);
url.searchParams.append('jwt_token', jwtToken);

// Clear the PKCE verifier cookie now that the flow is complete.
if (siteInstallation.configuration.use_pkce) {
return new Response(null, {
status: 302,
headers: {
Location: url.toString(),
'Set-Cookie': clearPKCECookie(new URL(installationURL).pathname),
},
});
}

return Response.redirect(url.toString());
}
});
Expand Down Expand Up @@ -568,14 +632,76 @@ export default createIntegration({
}

const location = event.location ? event.location : '';
const redirectURI = `${installationURL}/visitor-auth/response`;

// With PKCE enabled, generate the code verifier here and store it in a first-party
// cookie. This response is served from the integration's own domain, the same domain
// as the `/visitor-auth/response` callback, so the cookie is sent back to the callback
// where the verifier is needed for the token exchange.
if (configuration.use_pkce) {
const signingSecret = environment.signingSecrets.siteInstallation;
if (!signingSecret) {
throw new ExposableError('Missing signing secret required for PKCE');
}

const codeVerifier = generatePKCECodeVerifier();
const codeChallenge = await computePKCECodeChallenge(codeVerifier);
const encryptedVerifier = await encryptPKCEVerifier(signingSecret, codeVerifier);

const url = buildAuthorizeURL({
authorizationEndpoint,
clientId,
redirectURI,
scope,
state: `oidcstate-${location}`,
codeChallenge,
});

const url = new URL(authorizationEndpoint);
url.searchParams.append('client_id', clientId);
url.searchParams.append('response_type', 'code');
url.searchParams.append('redirect_uri', `${installationURL}/visitor-auth/response`);
url.searchParams.append('scope', scope.toLowerCase());
url.searchParams.append('state', `oidcstate-${location}`);
return new Response(null, {
status: 302,
headers: {
Location: url.toString(),
'Set-Cookie': serializePKCECookie(
encryptedVerifier,
new URL(installationURL).pathname,
),
},
});
}

const url = buildAuthorizeURL({
authorizationEndpoint,
clientId,
redirectURI,
scope,
state: `oidcstate-${location}`,
});

return Response.redirect(url.toString());
},
});

/**
* Build the authorization endpoint URL for the OAuth redirect, optionally
* including the PKCE code challenge.
*/
function buildAuthorizeURL(params: {
authorizationEndpoint: string;
clientId: string;
redirectURI: string;
scope: string;
state: string;
codeChallenge?: string;
}): URL {
const url = new URL(params.authorizationEndpoint);
url.searchParams.append('client_id', params.clientId);
url.searchParams.append('response_type', 'code');
url.searchParams.append('redirect_uri', params.redirectURI);
url.searchParams.append('scope', params.scope.toLowerCase());
url.searchParams.append('state', params.state);
if (params.codeChallenge) {
url.searchParams.append('code_challenge', params.codeChallenge);
url.searchParams.append('code_challenge_method', 'S256');
}
return url;
}
161 changes: 161 additions & 0 deletions integrations/oidc/src/pkce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* PKCE (Proof Key for Code Exchange, RFC 7636) helpers for the OIDC visitor
* authentication flow.
*/

const PKCE_COOKIE_NAME = 'gitbook-oidc-pkce-verifier';

/**
* How long (in seconds) the PKCE verifier cookie is valid. The visitor only
* needs it for the brief round-trip to the authentication provider and back.
*/
const PKCE_COOKIE_MAX_AGE = 600;

/**
* Generate a cryptographically random PKCE code verifier (RFC 7636 §4.1):
* 32 random bytes encoded as a 43-character base64url string.
*/
export function generatePKCECodeVerifier(): string {
return base64url(crypto.getRandomValues(new Uint8Array(32)));
}

/**
* Compute the PKCE code challenge (S256) from a code verifier.
*/
export async function computePKCECodeChallenge(codeVerifier: string): Promise<string> {
const encoder = new TextEncoder();
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(codeVerifier));
return base64url(digest);
}

/**
* Encrypt the PKCE verifier with AES-GCM. The random IV is prepended to the
* ciphertext and the result is base64url-encoded for cookie storage.
*/
export async function encryptPKCEVerifier(
signingSecret: string,
plaintext: string,
): Promise<string> {
const key = await importPKCECookieKey(signingSecret);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plaintext),
);
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), iv.length);
return base64url(combined);
}

/**
* Decrypt a value produced by {@link encryptPKCEVerifier}. Throws if the cookie was
* tampered with (AES-GCM authentication failure) or is malformed.
*/
async function decryptPKCEVerifier(signingSecret: string, value: string): Promise<string> {
const key = await importPKCECookieKey(signingSecret);
const combined = base64urlDecode(value);
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
return new TextDecoder().decode(plaintext);
}

/**
* Serialize the Set-Cookie header carrying the encrypted PKCE verifier. The
* cookie is first-party (HttpOnly, Secure), scoped to the integration's own
* path, and uses SameSite=Lax so it is still sent on the top-level redirect
* back from the authentication provider.
*/
export function serializePKCECookie(value: string, path: string): string {
return [
`${PKCE_COOKIE_NAME}=${value}`,
`Path=${path}`,
`Max-Age=${PKCE_COOKIE_MAX_AGE}`,
'HttpOnly',
'Secure',
'SameSite=Lax',
].join('; ');
}

/**
* Serialize a Set-Cookie header that clears the PKCE verifier cookie.
*/
export function clearPKCECookie(path: string): string {
return `${PKCE_COOKIE_NAME}=; Path=${path}; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
}

/**
* Read the encrypted PKCE verifier cookie from a request Cookie header and
* decrypt it back into the code verifier. Returns `undefined` when the cookie is
* absent or cannot be decrypted (e.g. it was tampered with, or the signing
* secret rotated since the flow started).
*/
export async function getPKCECodeVerifierFromCookie(
cookieHeader: string | null,
signingSecret: string,
): Promise<string | undefined> {
const encryptedVerifier = readCookie(cookieHeader, PKCE_COOKIE_NAME);
if (!encryptedVerifier) {
return undefined;
}
try {
return await decryptPKCEVerifier(signingSecret, encryptedVerifier);
} catch {
return undefined;
}
}

/**
* Derive an AES-GCM key from the site installation signing secret, used to
* encrypt the PKCE verifier before storing it in a cookie so the value stays
* confidential and tamper-proof even if the cookie were to leak.
*/
async function importPKCECookieKey(signingSecret: string): Promise<CryptoKey> {
const keyMaterial = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(signingSecret),
);
return crypto.subtle.importKey('raw', keyMaterial, { name: 'AES-GCM' }, false, [
'encrypt',
'decrypt',
]);
}

/**
* Read a single cookie value from a request Cookie header.
*/
function readCookie(cookieHeader: string | null, name: string): string | undefined {
if (!cookieHeader) {
return undefined;
}
for (const part of cookieHeader.split(';')) {
const [key, ...rest] = part.trim().split('=');
if (key === name) {
return rest.join('=');
}
}
return undefined;
}

/**
* Encode bytes as a base64url string, the encoding
* required for PKCE code verifiers and challenges.
*/
function base64url(data: ArrayBuffer | Uint8Array): string {
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

/**
* Decode a base64url string back into bytes.
*/
function base64urlDecode(input: string): Uint8Array {
const b64 = input.replace(/-/g, '+').replace(/_/g, '/');
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
}
Loading