Skip to content

Commit 811f303

Browse files
author
Arthur Suermondt
committed
AP-5345 Parse JWT token and return impersonation information
1 parent 1994add commit 811f303

File tree

3 files changed

+111
-50
lines changed

3 files changed

+111
-50
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+
impersonated: false,
51+
}
52+
53+
const IMPERSONATED_USER_DATA = {
54+
...USER_DATA,
55+
accessToken: FRONTEGG_IMPERSONATED_RESPONSE.access_token,
56+
impersonated: 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: 70 additions & 31 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+
impersonated: boolean
1011
}
1112

1213
const GET_FRONTEGG_TOKEN_RESPONSE_SCHEMA = z.object({
@@ -25,17 +26,17 @@ export type GetFronteggTokenResponse = z.infer<typeof GET_FRONTEGG_TOKEN_RESPONS
2526
export class FronteggError extends Error {
2627
text: string
2728
status: number
28-
url: string
29+
url?: string
2930
// https://support.frontegg.com/hc/en-us/articles/7027392266525-How-do-I-find-the-frontegg-trace-id
30-
fronteggTraceId: string
31-
body: unknown
31+
fronteggTraceId?: string
32+
body?: unknown
3233

3334
constructor(options: {
3435
text: string
3536
status: number
36-
url: string
37-
fronteggTraceId: string
38-
body: unknown
37+
url?: string
38+
fronteggTraceId?: string
39+
body?: unknown
3940
}) {
4041
super()
4142
this.name = 'FronteggError'
@@ -155,15 +156,64 @@ 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+
*
162+
* @param token the JWT token to decode
163+
* @returns the decoded JWT token
164+
*/
165+
const decodeJwt = (token: string) => {
166+
// Extract the base64 payload from the token
167+
const base64Url = token.split('.')[1]
168+
// Replace URL-safe characters with the base64 standard characters
169+
let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
170+
// Add padding to the base64 string if needed, as base64 strings need to be a multiple of 4 characters
171+
switch (base64.length % 4) {
172+
case 0:
173+
break
174+
case 2:
175+
base64 += '=='
176+
break
177+
case 3:
178+
base64 += '='
179+
break
180+
default:
181+
throw new Error('base64 string is not of the correct length')
182+
}
183+
// Decode the base64 string and replace special characters with their hex representation
184+
// Only using atob would not work in all cases, as it does not decode all special characters
185+
// See https://stackoverflow.com/a/30106551/10876985 for more details
186+
const jsonPayload = decodeURIComponent(
187+
atob(base64).replace(/(.)/g, (_m, p) => {
188+
let code = (p as string).charCodeAt(0).toString(16).toUpperCase()
189+
if (code.length < 2) {
190+
code = `0${code}`
191+
}
192+
return `%${code}`
193+
}),
194+
)
195+
// Parse the JSON payload and return it
196+
return JSON.parse(jsonPayload)
197+
}
198+
199+
const FRONTEGG_DECODED_TOKEN_SCHEMA = z.object({
200+
sub: z.string().describe('JWT subject claim used for the Frontegg user id'),
160201
email: z.string(),
161202
name: z.string(),
162203
profilePictureUrl: z.string().nullable().optional(),
163204
tenantId: z.string(),
205+
act: z
206+
.object({
207+
sub: z
208+
.string()
209+
.describe('The Frontegg admin user ID of the user impersonating the current session'),
210+
type: z.string(),
211+
})
212+
.optional()
213+
.describe('Act object is available only when the current session is being impersonated'),
164214
})
165215

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

168218
/**
169219
* Class providing a Frontegg OAuth login with AccessToken and UserData.
@@ -246,7 +296,7 @@ export class FronteggOAuthClient {
246296
if (!this.userDataPromise) {
247297
this.userDataPromise = this.getAccessToken({ forceRefresh })
248298
.then((accessToken) => {
249-
return this.fetchUserData(accessToken)
299+
return this.decodeAccessToken(accessToken)
250300
})
251301
.then((userData) => {
252302
this.userData = userData
@@ -405,31 +455,20 @@ export class FronteggOAuthClient {
405455
}
406456

407457
/**
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
458+
* Function to decode the user JWT access token and extract the user data.
411459
*/
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())
460+
private decodeAccessToken(userAccessToken: string): FronteggUserData {
461+
const decodedJwt = decodeJwt(userAccessToken)
462+
const parsedUserData = FRONTEGG_DECODED_TOKEN_SCHEMA.parse(decodedJwt)
425463

426464
return {
427-
externalUserId: data.id,
465+
externalUserId: parsedUserData.sub,
428466
accessToken: userAccessToken,
429-
email: data.email,
430-
name: data.name,
431-
profilePictureUrl: data.profilePictureUrl,
432-
externalWorkspaceId: data.tenantId,
467+
email: parsedUserData.email,
468+
name: parsedUserData.name,
469+
profilePictureUrl: parsedUserData.profilePictureUrl,
470+
externalWorkspaceId: parsedUserData.tenantId,
471+
impersonated: parsedUserData.act !== undefined,
433472
}
434473
}
435474
}

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)