Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 40 additions & 18 deletions src/frontegg-oauth-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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,
}),
),
)
Expand All @@ -128,7 +150,7 @@ describe('frontegg-oauth-client', () => {

const expectedUserDataRefreshed = {
...USER_DATA,
accessToken: 'test-refreshed-access-token',
accessToken: refreshedAccessToken,
}

expect(userDataRefreshed).toEqual(expectedUserDataRefreshed)
Expand Down Expand Up @@ -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)
})
})

Expand Down Expand Up @@ -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)
})
})

Expand Down
90 changes: 65 additions & 25 deletions src/frontegg-oauth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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) => {
// 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
Copy link
Contributor

@ondrejsevcik ondrejsevcik Oct 23, 2024

Choose a reason for hiding this comment

The 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.

Copy link
Author

Choose a reason for hiding this comment

The 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
}
}
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ export {
FronteggOAuthClient,
type FronteggUserData,
type GetFronteggTokenResponse,
type GetFronteggUserDataResponse,
type FronteggDecodedToken,
} from './frontegg-oauth-client'
Loading