@@ -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+
1625const logger = Logger ( 'oidc.visitor-auth' ) ;
1726
1827type 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
3040type 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+ }
0 commit comments