diff --git a/integrations/va-okta/.eslintrc.json b/integrations/va-okta/.eslintrc.json new file mode 100644 index 000000000..2486b4b2d --- /dev/null +++ b/integrations/va-okta/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["@gitbook/eslint-config/integration"] +} diff --git a/integrations/va-okta/assets/icon.png b/integrations/va-okta/assets/icon.png new file mode 100644 index 000000000..5f99f3b57 Binary files /dev/null and b/integrations/va-okta/assets/icon.png differ diff --git a/integrations/va-okta/assets/preview.png b/integrations/va-okta/assets/preview.png new file mode 100644 index 000000000..7f207b4bd Binary files /dev/null and b/integrations/va-okta/assets/preview.png differ diff --git a/integrations/va-okta/gitbook-manifest.yaml b/integrations/va-okta/gitbook-manifest.yaml new file mode 100644 index 000000000..5094a8b44 --- /dev/null +++ b/integrations/va-okta/gitbook-manifest.yaml @@ -0,0 +1,29 @@ +name: VA-Okta +title: VA Okta +icon: ./assets/icon.png +previewImages: + - ./assets/preview.png +description: Visitor Authentication with Okta +visibility: public +script: ./src/index.tsx +scopes: + - space:metadata:read + - space:visitor:auth + - space:content:read +organization: d8f63b60-89ae-11e7-8574-5927d48c4877 +summary: | + # Overview + Visitor Authentication allows you to publish content behind an authentication wall, so your content is only accessible to people you choose. + + This integration lets you control access to your published content as determined by Okta. + + # Configure + Install this integration on a space and then populate the configuration screen with the details of your Okta application and Okta instance. + You can then open the Share menu, publish the space with Visitor Authentication, choose this integration as the authentication backend, and hit Save. + + Your space is now published with Visitor Authentication using Okta. +categories: + - other +configurations: + space: + componentId: config diff --git a/integrations/va-okta/package.json b/integrations/va-okta/package.json new file mode 100644 index 000000000..a0eadbbf2 --- /dev/null +++ b/integrations/va-okta/package.json @@ -0,0 +1,20 @@ +{ + "name": "@gitbook/integration-va-okta", + "version": "0.0.1", + "private": true, + "dependencies": { + "@gitbook/api": "*", + "@gitbook/runtime": "*", + "itty-router": "^4.0.14", + "@tsndr/cloudflare-worker-jwt": "2.3.2" + }, + "devDependencies": { + "@gitbook/cli": "*" + }, + "scripts": { + "lint": "eslint ./**/*.ts*", + "typecheck": "tsc --noEmit", + "publish-integrations-staging": "gitbook publish .", + "publish-integrations": "gitbook publish ." + } +} diff --git a/integrations/va-okta/src/index.tsx b/integrations/va-okta/src/index.tsx new file mode 100644 index 000000000..d6366efa1 --- /dev/null +++ b/integrations/va-okta/src/index.tsx @@ -0,0 +1,286 @@ +import { sign } from '@tsndr/cloudflare-worker-jwt'; +import { Router } from 'itty-router'; + +import { IntegrationInstallationConfiguration } from '@gitbook/api'; +import { + createIntegration, + FetchEventCallback, + Logger, + RuntimeContext, + RuntimeEnvironment, + createComponent, +} from '@gitbook/runtime'; + +const logger = Logger('okta.visitor-auth'); + +type OktaRuntimeEnvironment = RuntimeEnvironment<{}, OktaSpaceInstallationConfiguration>; + +type OktaRuntimeContext = RuntimeContext; + +type OktaSpaceInstallationConfiguration = { + client_id?: string; + okta_domain?: string; + client_secret?: string; +}; + +type OktaState = OktaSpaceInstallationConfiguration; + +type OktaProps = { + installation: { + configuration?: IntegrationInstallationConfiguration; + }; + spaceInstallation: { + configuration?: OktaSpaceInstallationConfiguration; + }; +}; + +export type OktaAction = { action: 'save.config' }; + +const configBlock = createComponent({ + componentId: 'config', + initialState: (props) => { + return { + client_id: props.spaceInstallation.configuration?.client_id?.toString() || '', + okta_domain: props.spaceInstallation.configuration?.okta_domain?.toString() || '', + client_secret: props.spaceInstallation.configuration?.client_secret?.toString() || '', + }; + }, + action: async (element, action, context) => { + switch (action.action) { + case 'save.config': + const { api, environment } = context; + const spaceInstallation = environment.spaceInstallation; + + const configurationBody = { + ...spaceInstallation.configuration, + client_id: element.state.client_id, + client_secret: element.state.client_secret, + okta_domain: element.state.okta_domain, + }; + await api.integrations.updateIntegrationSpaceInstallation( + spaceInstallation.integration, + spaceInstallation.installation, + spaceInstallation.space, + { + configuration: { + ...configurationBody, + }, + } + ); + return element; + } + }, + render: async (element, context) => { + const VACallbackURL = `${context.environment.spaceInstallation?.urls?.publicEndpoint}/visitor-auth/response`; + return ( + + + 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. + + {' '} + More Details + + + } + element={} + /> + + + } + /> + + The following URL needs to be saved as a Sign-In Redirect URI in Okta: + + + ); + }, +}); + +const handleFetchEvent: FetchEventCallback = async (request, context) => { + const { environment } = context; + const installationURL = environment.spaceInstallation?.urls?.publicEndpoint; + if (installationURL) { + const router = Router({ + base: new URL(installationURL).pathname, + }); + + router.get('/visitor-auth/response', async (request) => { + if (context.environment.spaceInstallation?.space) { + const space = await context.api.spaces.getSpaceById( + context.environment.spaceInstallation?.space + ); + const spaceData = space.data; + const privateKey = context.environment.signingSecrets.spaceInstallation; + 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 = environment.spaceInstallation?.configuration.okta_domain; + const clientId = environment.spaceInstallation?.configuration.client_id; + const clientSecret = environment.spaceInstallation?.configuration.client_secret; + + 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 accessTokenURL = `https://${oktaDomain}/oauth2/default/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, + }); + }); + 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(`${spaceData.urls?.published}${location}`); + url.searchParams.append('jwt_token', token); + } else { + url = new URL(spaceData.urls?.published); + url.searchParams.append('jwt_token', token); + } + if (token && spaceData.urls?.published) { + return Response.redirect(url.toString()); + } else { + return new Response( + "Error: Either JWT token or space's published URL is missing", + { + status: 500, + } + ); + } + } else { + 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, + }); + } + } + }); + + let response; + try { + response = await router.handle(request, context); + } catch (error: any) { + logger.error('error handling request', error); + return new Response(error.message, { + status: error.status || 500, + }); + } + + if (!response) { + return new Response(`No route matching ${request.method} ${request.url}`, { + status: 404, + }); + } + + return response; + } +}; + +export default createIntegration({ + fetch: handleFetchEvent, + components: [configBlock], + fetch_visitor_authentication: async (event, context) => { + const { environment } = context; + const installationURL = environment.spaceInstallation?.urls?.publicEndpoint; + const oktaDomain = environment.spaceInstallation?.configuration.okta_domain; + const clientId = environment.spaceInstallation?.configuration.client_id; + const location = event.location ? event.location : ''; + + const url = new URL(`https://${oktaDomain}/oauth2/default/v1/authorize`); + url.searchParams.append('client_id', clientId); + url.searchParams.append('response_type', 'code'); + url.searchParams.append('redirect_uri', `${installationURL}/visitor-auth/response`); + url.searchParams.append('response_mode', 'query'); + url.searchParams.append('scope', 'openid'); + url.searchParams.append('state', `state-${location}`); + + try { + return Response.redirect(url.toString()); + } catch (e) { + return new Response(e.message, { + status: e.status || 500, + }); + } + }, +}); diff --git a/integrations/va-okta/tsconfig.json b/integrations/va-okta/tsconfig.json new file mode 100644 index 000000000..1a48f875b --- /dev/null +++ b/integrations/va-okta/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@gitbook/tsconfig/integration.json" +} diff --git a/package-lock.json b/package-lock.json index 443e1d8c5..2ca15b738 100644 --- a/package-lock.json +++ b/package-lock.json @@ -792,6 +792,18 @@ "@gitbook/cli": "*" } }, + "integrations/va-okta": { + "version": "0.0.1", + "dependencies": { + "@gitbook/api": "*", + "@gitbook/runtime": "*", + "@tsndr/cloudflare-worker-jwt": "2.3.2", + "itty-router": "^4.0.14" + }, + "devDependencies": { + "@gitbook/cli": "*" + } + }, "integrations/va-auth0": { "version": "0.0.1", "dependencies": { @@ -3232,6 +3244,10 @@ "resolved": "integrations/toucantoco", "link": true }, + "node_modules/@gitbook/integration-va-okta": { + "resolved": "integrations/va-okta", + "link": true + }, "node_modules/@gitbook/integration-va-auth0": { "resolved": "integrations/va-auth0", "link": true