Skip to content

Commit cc0d853

Browse files
author
Arthur Suermondt
authored
feat: decode user data from JWT access token and add impersonated property (#32)
* AP-5345 Parse JWT token and return impersonation information * Address PR feedback
1 parent 1994add commit cc0d853

File tree

3 files changed

+106
-44
lines changed

3 files changed

+106
-44
lines changed

src/frontegg-oauth-client.test.ts

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { http, HttpResponse } from 'msw'
22
import { setupServer } from 'msw/node'
33

44
import {
5+
type FronteggDecodedToken,
56
FronteggOAuthClient,
67
type GetFronteggTokenResponse,
7-
type GetFronteggUserDataResponse,
88
} from './frontegg-oauth-client'
99

1010
const EmptyResponse = (code: number) => new HttpResponse(null, { status: code })
@@ -19,27 +19,41 @@ const clientConfig = {
1919

2020
const FRONTEGG_RESPONSE = {
2121
token_type: 'Bearer',
22-
access_token: 'test-access-token',
22+
access_token:
23+
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXItaWQiLCJlbWFpbCI6InRlc3RAbG9rYWxpc2UuY29tIiwibmFtZSI6ImR1bW15IHVzZXJuYW1lIiwicHJvZmlsZVBpY3R1cmVVcmwiOiJodHRwczovL3d3dy5ncmF2YXRhci5jb20vYXZhdGFyLzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwidGVuYW50SWQiOiJ0ZXN0LXRlbmFudC1pZCJ9.dxFESK7KleQdEz4hBmd-pKMSKUN0uYJ44ycd-SQeAYBGfJcQQPCsjOWBDSlxGUodLmalhMMVDTvmN4G4La5lfOakas4kJzrfVAXfV_-ZYAiOHZaqS_OTMZaTPAcjWZfnNNEnewuNhZSiuzqEbaIpKOX4tmZOHH1ganJT2Z-gvRiArVC1zEZdZPFt0MVGl9Tt3Kmcgvf3j22j1FWI5AqVsiYFHolISaWveZyIR62qtF3pyGLRW-4qwoujV393Kf52kNWez0P7Ed70-yrVJX_D0buJ1aW-bPXSh1F0ifnGBvYKtoUqSLZ1e0InA3rTccWt5DIyOUULaE0asgJxB61Nqg',
2324
id_token: 'test-id-token',
2425
refresh_token: 'test-refresh-token',
2526
expires_in: 3600,
2627
} satisfies GetFronteggTokenResponse
2728

29+
const FRONTEGG_IMPERSONATED_RESPONSE = {
30+
...FRONTEGG_RESPONSE,
31+
access_token:
32+
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXItaWQiLCJlbWFpbCI6InRlc3RAbG9rYWxpc2UuY29tIiwibmFtZSI6ImR1bW15IHVzZXJuYW1lIiwicHJvZmlsZVBpY3R1cmVVcmwiOiJodHRwczovL3d3dy5ncmF2YXRhci5jb20vYXZhdGFyLzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwidGVuYW50SWQiOiJ0ZXN0LXRlbmFudC1pZCIsImFjdCI6eyJzdWIiOiJ0ZXN0LWFkbWluLXVzZXIiLCJ0eXBlIjoiaW1wZXJzb25hdGlvbiJ9fQ.lamGCm4sfTCsfyZ11-rnecqqJcAKua2IiCMQxHr5kQw',
33+
}
34+
2835
const FRONTEGG_USER_DATA = {
29-
id: 'test-user-id',
36+
sub: 'test-user-id',
3037
3138
name: 'dummy username',
3239
profilePictureUrl: 'https://www.gravatar.com/avatar/00000000000000000000000000000000',
3340
tenantId: 'test-tenant-id',
34-
} satisfies GetFronteggUserDataResponse
41+
} satisfies FronteggDecodedToken
3542

3643
const USER_DATA = {
37-
externalUserId: FRONTEGG_USER_DATA.id,
44+
externalUserId: FRONTEGG_USER_DATA.sub,
3845
accessToken: FRONTEGG_RESPONSE.access_token,
3946
email: FRONTEGG_USER_DATA.email,
4047
name: FRONTEGG_USER_DATA.name,
4148
profilePictureUrl: FRONTEGG_USER_DATA.profilePictureUrl,
4249
externalWorkspaceId: FRONTEGG_USER_DATA.tenantId,
50+
isImpersonated: false,
51+
}
52+
53+
const IMPERSONATED_USER_DATA = {
54+
...USER_DATA,
55+
accessToken: FRONTEGG_IMPERSONATED_RESPONSE.access_token,
56+
isImpersonated: true,
4357
}
4458

4559
const server = setupServer()
@@ -65,9 +79,6 @@ describe('frontegg-oauth-client', () => {
6579
http.post(`${baseUrl}/frontegg/oauth/authorize/silent`, () =>
6680
HttpResponse.json(FRONTEGG_RESPONSE),
6781
),
68-
http.get(`${baseUrl}/frontegg/identity/resources/users/v2/me`, () =>
69-
HttpResponse.json(FRONTEGG_USER_DATA),
70-
),
7182
)
7283

7384
const client = new FronteggOAuthClient(clientConfig)
@@ -76,14 +87,25 @@ describe('frontegg-oauth-client', () => {
7687
expect(client.userData).toEqual(USER_DATA)
7788
})
7889

90+
it('returns impersonated user data based on auth token', async () => {
91+
server.use(
92+
// This is a request that is made to the Frontegg API when the cookie is available
93+
http.post(`${baseUrl}/frontegg/oauth/authorize/silent`, () =>
94+
HttpResponse.json(FRONTEGG_IMPERSONATED_RESPONSE),
95+
),
96+
)
97+
98+
const client = new FronteggOAuthClient(clientConfig)
99+
const userData = await client.getUserData()
100+
expect(userData).toEqual(IMPERSONATED_USER_DATA)
101+
expect(client.userData).toEqual(IMPERSONATED_USER_DATA)
102+
})
103+
79104
it('allows to fetch user data only once at a time', async () => {
80105
server.use(
81106
http.post(`${baseUrl}/frontegg/oauth/authorize/silent`, () =>
82107
HttpResponse.json(FRONTEGG_RESPONSE),
83108
),
84-
http.get(`${baseUrl}/frontegg/identity/resources/users/v2/me`, () =>
85-
HttpResponse.json(FRONTEGG_USER_DATA),
86-
),
87109
)
88110

89111
const client = new FronteggOAuthClient(clientConfig)
@@ -102,17 +124,17 @@ describe('frontegg-oauth-client', () => {
102124
})
103125

104126
it('returns user data based on refresh token', async () => {
127+
const refreshedAccessToken =
128+
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXItaWQiLCJlbWFpbCI6InRlc3RAbG9rYWxpc2UuY29tIiwibmFtZSI6ImR1bW15IHVzZXJuYW1lIiwicHJvZmlsZVBpY3R1cmVVcmwiOiJodHRwczovL3d3dy5ncmF2YXRhci5jb20vYXZhdGFyLzAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwidGVuYW50SWQiOiJ0ZXN0LXRlbmFudC1pZCIsInNpZCI6InJlZnJlc2hlZC1zZXNzaW9uIn0.sSSFmSvkO7Rns6dkZsIqRhXzGfWPYhg2_IfK9sksqCnEpoiiQ5hNRy43hoU_rlGLJDehaMfxv9RYJuNJbU-HKIKrHyfsQWztGLyK11fEuMb1f3U9hd3-8eljIjk_SSrL3OGbvYu612qKkkEdyZkmjnCTxmKRtc3g0BSJI-EIDIXBoBKwRYb7p6TMdD5vba7krMZ-AbVp0eDjiiL6u8XorQa4Y95pkOSytfJl7T8T3-yPMlUAYep6Q4-1Lvg26W43KCTlb5-qsddPrH2T_FNL6LkVXaWxHbLtNRENpCQR6elD5528NgnBEOSphKZeuPUG4WvMsrOX2B0-nxFlzXooqg'
129+
105130
server.use(
106131
http.post(`${baseUrl}/frontegg/oauth/authorize/silent`, () =>
107132
HttpResponse.json({ ...FRONTEGG_RESPONSE, expires_in: 0 }),
108133
),
109-
http.get(`${baseUrl}/frontegg/identity/resources/users/v2/me`, () =>
110-
HttpResponse.json(FRONTEGG_USER_DATA),
111-
),
112134
http.post(`${baseUrl}/frontegg/oauth/token`, () =>
113135
HttpResponse.json({
114136
...FRONTEGG_RESPONSE,
115-
access_token: 'test-refreshed-access-token',
137+
access_token: refreshedAccessToken,
116138
}),
117139
),
118140
)
@@ -128,7 +150,7 @@ describe('frontegg-oauth-client', () => {
128150

129151
const expectedUserDataRefreshed = {
130152
...USER_DATA,
131-
accessToken: 'test-refreshed-access-token',
153+
accessToken: refreshedAccessToken,
132154
}
133155

134156
expect(userDataRefreshed).toEqual(expectedUserDataRefreshed)
@@ -169,7 +191,7 @@ describe('frontegg-oauth-client', () => {
169191
const client = new FronteggOAuthClient(clientConfig)
170192
const accessToken = await client.fetchAccessTokenByOAuthCode('test-oauth-code')
171193

172-
expect(accessToken).toBe('test-access-token')
194+
expect(accessToken).toBe(FRONTEGG_RESPONSE.access_token)
173195
})
174196
})
175197

@@ -206,7 +228,7 @@ describe('frontegg-oauth-client', () => {
206228
const client = new FronteggOAuthClient(clientConfig)
207229
const accessToken = await client.fetchAccessTokenByOAuthRefreshToken('test-oauth-code')
208230

209-
expect(accessToken).toBe('test-access-token')
231+
expect(accessToken).toBe(FRONTEGG_RESPONSE.access_token)
210232
})
211233
})
212234

src/frontegg-oauth-client.ts

Lines changed: 65 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface FronteggUserData {
77
email: string
88
profilePictureUrl: string | null | undefined
99
externalWorkspaceId: string
10+
isImpersonated: boolean
1011
}
1112

1213
const GET_FRONTEGG_TOKEN_RESPONSE_SCHEMA = z.object({
@@ -155,15 +156,65 @@ const isTokenExpired = (tokenExpirationTime: number | null) => {
155156
return tokenExpirationTime - 60 * 60 * 1000 < Date.now()
156157
}
157158

158-
const GET_FRONTEGG_USER_DATA_RESPONSE_SCHEMA = z.object({
159-
id: z.string(),
159+
/**
160+
* Function to decode a JWT token
161+
* Based on https://stackoverflow.com/a/30106551/10876985 and https://github.com/auth0/jwt-decode
162+
*
163+
* @param token the JWT token to decode
164+
* @returns the decoded JWT token
165+
*/
166+
const decodeJwt = (token: string) => {
167+
// Extract the base64 payload from the token
168+
const base64Url = token.split('.')[1]
169+
// Replace URL-safe characters with the base64 standard characters
170+
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
171+
// Add padding to the base64 string if needed, as base64 strings need to be a multiple of 4 characters
172+
switch (base64.length % 4) {
173+
case 0:
174+
break
175+
case 2:
176+
base64 += '=='
177+
break
178+
case 3:
179+
base64 += '='
180+
break
181+
default:
182+
throw new Error('base64 string is not of the correct length')
183+
}
184+
// Decode the base64 string and replace special characters with their hex representation
185+
// Only using atob would not work in all cases, as it does not decode all special characters
186+
// See https://stackoverflow.com/a/30106551/10876985 for more details
187+
const jsonPayload = decodeURIComponent(
188+
atob(base64).replace(/(.)/g, (_m, p) => {
189+
let code = (p as string).charCodeAt(0).toString(16).toUpperCase()
190+
if (code.length < 2) {
191+
code = `0${code}`
192+
}
193+
return `%${code}`
194+
}),
195+
)
196+
// Parse the JSON payload and return it
197+
return JSON.parse(jsonPayload)
198+
}
199+
200+
const FRONTEGG_DECODED_TOKEN_SCHEMA = z.object({
201+
sub: z.string().describe('JWT subject claim used for the Frontegg user id'),
160202
email: z.string(),
161203
name: z.string(),
162204
profilePictureUrl: z.string().nullable().optional(),
163205
tenantId: z.string(),
206+
act: z
207+
.object({
208+
sub: z
209+
.string()
210+
.describe('The Frontegg admin user ID of the user impersonating the current session'),
211+
type: z.string(),
212+
})
213+
.optional()
214+
.describe('Act object is available only when the current session is being impersonated'),
164215
})
165216

166-
export type GetFronteggUserDataResponse = z.infer<typeof GET_FRONTEGG_USER_DATA_RESPONSE_SCHEMA>
217+
export type FronteggDecodedToken = z.infer<typeof FRONTEGG_DECODED_TOKEN_SCHEMA>
167218

168219
/**
169220
* Class providing a Frontegg OAuth login with AccessToken and UserData.
@@ -246,7 +297,7 @@ export class FronteggOAuthClient {
246297
if (!this.userDataPromise) {
247298
this.userDataPromise = this.getAccessToken({ forceRefresh })
248299
.then((accessToken) => {
249-
return this.fetchUserData(accessToken)
300+
return this.decodeAccessToken(accessToken)
250301
})
251302
.then((userData) => {
252303
this.userData = userData
@@ -405,31 +456,20 @@ export class FronteggOAuthClient {
405456
}
406457

407458
/**
408-
* Function to fetch the user details from Frontegg.
409-
* If the user is not authenticated or the access token is expired, the function throws error.
410-
* More information: https://docs.frontegg.com/reference/userscontrollerv2_getuserprofile
459+
* Function to decode the user JWT access token and extract the user data.
411460
*/
412-
private async fetchUserData(userAccessToken: string): Promise<FronteggUserData> {
413-
const response = await fetchWithAssert(
414-
`${this.baseUrl}/frontegg/identity/resources/users/v2/me`,
415-
{
416-
credentials: 'include',
417-
headers: {
418-
Authorization: `Bearer ${userAccessToken}`,
419-
'Content-Type': 'application/json',
420-
},
421-
},
422-
)
423-
424-
const data = GET_FRONTEGG_USER_DATA_RESPONSE_SCHEMA.parse(await response.json())
461+
private decodeAccessToken(userAccessToken: string): FronteggUserData {
462+
const decodedJwt = decodeJwt(userAccessToken)
463+
const parsedUserData = FRONTEGG_DECODED_TOKEN_SCHEMA.parse(decodedJwt)
425464

426465
return {
427-
externalUserId: data.id,
466+
externalUserId: parsedUserData.sub,
428467
accessToken: userAccessToken,
429-
email: data.email,
430-
name: data.name,
431-
profilePictureUrl: data.profilePictureUrl,
432-
externalWorkspaceId: data.tenantId,
468+
email: parsedUserData.email,
469+
name: parsedUserData.name,
470+
profilePictureUrl: parsedUserData.profilePictureUrl,
471+
externalWorkspaceId: parsedUserData.tenantId,
472+
isImpersonated: parsedUserData.act?.type === 'impersonation',
433473
}
434474
}
435475
}

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ export {
33
FronteggOAuthClient,
44
type FronteggUserData,
55
type GetFronteggTokenResponse,
6-
type GetFronteggUserDataResponse,
6+
type FronteggDecodedToken,
77
} from './frontegg-oauth-client'

0 commit comments

Comments
 (0)