Skip to content

Commit a295590

Browse files
authored
Add support to generic OIDC integration to pull user/custom claims from auth token (#905)
* Add support to OIDC integration to retrieve user/custom claims from auth token * Add changeset * Make location parsing a bit safer
1 parent 94006ca commit a295590

File tree

2 files changed

+95
-70
lines changed

2 files changed

+95
-70
lines changed

.changeset/spotty-hoops-flash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@gitbook/integration-oidc': patch
3+
---
4+
5+
Add support to OIDC integration to pull user/custom claims from auth token

integrations/oidc/src/index.tsx

Lines changed: 90 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { sign } from '@tsndr/cloudflare-worker-jwt';
1+
import * as jwt from '@tsndr/cloudflare-worker-jwt';
22
import { Router } from 'itty-router';
33

44
import { IntegrationInstallationConfiguration } from '@gitbook/api';
@@ -39,6 +39,14 @@ type OIDCProps = {
3939

4040
export type OIDCAction = { action: 'save.config' };
4141

42+
type OIDCTokenResponseData = {
43+
access_token?: string;
44+
id_token?: string;
45+
refresh_token?: string;
46+
token_type: 'Bearer';
47+
expires_in: number;
48+
};
49+
4250
const getDomainWithHttps = (url: string): string => {
4351
const sanitizedURL = url.trim();
4452
if (sanitizedURL.startsWith('https://')) {
@@ -271,11 +279,76 @@ const handleFetchEvent: FetchEventCallback<OIDCRuntimeContext> = async (request,
271279
router.get('/visitor-auth/response', async (request) => {
272280
if ('site' in siteInstallation && siteInstallation.site) {
273281
const publishedContentUrls = await getPublishedContentUrls(context);
274-
const privateKey = context.environment.signingSecrets.siteInstallation!;
275-
let token;
282+
283+
const accessTokenEndpoint = siteInstallation.configuration.access_token_endpoint;
284+
const clientId = siteInstallation.configuration.client_id;
285+
const clientSecret = siteInstallation.configuration.client_secret;
286+
287+
if (!clientId || !clientSecret || !accessTokenEndpoint) {
288+
return new Response(
289+
'Error: Either client id, client secret or access token endpoint is missing in configuration',
290+
{
291+
status: 400,
292+
},
293+
);
294+
}
295+
const searchParams = new URLSearchParams({
296+
grant_type: 'authorization_code',
297+
client_id: clientId,
298+
client_secret: clientSecret,
299+
code: `${request.query.code}`,
300+
redirect_uri: `${installationURL}/visitor-auth/response`,
301+
});
302+
303+
const accessTokenResp = await fetch(accessTokenEndpoint, {
304+
method: 'POST',
305+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
306+
body: searchParams,
307+
});
308+
309+
if (!accessTokenResp.ok) {
310+
return new Response(
311+
'Error: Could not fetch access token from your authentication provider',
312+
{
313+
status: 401,
314+
},
315+
);
316+
}
317+
318+
const accessTokenData = await accessTokenResp.json<OIDCTokenResponseData>();
319+
if (!accessTokenData.access_token) {
320+
logger.debug(JSON.stringify(accessTokenResp, null, 2));
321+
logger.debug(
322+
`Did not receive access token. Error: ${accessTokenResp && 'error' in accessTokenResp ? accessTokenResp.error : ''} ${
323+
accessTokenResp && 'error_description' in accessTokenResp
324+
? accessTokenResp.error_description
325+
: ''
326+
}`,
327+
);
328+
return new Response(
329+
'Error: No access token found in response from your authentication provider',
330+
{
331+
status: 401,
332+
},
333+
);
334+
}
335+
336+
// TODO: verify token using JWKS and check audience (aud) claims
337+
const decodedAccessToken = await jwt.decode(accessTokenData.access_token);
338+
const privateKey = context.environment.signingSecrets.siteInstallation;
339+
if (!privateKey) {
340+
return new Response('Error: Missing private key from site installation', {
341+
status: 400,
342+
});
343+
}
344+
345+
let jwtToken: string | undefined;
276346
try {
277-
token = await sign(
278-
{ exp: Math.floor(Date.now() / 1000) + 1 * (60 * 60) },
347+
jwtToken = await jwt.sign(
348+
{
349+
...(decodedAccessToken.payload ?? {}),
350+
exp: Math.floor(Date.now() / 1000) + 1 * (60 * 60),
351+
},
279352
privateKey,
280353
);
281354
} catch (e) {
@@ -284,76 +357,23 @@ const handleFetchEvent: FetchEventCallback<OIDCRuntimeContext> = async (request,
284357
});
285358
}
286359

287-
const accessTokenEndpoint = siteInstallation.configuration.access_token_endpoint;
288-
const clientId = siteInstallation.configuration.client_id;
289-
const clientSecret = siteInstallation.configuration.client_secret;
290-
if (clientId && clientSecret && accessTokenEndpoint) {
291-
const searchParams = new URLSearchParams({
292-
grant_type: 'authorization_code',
293-
client_id: clientId,
294-
client_secret: clientSecret,
295-
code: `${request.query.code}`,
296-
redirect_uri: `${installationURL}/visitor-auth/response`,
297-
});
298-
299-
const resp: any = await fetch(accessTokenEndpoint, {
300-
method: 'POST',
301-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
302-
body: searchParams,
303-
})
304-
.then((response) => response.json())
305-
.catch((err) => {
306-
return new Response(
307-
'Error: Could not fetch access token from your authentication provider',
308-
{
309-
status: 401,
310-
},
311-
);
312-
});
313-
314-
if ('access_token' in resp) {
315-
let url;
316-
const state = request.query.state!.toString();
317-
const location = state.substring(state.indexOf('-') + 1);
318-
if (location) {
319-
url = new URL(`${publishedContentUrls?.published}${location}`);
320-
url.searchParams.append('jwt_token', token);
321-
} else {
322-
url = new URL(publishedContentUrls?.published!);
323-
url.searchParams.append('jwt_token', token);
324-
}
325-
if (publishedContentUrls?.published && token) {
326-
return Response.redirect(url.toString());
327-
} else {
328-
return new Response(
329-
"Error: Either JWT token or space's published URL is missing",
330-
{
331-
status: 500,
332-
},
333-
);
334-
}
335-
} else {
336-
logger.debug(JSON.stringify(resp, null, 2));
337-
logger.debug(
338-
`Did not receive access token. Error: ${(resp && resp.error) || ''} ${
339-
(resp && resp.error_description) || ''
340-
}`,
341-
);
342-
return new Response(
343-
'Error: No Access Token found in response from your OIDC provider',
344-
{
345-
status: 401,
346-
},
347-
);
348-
}
349-
} else {
360+
const publishedContentUrl = publishedContentUrls?.published;
361+
if (!publishedContentUrl || !jwtToken) {
350362
return new Response(
351-
'Error: Either ClientId or Client Secret or Access Token Endpoint is missing',
363+
"Error: Either JWT token or site's published URL is missing",
352364
{
353-
status: 400,
365+
status: 500,
354366
},
355367
);
356368
}
369+
370+
const state = request.query.state?.toString();
371+
const location = state ? state.substring(state.indexOf('-') + 1) : '';
372+
373+
const url = new URL(`${publishedContentUrl}${location || ''}`);
374+
url.searchParams.append('jwt_token', jwtToken);
375+
376+
return Response.redirect(url.toString());
357377
}
358378
});
359379

0 commit comments

Comments
 (0)