diff --git a/.changeset/oidc-pkce-support.md b/.changeset/oidc-pkce-support.md new file mode 100644 index 000000000..4b74f6e8b --- /dev/null +++ b/.changeset/oidc-pkce-support.md @@ -0,0 +1,5 @@ +--- +'@gitbook/integration-oidc': minor +--- + +Add support for PKCE to the OIDC visitor authentication flow. diff --git a/integrations/oidc/src/index.tsx b/integrations/oidc/src/index.tsx index 0bf35db21..f23ad435a 100644 --- a/integrations/oidc/src/index.tsx +++ b/integrations/oidc/src/index.tsx @@ -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>; @@ -25,6 +34,7 @@ type OIDCSiteInstallationConfiguration = { access_token_endpoint?: string; client_secret?: string; scope?: string; + use_pkce?: boolean; }; type OIDCState = OIDCSiteInstallationConfiguration; @@ -71,6 +81,7 @@ const configBlock = createComponent { @@ -91,6 +102,7 @@ const configBlock = createComponent} /> + + + Enable Proof Key for Code Exchange (PKCE) for the authorization code + flow. Turn this on if your authentication provider requires or + recommends PKCE. + + {' '} + More Details + + + } + element={} + /> @@ -428,6 +460,27 @@ const handleFetchEvent: FetchEventCallback = 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, @@ -517,6 +570,17 @@ const handleFetchEvent: FetchEventCallback = 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()); } }); @@ -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; +} diff --git a/integrations/oidc/src/pkce.ts b/integrations/oidc/src/pkce.ts new file mode 100644 index 000000000..d9f80893a --- /dev/null +++ b/integrations/oidc/src/pkce.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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)); +}