diff --git a/e2e/tests/dpop-smoke.test.ts b/e2e/tests/dpop-smoke.test.ts index 60a2cc1..1d089df 100644 --- a/e2e/tests/dpop-smoke.test.ts +++ b/e2e/tests/dpop-smoke.test.ts @@ -39,7 +39,6 @@ import { const FA_URL = 'http://localhost:9011'; const CLIENT_ID = 'baf3d520-40d7-4000-9b62-e6a7d0091102'; const REDIRECT_URI = 'https://www.example.com'; -const TOKEN_ENDPOINT = `${FA_URL}/oauth2/token`; const USERINFO_ENDPOINT = `${FA_URL}/oauth2/userinfo`; const TEST_EMAIL = 'mike@fusionauth.io'; const TEST_PASSWORD = 'password'; @@ -351,7 +350,6 @@ test.describe('DPoP smoke tests', () => { // Shared state populated by earlier tests and used by later ones. let accessToken: string; - let refreshToken: string; let thumbprint: string; let core: SDKCore; @@ -496,12 +494,42 @@ test.describe('DPoP smoke tests', () => { // Persist tokens for subsequent tests — same key pair as `manager`, so // proofs `manager` signs for these tokens remain valid. accessToken = tokens!.accessToken; - refreshToken = tokens!.refreshToken ?? ''; manager.setTokens(tokens!); expect(manager.isLoggedIn).toBe(true); }); + test('refresh token grant — issues new DPoP-bound tokens', async () => { + test.skip(!accessToken, 'No access token from previous test'); + + expect(core.isLoggedIn).toBe(true); + const previousAccessToken = accessToken; + + const response = await core.refreshToken(); + expect(response.ok).toBe(true); + + expect(core.isLoggedIn).toBe(true); + const newAccessToken = core.getAccessToken(); + expect(newAccessToken).toBeDefined(); + expect(newAccessToken).not.toBe(previousAccessToken); + + // verify cnf.jkt still matches our key's thumbprint — + // proves FusionAuth bound the refreshed token to the + // same DPoP key pair. + const atPayload = decodeJwt(newAccessToken!); + expect(atPayload.cnf).toBeDefined(); + expect((atPayload.cnf as { jkt: string }).jkt).toBe(thumbprint); + + const tokenStore = new DPoPTokenStore(CLIENT_ID, 'localStorage'); + const tokens = tokenStore.get(); + expect(tokens).not.toBeNull(); + expect(tokens!.tokenType).toBe('DPoP'); + expect(tokens!.accessToken).toBe(newAccessToken); + expect(tokens!.expiresAt).toBeGreaterThan(Date.now()); + + accessToken = newAccessToken!; + }); + test('startLogout() clears DPoP state and redirects to the logout URL', async () => { test.skip(!accessToken, 'No access token from previous test'); @@ -529,58 +557,6 @@ test.describe('DPoP smoke tests', () => { expect(tokenStore.get()).toBeNull(); }); - test('refresh token grant — issues new DPoP-bound tokens', async () => { - test.skip(!refreshToken, 'No refresh token from previous test'); - - const proof = await manager.generateProof(TOKEN_ENDPOINT, 'POST'); - - const body = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: CLIENT_ID, - }); - - const response = await fetch(TOKEN_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - DPoP: proof, - }, - body: body.toString(), - }); - - const refreshText = await response.text(); - expect(response.status, `Refresh token grant failed: ${refreshText}`).toBe( - 200, - ); - - const tokenResponse = JSON.parse(refreshText) as { - access_token: string; - refresh_token?: string; - token_type: string; - expires_in: number; - }; - - expect(tokenResponse.token_type.toLowerCase()).toBe('dpop'); - expect(tokenResponse.access_token).toBeDefined(); - // New access token must be different from the original. - expect(tokenResponse.access_token).not.toBe(accessToken); - - const atPayload = decodeJwt(tokenResponse.access_token); - expect((atPayload.cnf as { jkt: string }).jkt).toBe(thumbprint); - - accessToken = tokenResponse.access_token; - refreshToken = tokenResponse.refresh_token ?? refreshToken; - - const expiresAt = Date.now() + tokenResponse.expires_in * 1000; - manager.setTokens({ - accessToken, - refreshToken: refreshToken || undefined, - expiresAt, - tokenType: 'DPoP', - }); - }); - test('DPoPManager.fetch() calls /oauth2/userinfo with correct DPoP headers and gets user claims', async () => { test.skip(!accessToken, 'No access token from previous test'); diff --git a/packages/core/src/SDKCore/SDKCore.test.ts b/packages/core/src/SDKCore/SDKCore.test.ts index 3950c81..af0ea87 100644 --- a/packages/core/src/SDKCore/SDKCore.test.ts +++ b/packages/core/src/SDKCore/SDKCore.test.ts @@ -524,10 +524,6 @@ describe('SDKCore', () => { vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( MOCK_PROOF, ); - // Avoid an actual (cookie-mode) network call from the real - // refreshToken() — DPoP mode's refreshToken() is implemented in a - // later ticket. We only assert *that* a refresh was - // scheduled and fires at the right time. const refreshToken = vi .spyOn(SDKCore.prototype, 'refreshToken') .mockResolvedValue(new Response(null, { status: 200 })); @@ -612,5 +608,187 @@ describe('SDKCore', () => { ); }); }); + + describe('refreshToken() in DPoP mode', () => { + const MOCK_PROOF = 'mock-dpop-refresh-proof-jwt'; + const MOCK_OLD_REFRESH_TOKEN = 'mock-old-refresh-token'; + const MOCK_NEW_ACCESS_TOKEN = 'mock-new-access-token'; + const MOCK_NEW_REFRESH_TOKEN = 'mock-new-refresh-token'; + const EXPIRES_IN_SECONDS = 3600; + + function seedExistingTokens(core: SDKCore) { + const dpopManager = (core as any).dpopManager as DPoPManager; + dpopManager.setTokens({ + accessToken: 'mock-old-access-token', + refreshToken: MOCK_OLD_REFRESH_TOKEN, + expiresAt: Date.now() + 60_000, + tokenType: 'DPoP', + }); + } + + function mockTokenResponse( + overrides: Partial<{ + access_token: string; + refresh_token?: string; + expires_in: number; + token_type: string; + }> = {}, + ) { + return vi.spyOn(window, 'fetch').mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ + access_token: MOCK_NEW_ACCESS_TOKEN, + refresh_token: MOCK_NEW_REFRESH_TOKEN, + expires_in: EXPIRES_IN_SECONDS, + token_type: 'DPoP', + ...overrides, + }), + { status: 200 }, + ), + ), + ); + } + + it('sends a DPoP header and refresh_token grant body to /oauth2/token', async () => { + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + MOCK_PROOF, + ); + const core = new SDKCore(dpopConfig); + seedExistingTokens(core); + const fetchMock = mockTokenResponse(); + + await core.refreshToken(); + + expect(fetchMock).toHaveBeenCalledOnce(); + const call = fetchMock.mock.calls[0]; + if (!call) throw new Error('fetch was not called'); + const [url, init] = call; + expect(new URL(url.toString()).pathname).toBe('/oauth2/token'); + expect(init?.method).toBe('POST'); + + const headers = init?.headers as Record; + expect(headers['DPoP']).toBe(MOCK_PROOF); + expect(headers['Content-Type']).toBe( + 'application/x-www-form-urlencoded', + ); + + const body = new URLSearchParams(init?.body as string); + expect(body.get('grant_type')).toBe('refresh_token'); + expect(body.get('refresh_token')).toBe(MOCK_OLD_REFRESH_TOKEN); + expect(body.get('client_id')).toBe(dpopConfig.clientId); + + expect(DPoPManager.prototype.generateProof).toHaveBeenCalledWith( + expect.stringContaining('/oauth2/token'), + 'POST', + ); + }); + + it('updates stored tokens on success and isLoggedIn remains true', async () => { + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + MOCK_PROOF, + ); + const core = new SDKCore(dpopConfig); + seedExistingTokens(core); + mockTokenResponse(); + + expect(core.isLoggedIn).toBe(true); + + await core.refreshToken(); + + expect(core.isLoggedIn).toBe(true); + expect(core.getAccessToken()).toBe(MOCK_NEW_ACCESS_TOKEN); + }); + + it('reschedules token expiration from the new expiresAt', async () => { + vi.useFakeTimers(); + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + MOCK_PROOF, + ); + const onTokenExpiration = vi.fn(); + const core = new SDKCore({ ...dpopConfig, onTokenExpiration }); + seedExistingTokens(core); + mockTokenResponse(); + + await core.refreshToken(); + + vi.advanceTimersByTime(EXPIRES_IN_SECONDS * 1000 - 1000); + expect(onTokenExpiration).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1000); + expect(onTokenExpiration).toHaveBeenCalledTimes(1); + }); + + it('reschedules auto-refresh from the new expiresAt when shouldAutoRefresh is true', async () => { + vi.useFakeTimers(); + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + MOCK_PROOF, + ); + const core = new SDKCore({ + ...dpopConfig, + shouldAutoRefresh: true, + autoRefreshSecondsBeforeExpiry: 60, + }); + seedExistingTokens(core); + mockTokenResponse(); + + const refreshTokenSpy = vi.spyOn(SDKCore.prototype, 'refreshToken'); + + await core.refreshToken(); + expect(refreshTokenSpy).toHaveBeenCalledTimes(1); + + // Auto-refresh fires 60s before the 3600s expiry + vi.advanceTimersByTime((EXPIRES_IN_SECONDS - 60) * 1000 - 1000); + expect(refreshTokenSpy).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1000); + expect(refreshTokenSpy).toHaveBeenCalledTimes(2); // + the auto-refresh firing + }); + + it('does not reschedule auto-refresh when shouldAutoRefresh is not set', async () => { + vi.useFakeTimers(); + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + vi.spyOn(DPoPManager.prototype, 'generateProof').mockResolvedValue( + MOCK_PROOF, + ); + const core = new SDKCore(dpopConfig); // shouldAutoRefresh defaults to false + seedExistingTokens(core); + mockTokenResponse(); + + const refreshTokenSpy = vi.spyOn(SDKCore.prototype, 'refreshToken'); + + await core.refreshToken(); + + vi.advanceTimersByTime(EXPIRES_IN_SECONDS * 1000); + expect(refreshTokenSpy).toHaveBeenCalledTimes(1); + }); + + it('throws a descriptive error when no refresh token is stored', async () => { + vi.spyOn(DPoPManager.prototype, 'getOrCreateKeyPair').mockResolvedValue( + {} as any, + ); + const fetchMock = vi.spyOn(window, 'fetch'); + const core = new SDKCore(dpopConfig); // no tokens stored — never logged in + + await expect(core.refreshToken()).rejects.toThrow( + 'No refresh token available. Have you called startLogin()?', + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/packages/core/src/SDKCore/SDKCore.ts b/packages/core/src/SDKCore/SDKCore.ts index 8ff59c8..4c6e80b 100644 --- a/packages/core/src/SDKCore/SDKCore.ts +++ b/packages/core/src/SDKCore/SDKCore.ts @@ -160,6 +160,10 @@ export class SDKCore { } async refreshToken(): Promise { + if (this.dpopManager) { + return this.refreshDpopToken(); + } + const response = await fetch(this.urlHelper.getTokenRefreshUrl(), { method: 'POST', credentials: 'include', @@ -184,6 +188,64 @@ export class SDKCore { return response; } + /** + * Performs the DPoP mode refresh token grant. + */ + private async refreshDpopToken(): Promise { + const refreshToken = this.dpopManager!.getRefreshToken(); + if (!refreshToken) { + throw new Error( + 'No refresh token available. Have you called startLogin()?', + ); + } + + const tokenUrl = this.urlHelper.getTokenUrl(); + const proof = await this.dpopManager!.generateProof( + tokenUrl.toString(), + 'POST', + ); + + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: this.config.clientId, + }); + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + DPoP: proof, + }, + body: body.toString(), + }); + + if (!response.ok) { + const errorDetails = { + status: response.status, + details: + (await response.text()) || + 'Failed to refresh fusionauth access token', + }; + throw new Error(JSON.stringify(errorDetails)); + } + + const tokenResponse = await response.clone().json(); + this.dpopManager!.setTokens({ + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token ?? refreshToken, + expiresAt: Date.now() + tokenResponse.expires_in * 1000, + tokenType: 'DPoP', + }); + + this.scheduleTokenExpiration(); + if (this.config.shouldAutoRefresh) { + this.initAutoRefresh(); + } + + return response; + } + initAutoRefresh(): NodeJS.Timeout | undefined { // Clear any pending refresh so repeated calls (e.g. a reactive framework // re-running an effect) doesn't leave duplicate timers running.