diff --git a/integrations/va-auth0/.eslintrc.json b/integrations/va-auth0/.eslintrc.json new file mode 100644 index 000000000..2486b4b2d --- /dev/null +++ b/integrations/va-auth0/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["@gitbook/eslint-config/integration"] +} diff --git a/integrations/va-auth0/assets/auth0-preview.png b/integrations/va-auth0/assets/auth0-preview.png new file mode 100644 index 000000000..74fcaee10 Binary files /dev/null and b/integrations/va-auth0/assets/auth0-preview.png differ diff --git a/integrations/va-auth0/assets/icon.png b/integrations/va-auth0/assets/icon.png new file mode 100644 index 000000000..9efca3b96 Binary files /dev/null and b/integrations/va-auth0/assets/icon.png differ diff --git a/integrations/va-auth0/gitbook-manifest.yaml b/integrations/va-auth0/gitbook-manifest.yaml new file mode 100644 index 000000000..dd9cc1b8d --- /dev/null +++ b/integrations/va-auth0/gitbook-manifest.yaml @@ -0,0 +1,30 @@ +name: VA-Auth0 +title: VA Auth0 +icon: ./assets/icon.png +previewImages: + - ./assets/auth0-preview.png +description: Visitor Authentication with Auth0 +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 Auth0. + + # Configure + Install this integration on a space and then populate the configuration screen with the details of your Auth0 application and Auth0 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 Auth0. + +categories: + - other +configurations: + space: + componentId: config diff --git a/integrations/va-auth0/package.json b/integrations/va-auth0/package.json new file mode 100644 index 000000000..6e2ff91d0 --- /dev/null +++ b/integrations/va-auth0/package.json @@ -0,0 +1,20 @@ +{ + "name": "@gitbook/integration-va-auth0", + "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-auth0/src/index.tsx b/integrations/va-auth0/src/index.tsx new file mode 100644 index 000000000..700477dbe --- /dev/null +++ b/integrations/va-auth0/src/index.tsx @@ -0,0 +1,284 @@ +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('auth0.visitor-auth'); + +type Auth0RuntimeEnvironment = RuntimeEnvironment<{}, Auth0SpaceInstallationConfiguration>; + +type Auth0RuntimeContext = RuntimeContext; + +type Auth0SpaceInstallationConfiguration = { + client_id?: string; + issuer_base_url?: string; + client_secret?: string; +}; + +type Auth0State = Auth0SpaceInstallationConfiguration; + +type Auth0Props = { + installation: { + configuration?: IntegrationInstallationConfiguration; + }; + spaceInstallation: { + configuration?: Auth0SpaceInstallationConfiguration; + }; +}; + +export type Auth0Action = { action: 'save.config' }; + +const configBlock = createComponent({ + componentId: 'config', + initialState: (props) => { + return { + client_id: props.spaceInstallation.configuration?.client_id?.toString() || '', + issuer_base_url: + props.spaceInstallation.configuration?.issuer_base_url?.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, + issuer_base_url: element.state.issuer_base_url, + }; + 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 unique identifier of your Auth0 application. + + {' '} + More Details + + + } + element={} + /> + + + The Auth0 domain (also known as tenant). + + {' '} + More Details + + + } + element={} + /> + + + The secret used for signing and validating tokens. + + {' '} + More Details + + + } + element={} + /> + + + } + /> + + + The following URL needs to be saved as an allowed callback URL in Auth0: + + + + ); + }, +}); + +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 issuerBaseUrl = environment.spaceInstallation?.configuration.issuer_base_url; + 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}`, + redirect_uri: `${installationURL}/visitor-auth/response`, + }); + const accessTokenURL = `${issuerBaseUrl}/oauth/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 Auth0', { + status: 401, + }); + }); + + if ('access_token' in resp) { + let url; + if (request.query.state) { + url = new URL(`${spaceData.urls?.published}${request.query.state}`); + url.searchParams.append('jwt_token', token); + } else { + url = new URL(spaceData.urls?.published); + url.searchParams.append('jwt_token', token); + } + if (spaceData.urls?.published && token) { + 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 Auth0', { + 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 issuerBaseUrl = environment.spaceInstallation?.configuration.issuer_base_url; + const clientId = environment.spaceInstallation?.configuration.client_id; + const location = event.location ? event.location : ''; + + const url = new URL(`${issuerBaseUrl}/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('state', location); + + try { + return Response.redirect(url.toString()); + } catch (e) { + return new Response(e.message, { + status: e.status || 500, + }); + } + }, +}); diff --git a/integrations/va-auth0/tsconfig.json b/integrations/va-auth0/tsconfig.json new file mode 100644 index 000000000..1a48f875b --- /dev/null +++ b/integrations/va-auth0/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@gitbook/tsconfig/integration.json" +} diff --git a/package-lock.json b/package-lock.json index a871ffcab..443e1d8c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -792,6 +792,18 @@ "@gitbook/cli": "*" } }, + "integrations/va-auth0": { + "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-azure": { "version": "0.0.1", "dependencies": { @@ -3220,6 +3232,10 @@ "resolved": "integrations/toucantoco", "link": true }, + "node_modules/@gitbook/integration-va-auth0": { + "resolved": "integrations/va-auth0", + "link": true + }, "node_modules/@gitbook/integration-va-azure": { "resolved": "integrations/va-azure", "link": true