-
Notifications
You must be signed in to change notification settings - Fork 0
feat: decode user data from JWT access token and add impersonated property #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,9 +2,9 @@ import { http, HttpResponse } from 'msw' | |
| import { setupServer } from 'msw/node' | ||
|
|
||
| import { | ||
| type FronteggDecodedToken, | ||
| FronteggOAuthClient, | ||
| type GetFronteggTokenResponse, | ||
| type GetFronteggUserDataResponse, | ||
| } from './frontegg-oauth-client' | ||
|
|
||
| const EmptyResponse = (code: number) => new HttpResponse(null, { status: code }) | ||
|
|
@@ -19,27 +19,41 @@ const clientConfig = { | |
|
|
||
| const FRONTEGG_RESPONSE = { | ||
| token_type: 'Bearer', | ||
| access_token: 'test-access-token', | ||
| access_token: | ||
| 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXItaWQiLCJlbWFpbCI6InRlc3RAbG9rYWxpc2UuY29tIiwibmFtZSI6ImR1bW15IHVzZXJuYW1lIiwicHJvZmlsZVBpY3R1cmVVcmwiOiJodHRwczovL3d3dy5ncmF2YXRhci5jb20vYXZhdGFyLzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwidGVuYW50SWQiOiJ0ZXN0LXRlbmFudC1pZCJ9.dxFESK7KleQdEz4hBmd-pKMSKUN0uYJ44ycd-SQeAYBGfJcQQPCsjOWBDSlxGUodLmalhMMVDTvmN4G4La5lfOakas4kJzrfVAXfV_-ZYAiOHZaqS_OTMZaTPAcjWZfnNNEnewuNhZSiuzqEbaIpKOX4tmZOHH1ganJT2Z-gvRiArVC1zEZdZPFt0MVGl9Tt3Kmcgvf3j22j1FWI5AqVsiYFHolISaWveZyIR62qtF3pyGLRW-4qwoujV393Kf52kNWez0P7Ed70-yrVJX_D0buJ1aW-bPXSh1F0ifnGBvYKtoUqSLZ1e0InA3rTccWt5DIyOUULaE0asgJxB61Nqg', | ||
| id_token: 'test-id-token', | ||
| refresh_token: 'test-refresh-token', | ||
| expires_in: 3600, | ||
| } satisfies GetFronteggTokenResponse | ||
|
|
||
| const FRONTEGG_IMPERSONATED_RESPONSE = { | ||
| ...FRONTEGG_RESPONSE, | ||
| access_token: | ||
| 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXItaWQiLCJlbWFpbCI6InRlc3RAbG9rYWxpc2UuY29tIiwibmFtZSI6ImR1bW15IHVzZXJuYW1lIiwicHJvZmlsZVBpY3R1cmVVcmwiOiJodHRwczovL3d3dy5ncmF2YXRhci5jb20vYXZhdGFyLzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwidGVuYW50SWQiOiJ0ZXN0LXRlbmFudC1pZCIsImFjdCI6eyJzdWIiOiJ0ZXN0LWFkbWluLXVzZXIiLCJ0eXBlIjoiaW1wZXJzb25hdGlvbiJ9fQ.lamGCm4sfTCsfyZ11-rnecqqJcAKua2IiCMQxHr5kQw', | ||
| } | ||
|
|
||
| const FRONTEGG_USER_DATA = { | ||
| id: 'test-user-id', | ||
| sub: 'test-user-id', | ||
| email: '[email protected]', | ||
| name: 'dummy username', | ||
| profilePictureUrl: 'https://www.gravatar.com/avatar/00000000000000000000000000000000', | ||
| tenantId: 'test-tenant-id', | ||
| } satisfies GetFronteggUserDataResponse | ||
| } satisfies FronteggDecodedToken | ||
|
|
||
| const USER_DATA = { | ||
| externalUserId: FRONTEGG_USER_DATA.id, | ||
| externalUserId: FRONTEGG_USER_DATA.sub, | ||
| accessToken: FRONTEGG_RESPONSE.access_token, | ||
| email: FRONTEGG_USER_DATA.email, | ||
| name: FRONTEGG_USER_DATA.name, | ||
| profilePictureUrl: FRONTEGG_USER_DATA.profilePictureUrl, | ||
| externalWorkspaceId: FRONTEGG_USER_DATA.tenantId, | ||
| isImpersonated: false, | ||
| } | ||
|
|
||
| const IMPERSONATED_USER_DATA = { | ||
| ...USER_DATA, | ||
| accessToken: FRONTEGG_IMPERSONATED_RESPONSE.access_token, | ||
| isImpersonated: true, | ||
| } | ||
|
|
||
| const server = setupServer() | ||
|
|
@@ -65,9 +79,6 @@ describe('frontegg-oauth-client', () => { | |
| http.post(`${baseUrl}/frontegg/oauth/authorize/silent`, () => | ||
| HttpResponse.json(FRONTEGG_RESPONSE), | ||
| ), | ||
| http.get(`${baseUrl}/frontegg/identity/resources/users/v2/me`, () => | ||
| HttpResponse.json(FRONTEGG_USER_DATA), | ||
| ), | ||
| ) | ||
|
|
||
| const client = new FronteggOAuthClient(clientConfig) | ||
|
|
@@ -76,14 +87,25 @@ describe('frontegg-oauth-client', () => { | |
| expect(client.userData).toEqual(USER_DATA) | ||
| }) | ||
|
|
||
| it('returns impersonated user data based on auth token', async () => { | ||
| server.use( | ||
| // This is a request that is made to the Frontegg API when the cookie is available | ||
| http.post(`${baseUrl}/frontegg/oauth/authorize/silent`, () => | ||
| HttpResponse.json(FRONTEGG_IMPERSONATED_RESPONSE), | ||
| ), | ||
| ) | ||
|
|
||
| const client = new FronteggOAuthClient(clientConfig) | ||
| const userData = await client.getUserData() | ||
| expect(userData).toEqual(IMPERSONATED_USER_DATA) | ||
| expect(client.userData).toEqual(IMPERSONATED_USER_DATA) | ||
| }) | ||
|
|
||
| it('allows to fetch user data only once at a time', async () => { | ||
| server.use( | ||
| http.post(`${baseUrl}/frontegg/oauth/authorize/silent`, () => | ||
| HttpResponse.json(FRONTEGG_RESPONSE), | ||
| ), | ||
| http.get(`${baseUrl}/frontegg/identity/resources/users/v2/me`, () => | ||
| HttpResponse.json(FRONTEGG_USER_DATA), | ||
| ), | ||
| ) | ||
|
|
||
| const client = new FronteggOAuthClient(clientConfig) | ||
|
|
@@ -102,17 +124,17 @@ describe('frontegg-oauth-client', () => { | |
| }) | ||
|
|
||
| it('returns user data based on refresh token', async () => { | ||
| const refreshedAccessToken = | ||
| 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXItaWQiLCJlbWFpbCI6InRlc3RAbG9rYWxpc2UuY29tIiwibmFtZSI6ImR1bW15IHVzZXJuYW1lIiwicHJvZmlsZVBpY3R1cmVVcmwiOiJodHRwczovL3d3dy5ncmF2YXRhci5jb20vYXZhdGFyLzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwidGVuYW50SWQiOiJ0ZXN0LXRlbmFudC1pZCIsInNpZCI6InJlZnJlc2hlZC1zZXNzaW9uIn0.sSSFmSvkO7Rns6dkZsIqRhXzGfWPYhg2_IfK9sksqCnEpoiiQ5hNRy43hoU_rlGLJDehaMfxv9RYJuNJbU-HKIKrHyfsQWztGLyK11fEuMb1f3U9hd3-8eljIjk_SSrL3OGbvYu612qKkkEdyZkmjnCTxmKRtc3g0BSJI-EIDIXBoBKwRYb7p6TMdD5vba7krMZ-AbVp0eDjiiL6u8XorQa4Y95pkOSytfJl7T8T3-yPMlUAYep6Q4-1Lvg26W43KCTlb5-qsddPrH2T_FNL6LkVXaWxHbLtNRENpCQR6elD5528NgnBEOSphKZeuPUG4WvMsrOX2B0-nxFlzXooqg' | ||
|
|
||
| server.use( | ||
| http.post(`${baseUrl}/frontegg/oauth/authorize/silent`, () => | ||
| HttpResponse.json({ ...FRONTEGG_RESPONSE, expires_in: 0 }), | ||
| ), | ||
| http.get(`${baseUrl}/frontegg/identity/resources/users/v2/me`, () => | ||
| HttpResponse.json(FRONTEGG_USER_DATA), | ||
| ), | ||
| http.post(`${baseUrl}/frontegg/oauth/token`, () => | ||
| HttpResponse.json({ | ||
| ...FRONTEGG_RESPONSE, | ||
| access_token: 'test-refreshed-access-token', | ||
| access_token: refreshedAccessToken, | ||
| }), | ||
| ), | ||
| ) | ||
|
|
@@ -128,7 +150,7 @@ describe('frontegg-oauth-client', () => { | |
|
|
||
| const expectedUserDataRefreshed = { | ||
| ...USER_DATA, | ||
| accessToken: 'test-refreshed-access-token', | ||
| accessToken: refreshedAccessToken, | ||
| } | ||
|
|
||
| expect(userDataRefreshed).toEqual(expectedUserDataRefreshed) | ||
|
|
@@ -169,7 +191,7 @@ describe('frontegg-oauth-client', () => { | |
| const client = new FronteggOAuthClient(clientConfig) | ||
| const accessToken = await client.fetchAccessTokenByOAuthCode('test-oauth-code') | ||
|
|
||
| expect(accessToken).toBe('test-access-token') | ||
| expect(accessToken).toBe(FRONTEGG_RESPONSE.access_token) | ||
| }) | ||
| }) | ||
|
|
||
|
|
@@ -206,7 +228,7 @@ describe('frontegg-oauth-client', () => { | |
| const client = new FronteggOAuthClient(clientConfig) | ||
| const accessToken = await client.fetchAccessTokenByOAuthRefreshToken('test-oauth-code') | ||
|
|
||
| expect(accessToken).toBe('test-access-token') | ||
| expect(accessToken).toBe(FRONTEGG_RESPONSE.access_token) | ||
| }) | ||
| }) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ export interface FronteggUserData { | |
| email: string | ||
| profilePictureUrl: string | null | undefined | ||
| externalWorkspaceId: string | ||
| isImpersonated: boolean | ||
| } | ||
|
|
||
| const GET_FRONTEGG_TOKEN_RESPONSE_SCHEMA = z.object({ | ||
|
|
@@ -155,15 +156,65 @@ const isTokenExpired = (tokenExpirationTime: number | null) => { | |
| return tokenExpirationTime - 60 * 60 * 1000 < Date.now() | ||
| } | ||
|
|
||
| const GET_FRONTEGG_USER_DATA_RESPONSE_SCHEMA = z.object({ | ||
| id: z.string(), | ||
| /** | ||
| * Function to decode a JWT token | ||
| * Based on https://stackoverflow.com/a/30106551/10876985 and https://github.com/auth0/jwt-decode | ||
| * | ||
| * @param token the JWT token to decode | ||
| * @returns the decoded JWT token | ||
| */ | ||
| const decodeJwt = (token: string) => { | ||
arthuracs marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // Extract the base64 payload from the token | ||
| const base64Url = token.split('.')[1] | ||
| // Replace URL-safe characters with the base64 standard characters | ||
| let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/') | ||
| // Add padding to the base64 string if needed, as base64 strings need to be a multiple of 4 characters | ||
| switch (base64.length % 4) { | ||
| case 0: | ||
| break | ||
| case 2: | ||
| base64 += '==' | ||
| break | ||
| case 3: | ||
| base64 += '=' | ||
| break | ||
| default: | ||
| throw new Error('base64 string is not of the correct length') | ||
| } | ||
| // Decode the base64 string and replace special characters with their hex representation | ||
| // Only using atob would not work in all cases, as it does not decode all special characters | ||
| // See https://stackoverflow.com/a/30106551/10876985 for more details | ||
| const jsonPayload = decodeURIComponent( | ||
| atob(base64).replace(/(.)/g, (_m, p) => { | ||
| let code = (p as string).charCodeAt(0).toString(16).toUpperCase() | ||
| if (code.length < 2) { | ||
| code = `0${code}` | ||
| } | ||
| return `%${code}` | ||
| }), | ||
| ) | ||
| // Parse the JSON payload and return it | ||
| return JSON.parse(jsonPayload) | ||
| } | ||
|
|
||
| const FRONTEGG_DECODED_TOKEN_SCHEMA = z.object({ | ||
| sub: z.string().describe('JWT subject claim used for the Frontegg user id'), | ||
| email: z.string(), | ||
| name: z.string(), | ||
| profilePictureUrl: z.string().nullable().optional(), | ||
| tenantId: z.string(), | ||
| act: z | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟢 It would be good to add some link to Frontegg docs for this variable. For someone like me (who sees this first time), it would be very helpful.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unfortunately there are none. |
||
| .object({ | ||
| sub: z | ||
| .string() | ||
| .describe('The Frontegg admin user ID of the user impersonating the current session'), | ||
| type: z.string(), | ||
| }) | ||
| .optional() | ||
| .describe('Act object is available only when the current session is being impersonated'), | ||
| }) | ||
|
|
||
| export type GetFronteggUserDataResponse = z.infer<typeof GET_FRONTEGG_USER_DATA_RESPONSE_SCHEMA> | ||
| export type FronteggDecodedToken = z.infer<typeof FRONTEGG_DECODED_TOKEN_SCHEMA> | ||
|
|
||
| /** | ||
| * Class providing a Frontegg OAuth login with AccessToken and UserData. | ||
|
|
@@ -246,7 +297,7 @@ export class FronteggOAuthClient { | |
| if (!this.userDataPromise) { | ||
| this.userDataPromise = this.getAccessToken({ forceRefresh }) | ||
| .then((accessToken) => { | ||
| return this.fetchUserData(accessToken) | ||
| return this.decodeAccessToken(accessToken) | ||
| }) | ||
| .then((userData) => { | ||
| this.userData = userData | ||
|
|
@@ -405,31 +456,20 @@ export class FronteggOAuthClient { | |
| } | ||
|
|
||
| /** | ||
| * Function to fetch the user details from Frontegg. | ||
| * If the user is not authenticated or the access token is expired, the function throws error. | ||
| * More information: https://docs.frontegg.com/reference/userscontrollerv2_getuserprofile | ||
| * Function to decode the user JWT access token and extract the user data. | ||
| */ | ||
| private async fetchUserData(userAccessToken: string): Promise<FronteggUserData> { | ||
| const response = await fetchWithAssert( | ||
| `${this.baseUrl}/frontegg/identity/resources/users/v2/me`, | ||
| { | ||
| credentials: 'include', | ||
| headers: { | ||
| Authorization: `Bearer ${userAccessToken}`, | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| }, | ||
| ) | ||
|
|
||
| const data = GET_FRONTEGG_USER_DATA_RESPONSE_SCHEMA.parse(await response.json()) | ||
| private decodeAccessToken(userAccessToken: string): FronteggUserData { | ||
| const decodedJwt = decodeJwt(userAccessToken) | ||
| const parsedUserData = FRONTEGG_DECODED_TOKEN_SCHEMA.parse(decodedJwt) | ||
|
|
||
| return { | ||
| externalUserId: data.id, | ||
| externalUserId: parsedUserData.sub, | ||
| accessToken: userAccessToken, | ||
| email: data.email, | ||
| name: data.name, | ||
| profilePictureUrl: data.profilePictureUrl, | ||
| externalWorkspaceId: data.tenantId, | ||
| email: parsedUserData.email, | ||
| name: parsedUserData.name, | ||
| profilePictureUrl: parsedUserData.profilePictureUrl, | ||
| externalWorkspaceId: parsedUserData.tenantId, | ||
| isImpersonated: parsedUserData.act?.type === 'impersonation', | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.