From 44a42bf8e7aa16a064a0bdbf0e8b546e90635159 Mon Sep 17 00:00:00 2001 From: Jack Palmer Date: Thu, 2 Apr 2026 13:43:16 +0100 Subject: [PATCH] fix(auth0): use jose to decode id_token sub claim for cache key Replace manual base64 JWT decoding with jose's decodeJwt for correctness and consistency with other auth modules. Add jose as a dependency. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Jack Palmer --- .../package.json | 1 + .../src/authenticator.test.ts | 34 ++++++++++++++----- .../src/authenticator.ts | 5 ++- yarn.lock | 1 + 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/plugins/auth-backend-module-auth0-provider/package.json b/plugins/auth-backend-module-auth0-provider/package.json index f041c770bd..6818b23e57 100644 --- a/plugins/auth-backend-module-auth0-provider/package.json +++ b/plugins/auth-backend-module-auth0-provider/package.json @@ -39,6 +39,7 @@ "@backstage/plugin-auth-node": "workspace:^", "@types/passport": "^1.0.3", "express": "^4.22.0", + "jose": "^5.0.0", "passport": "^0.7.0", "passport-auth0": "^1.4.3", "passport-oauth2": "^1.6.1" diff --git a/plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts b/plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts index 6d783fc96f..0fdeb87cdb 100644 --- a/plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-auth0-provider/src/authenticator.test.ts @@ -63,6 +63,15 @@ describe('createAuth0Authenticator', () => { }, }); + // Create a minimal valid JWT with a sub claim for decodeJwt + function createTestIdToken(sub: string): string { + const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString( + 'base64url', + ); + const payload = Buffer.from(JSON.stringify({ sub })).toString('base64url'); + return `${header}.${payload}.`; + } + const mockProfile = { provider: 'auth0', id: 'user-123', @@ -101,7 +110,7 @@ describe('createAuth0Authenticator', () => { token_type: 'bearer', scope: 'openid profile', expires_in: 3600, - id_token: 'id-token-value', + id_token: createTestIdToken('auth0|user-123'), }, }); }); @@ -134,7 +143,7 @@ describe('createAuth0Authenticator', () => { tokenType: 'bearer', scope: 'openid profile', expiresInSeconds: 3600, - idToken: 'id-token-value', + idToken: createTestIdToken('auth0|user-123'), refreshToken: 'new-refresh-token', }); }); @@ -166,7 +175,7 @@ describe('createAuth0Authenticator', () => { token_type: 'bearer', scope: 'openid profile', expires_in: 3600, - id_token: 'id-token-2', + id_token: createTestIdToken('auth0|user-123'), }, }); @@ -181,22 +190,21 @@ describe('createAuth0Authenticator', () => { // Token refresh still happens expect(mockExecuteRefresh).toHaveBeenCalledTimes(1); - // But profile fetch is skipped + // But profile fetch is skipped because same sub expect(mockFetchProfile).not.toHaveBeenCalled(); expect(result.fullProfile).toEqual(mockProfile); // Session uses fresh token data expect(result.session.accessToken).toBe('another-access-token'); - expect(result.session.idToken).toBe('id-token-2'); }); - it('should fetch profile again when refresh token changes', async () => { + it('should fetch profile again when sub claim changes', async () => { const authenticator = createAuth0Authenticator({ cache }); const ctx = authenticator.initialize({ callbackUrl: 'http://localhost/callback', config: mockConfig, }); - // First call with token A + // First call with user-123 await authenticator.refresh( { refreshToken: 'token-a', @@ -208,7 +216,17 @@ describe('createAuth0Authenticator', () => { expect(mockFetchProfile).toHaveBeenCalledTimes(1); - // Second call with token B - different session, should miss cache + // Second call with a different sub - different user, should miss cache + mockExecuteRefresh.mockResolvedValue({ + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + params: { + token_type: 'bearer', + scope: 'openid profile', + expires_in: 3600, + id_token: createTestIdToken('auth0|user-456'), + }, + }); const updatedProfile = { ...mockProfile, displayName: 'Updated User' }; mockFetchProfile.mockResolvedValue(updatedProfile); diff --git a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts index 8c8b200793..6390324869 100644 --- a/plugins/auth-backend-module-auth0-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-auth0-provider/src/authenticator.ts @@ -16,6 +16,7 @@ import { CacheService } from '@backstage/backend-plugin-api'; import express from 'express'; +import { decodeJwt } from 'jose'; import { Strategy } from 'passport'; import { createOAuthAuthenticator, @@ -138,9 +139,7 @@ export function createAuth0Authenticator(options?: { cache?: CacheService }) { input.scope, ); - const sub = JSON.parse( - Buffer.from(result.params.id_token.split('.')[1], 'base64').toString(), - ).sub; + const { sub } = decodeJwt(result.params.id_token); const cacheKey = `auth0-profile:${sub}`; let fullProfile = (await profileCache?.get(cacheKey)) as | PassportProfile diff --git a/yarn.lock b/yarn.lock index a4ea35b988..9ad909ea8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4199,6 +4199,7 @@ __metadata: "@types/passport-auth0": "npm:^1.0.5" "@types/passport-oauth2": "npm:^1.4.15" express: "npm:^4.22.0" + jose: "npm:^5.0.0" passport: "npm:^0.7.0" passport-auth0: "npm:^1.4.3" passport-oauth2: "npm:^1.6.1"