From 50d9d216dbdd1f30dc367d222477f9c77cfd7bb5 Mon Sep 17 00:00:00 2001 From: Steeve Pastorelli Date: Thu, 21 Nov 2024 16:53:28 +0100 Subject: [PATCH] Add support to VA Okta integration to include user claims from backend in VA token --- integrations/va-okta/package.json | 2 +- integrations/va-okta/src/index.tsx | 455 ++++++++++++++++++++--------- 2 files changed, 311 insertions(+), 146 deletions(-) diff --git a/integrations/va-okta/package.json b/integrations/va-okta/package.json index d5c342356..2ba705f18 100644 --- a/integrations/va-okta/package.json +++ b/integrations/va-okta/package.json @@ -6,7 +6,7 @@ "@gitbook/api": "*", "@gitbook/runtime": "*", "itty-router": "^4.0.14", - "@tsndr/cloudflare-worker-jwt": "2.3.2" + "@tsndr/cloudflare-worker-jwt": "3.1.3" }, "devDependencies": { "@gitbook/cli": "workspace:*", diff --git a/integrations/va-okta/src/index.tsx b/integrations/va-okta/src/index.tsx index 5928c12cb..0da5d4c6f 100644 --- a/integrations/va-okta/src/index.tsx +++ b/integrations/va-okta/src/index.tsx @@ -1,4 +1,4 @@ -import { sign } from '@tsndr/cloudflare-worker-jwt'; +import * as jwt from '@tsndr/cloudflare-worker-jwt'; import { Router } from 'itty-router'; import { IntegrationInstallationConfiguration } from '@gitbook/api'; @@ -18,13 +18,25 @@ type OktaRuntimeEnvironment = RuntimeEnvironment<{}, OktaSiteInstallationConfigu type OktaRuntimeContext = RuntimeContext; -type OktaSiteInstallationConfiguration = { +type OktaSiteInstallationBaseConfiguration = { client_id?: string; okta_domain?: string; client_secret?: string; + include_claims_in_va_token?: boolean; }; -type OktaState = OktaSiteInstallationConfiguration; +type OktaSiteInstallationConfiguration = OktaSiteInstallationBaseConfiguration & { + okta_custom_auth_server?: OktaCustomAuthServerConfiguration; +}; + +type OktaCustomAuthServerConfiguration = { id: string } & Pick< + OktaCustomAuthServerDiscoveryData, + 'authorization_endpoint' | 'token_endpoint' +>; + +type OktaState = OktaSiteInstallationBaseConfiguration & { + okta_custom_auth_server_id?: string; +}; type OktaProps = { installation: { @@ -35,7 +47,35 @@ type OktaProps = { }; }; -export type OktaAction = { action: 'save.config' }; +type OktaTokenResponseData = { + access_token?: string; + refresh_token?: string; + token_type: 'Bearer'; + expires_in: number; +}; + +type OktaTokenResponseError = { + error: string; + error_description: string; +}; + +type OktaCustomAuthServerDiscoveryData = { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + registration_endpoint: string; + jwks_uri: string; +}; + +const EXCLUDED_CLAIMS = ['iat', 'exp', 'iss', 'aud', 'jti', 'ver']; + +export type OktaAction = + | { action: 'save.config' } + | { + action: 'toggle.include_claims_in_va_token'; + includeClaimsInVAToken: boolean; + }; const configBlock = createComponent({ componentId: 'config', @@ -45,19 +85,65 @@ const configBlock = createComponent { switch (action.action) { + case 'toggle.include_claims_in_va_token': + return { + ...element, + state: { + ...element.state, + include_claims_in_va_token: action.includeClaimsInVAToken, + }, + }; case 'save.config': const { api, environment } = context; const siteInstallation = assertSiteInstallation(environment); - const configurationBody = { + let oktaCustomServerInfo: OktaCustomAuthServerConfiguration | undefined; + + // When using a custom auth server fetch the OAuth endpoints from the discovery URL. + if ( + element.state.include_claims_in_va_token && + element.state.okta_custom_auth_server_id + ) { + const customAuthServerDiscoveryURL = new URL( + `oauth2/${element.state.okta_custom_auth_server_id}/.well-known/openid-configuration`, + `https://${element.state.okta_domain}/`, + ); + const discoveryResp = await fetch(customAuthServerDiscoveryURL, { + method: 'GET', + }); + + if (!discoveryResp.ok) { + throw new ExposableError( + 'Error: The Okta custom auth server ID provided is invalid', + ); + } + const { authorization_endpoint, token_endpoint } = + await discoveryResp.json(); + + oktaCustomServerInfo = { + id: element.state.okta_custom_auth_server_id, + authorization_endpoint, + token_endpoint, + }; + } + + const configurationBody: OktaSiteInstallationConfiguration = { ...siteInstallation.configuration, client_id: element.state.client_id, client_secret: element.state.client_secret, okta_domain: element.state.okta_domain, + include_claims_in_va_token: element.state.include_claims_in_va_token, + okta_custom_auth_server: element.state.include_claims_in_va_token + ? oktaCustomServerInfo + : undefined, }; await api.integrations.updateIntegrationSiteInstallation( siteInstallation.integration, @@ -77,83 +163,124 @@ const configBlock = createComponent - - The Client ID of your Okta application. - - {' '} - More Details - - - } - element={} - /> - - - The Domain of your Okta instance. - - {' '} - More Details - - - } - element={} - /> - - - The Client Secret of your Okta application. - + + + + + The Domain of your Okta instance. + + {' '} + More Details + + + } + element={} + /> + + + The Client ID of your Okta application. + + {' '} + More Details + + + } + element={} + /> + + + The Client Secret of your Okta application. + + {' '} + More Details + + + } + element={ + + } + /> + + + Add this URL to the{' '} + Sign-In Redirect URIs section in your + application's settings in Okta. + + } + element={} + /> + + + + + + - {' '} - More Details - - - } - element={} - /> - - - - The following URL needs to be saved as a Sign-In Redirect URI in Okta: - - - - + } + /> + {element.state.include_claims_in_va_token ? ( + + } /> - } - /> - + ) : null} + + + } + /> + + ); }, }); @@ -202,79 +329,98 @@ const handleFetchEvent: FetchEventCallback = async (request, router.get('/visitor-auth/response', async (request) => { if ('site' in siteInstallation && siteInstallation.site) { const publishedContentUrls = await getPublishedContentUrls(context); - const privateKey = context.environment.signingSecrets.siteInstallation!; - let token; - try { - token = await sign( - { exp: Math.floor(Date.now() / 1000) + 1 * (60 * 60) }, - privateKey, - ); - } catch (e) { - return new Response('Error: Could not sign JWT token', { - status: 500, - }); - } const oktaDomain = siteInstallation.configuration.okta_domain; const clientId = siteInstallation.configuration.client_id; const clientSecret = siteInstallation.configuration.client_secret; + const oktaCustomAuthServerConfig = + siteInstallation.configuration.okta_custom_auth_server; + const includeClaimsInVAToken = + siteInstallation.configuration.include_claims_in_va_token; + + if (!clientId || !clientSecret || !oktaDomain) { + return new Response( + 'Error: Either client id, client secret or okta domain is missing', + { + status: 400, + }, + ); + } + + const searchParams = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientId, + client_secret: clientSecret, + code: `${request.query.code}`, + redirect_uri: `${installationURL}/visitor-auth/response`, + }); + const accessTokenURL = + includeClaimsInVAToken && oktaCustomAuthServerConfig + ? oktaCustomAuthServerConfig.token_endpoint + : `https://${oktaDomain}/oauth2/v1/token/`; + const oktaTokenResp = await fetch(accessTokenURL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: searchParams, + }); + + if (!oktaTokenResp.ok) { + const errorResponse = await oktaTokenResp.json(); + logger.debug(JSON.stringify(errorResponse, null, 2)); + logger.debug( + `Did not receive access token. Error: ${ + (errorResponse && errorResponse.error) || '' + } ${(errorResponse && errorResponse.error_description) || ''}`, + ); + return new Response('Error: Could not fetch token from Okta', { + status: 401, + }); + } - if (clientId && clientSecret) { - const searchParams = new URLSearchParams({ - grant_type: 'authorization_code', - client_id: clientId, - client_secret: clientSecret, - code: `${request.query.code}`, - scope: 'openid', - redirect_uri: `${installationURL}/visitor-auth/response`, + const oktaTokenData = await oktaTokenResp.json(); + if (!oktaTokenData.access_token) { + return new Response('Error: No Access Token found in response from Okta', { + status: 401, }); - const accessTokenURL = `https://${oktaDomain}/oauth2/v1/token/`; - const resp: any = await fetch(accessTokenURL, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: searchParams, - }) - .then((response) => response.json()) - .catch((err) => { - return new Response('Error: Could not fetch access token from Okta', { - status: 401, - }); + } + + // Okta already include user/custom claims in the access token so we can just decode it + // TODO: verify token using JWKS and check audience (aud) claims + const decodedOktaToken = await jwt.decode(oktaTokenData.access_token); + try { + const privateKey = context.environment.signingSecrets.siteInstallation; + if (!privateKey) { + return new Response('Error: Missing private key from site installation', { + status: 400, }); - if ('access_token' in resp) { - let url; - const state = request.query.state!.toString(); - const location = state.substring(state.indexOf('-') + 1); - if (location) { - url = new URL(`${publishedContentUrls?.published}${location}`); - url.searchParams.append('jwt_token', token); - } else { - url = new URL(publishedContentUrls?.published!); - url.searchParams.append('jwt_token', token); - } - if (token && publishedContentUrls?.published) { - return Response.redirect(url.toString()); - } else { - return new Response( - "Error: Either JWT token or space's published URL is missing", - { - status: 500, - }, - ); - } - } else { - logger.debug(JSON.stringify(resp, null, 2)); - logger.debug( - `Did not receive access token. Error: ${(resp && resp.error) || ''} ${ - (resp && resp.error_description) || '' - }`, + } + const jwtToken = await jwt.sign( + { + ...sanitizeJWTTokenClaims(decodedOktaToken.payload || {}), + exp: Math.floor(Date.now() / 1000) + 1 * (60 * 60), + }, + privateKey, + ); + + const publishedContentUrl = publishedContentUrls?.published; + if (!publishedContentUrl || !jwtToken) { + return new Response( + "Error: Either JWT token or site's published URL is missing", + { + status: 500, + }, ); - return new Response('Error: No Access Token found in response from Okta', { - status: 401, - }); } - } else { - return new Response('Error: Either ClientId or Client Secret is missing', { - status: 400, + + const state = request.query.state?.toString(); + const location = state ? state.substring(state.indexOf('-') + 1) : ''; + const url = new URL(`${publishedContentUrl}${location || ''}`); + url.searchParams.append('jwt_token', jwtToken); + + return Response.redirect(url.toString()); + } catch (e) { + return new Response('Error: Could not sign JWT token', { + status: 500, }); } } @@ -318,7 +464,14 @@ export default createIntegration({ throw new ExposableError('OIDC configuration is missing'); } - const url = new URL(`https://${oktaDomain}/oauth2/v1/authorize`); + const oktaCustomAuthServerConfig = siteInstallation.configuration.okta_custom_auth_server; + const includeClaimsInVAToken = siteInstallation.configuration.include_claims_in_va_token; + + const url = new URL( + includeClaimsInVAToken && oktaCustomAuthServerConfig + ? oktaCustomAuthServerConfig.authorization_endpoint + : `https://${oktaDomain}/oauth2/v1/authorize`, + ); url.searchParams.append('client_id', clientId); url.searchParams.append('response_type', 'code'); url.searchParams.append('redirect_uri', `${installationURL}/visitor-auth/response`); @@ -329,3 +482,15 @@ export default createIntegration({ return Response.redirect(url.toString()); }, }); + +function sanitizeJWTTokenClaims(claims: jwt.JwtPayload) { + const result: Record = {}; + + Object.entries(claims).forEach(([key, value]) => { + if (EXCLUDED_CLAIMS.includes(key)) { + return; + } + result[key] = value; + }); + return result; +}