diff --git a/src/client.ts b/src/client.ts index 532c942..ee27015 100644 --- a/src/client.ts +++ b/src/client.ts @@ -334,7 +334,9 @@ export class StuffleIAMClient { } /** - * Refresh access token using refresh token + * Refresh access token using refresh token. + * Guards against server-side refresh_token grant being disabled + * (checked via discovery.grant_types_supported). */ async refreshToken(): Promise { const refreshToken = this.storage.get(STORAGE_KEYS.REFRESH_TOKEN); @@ -343,10 +345,17 @@ export class StuffleIAMClient { return null; } - console.debug('[IAMClient] refreshToken: attempting token refresh'); - const discovery = await this.getDiscovery(); + // Respect server-side grant_types_supported — if refresh_token grant is + // disabled in the IAM control plane it will not appear here. + if (!discovery.grant_types_supported?.includes('refresh_token')) { + console.warn('[IAMClient] refreshToken: server does not support refresh_token grant — autoRefresh disabled'); + return null; + } + + console.debug('[IAMClient] refreshToken: attempting token refresh'); + const response = await fetch(discovery.token_endpoint, { method: 'POST', headers: { @@ -542,9 +551,13 @@ export class StuffleIAMClient { } /** - * Setup auto-refresh timer + * Setup auto-refresh timer. + * Only runs when both client config (autoRefresh) AND server capability + * (grant_types_supported includes refresh_token) are satisfied. */ private setupAutoRefresh(): void { + if (!this.config.autoRefresh) return; + if (this.refreshTimer) { clearTimeout(this.refreshTimer); } diff --git a/src/types.ts b/src/types.ts index 472b49c..7425601 100644 --- a/src/types.ts +++ b/src/types.ts @@ -97,7 +97,7 @@ export interface OIDCDiscovery { registration_endpoint?: string; scopes_supported: string[]; response_types_supported: string[]; - grant_types_supported: string[]; + grant_types_supported?: string[]; token_endpoint_auth_methods_supported: string[]; code_challenge_methods_supported?: string[]; } diff --git a/tests/refresh-token.integration.test.ts b/tests/refresh-token.integration.test.ts index e9ce04e..046f455 100644 --- a/tests/refresh-token.integration.test.ts +++ b/tests/refresh-token.integration.test.ts @@ -82,6 +82,16 @@ function expiredJwt(): string { return `${header}.${payload}.fakesig` } +// ── Shared state for real-IAM tests (handles refresh token rotation) ───────── +// +// IMPORTANT: IAM uses refresh token rotation (single-use tokens). +// All real-IAM tests share a single client and storage — each test uses the +// rotated refresh token left behind by the previous step. +// Tests MUST run in order and share the same client instance. + +let sharedClient: StuffleIAMClient +let t1Result: Awaited> + // ── Pre-flight check ─────────────────────────────────────────────────────────── beforeAll(async () => { @@ -98,32 +108,32 @@ beforeAll(async () => { console.error(`❌ IAM not reachable at ${IAM_ISSUER}:`, e) throw e } + + // Seed the initial refresh token — shared across all real-IAM tests + sharedClient = makeClient() + seedStorage(INITIAL_REFRESH_TOKEN) }) // ── Tests ────────────────────────────────────────────────────────────────────── -describe('refreshToken() — real IAM', () => { - it.skipIf(!!SKIP_IF_NO_TOKEN)('T1: succeeds with valid refresh token and returns new tokens', async () => { - const client = makeClient() - seedStorage(INITIAL_REFRESH_TOKEN) +describe('refreshToken() — real IAM (sequential, shares rotated token)', () => { + it.skipIf(!!SKIP_IF_NO_TOKEN)('T1: refreshToken() succeeds and returns new tokens', async () => { + // NOTE: This consumes INITIAL_REFRESH_TOKEN. The rotated token is stored + // in localStorage by storeTokens() and used by subsequent tests. + t1Result = await sharedClient.refreshToken() - const result = await client.refreshToken() + expect(t1Result).not.toBeNull() + expect(t1Result!.access_token).toBeTruthy() + expect(t1Result!.refresh_token).toBeTruthy() + expect(typeof t1Result!.expires_in).toBe('number') + expect(t1Result!.expires_in).toBeGreaterThan(0) - expect(result).not.toBeNull() - expect(result!.access_token).toBeTruthy() - expect(result!.refresh_token).toBeTruthy() - expect(typeof result!.expires_in).toBe('number') - expect(result!.expires_in).toBeGreaterThan(0) - - console.log(` → new token expires_in: ${result!.expires_in}s`) + console.log(` → new token expires_in: ${t1Result!.expires_in}s`) + console.log(` → rotated refresh token stored for T2–T7`) }) - it.skipIf(!!SKIP_IF_NO_TOKEN)('T2: stores new tokens in localStorage after refresh', async () => { - const client = makeClient() - seedStorage(INITIAL_REFRESH_TOKEN) - - await client.refreshToken() - + it.skipIf(!!SKIP_IF_NO_TOKEN)('T2: storeTokens() writes new access token, refresh token, and expires_at', () => { + // After T1 — storage should have the new rotated tokens const storedAt = localStorage.getItem('stuffle_iam_access_token') const storedRt = localStorage.getItem('stuffle_iam_refresh_token') const storedExp = localStorage.getItem('stuffle_iam_expires_at') @@ -132,52 +142,47 @@ describe('refreshToken() — real IAM', () => { expect(storedRt).toBeTruthy() expect(storedExp).toBeTruthy() expect(parseInt(storedExp!)).toBeGreaterThan(Date.now()) + // Rotated refresh token must differ from the seed token + expect(storedRt).not.toBe(INITIAL_REFRESH_TOKEN) }) - it.skipIf(!!SKIP_IF_NO_TOKEN)('T3: getAccessToken() with NO access token triggers refreshToken()', async () => { - const client = makeClient() - // Only seed refresh token — no access token - seedStorage(INITIAL_REFRESH_TOKEN) + it.skipIf(!!SKIP_IF_NO_TOKEN)('T3: getAccessToken() with NO access token triggers refresh', async () => { + // Remove access token but keep the rotated refresh token from T1 + localStorage.removeItem('stuffle_iam_access_token') - const token = await client.getAccessToken() + const token = await sharedClient.getAccessToken() expect(token).toBeTruthy() - // Access token should now be in storage expect(localStorage.getItem('stuffle_iam_access_token')).toBeTruthy() + console.log(' → getAccessToken() silently refreshed when access token absent') }) - it.skipIf(!!SKIP_IF_NO_TOKEN)('T4: getAccessToken() with expired access token triggers refreshToken()', async () => { - const client = makeClient() - seedStorage(INITIAL_REFRESH_TOKEN, expiredJwt()) + it.skipIf(!!SKIP_IF_NO_TOKEN)('T4: getAccessToken() with expired access token triggers refresh', async () => { + // Replace access token with a fake expired one, keep rotated refresh token + localStorage.setItem('stuffle_iam_access_token', expiredJwt()) + localStorage.setItem('stuffle_iam_expires_at', (Date.now() - 1000).toString()) - const token = await client.getAccessToken() + const token = await sharedClient.getAccessToken() expect(token).toBeTruthy() - // New token must differ from the fake expired one const stored = localStorage.getItem('stuffle_iam_access_token') expect(stored).not.toBe(expiredJwt()) + console.log(' → getAccessToken() refreshed when access token expired') }) - it.skipIf(!!SKIP_IF_NO_TOKEN)('T7: simulateExpiry flow — remove access token → getAccessToken() → still authenticated', async () => { - const client = makeClient() - - // Step 1: seed a valid session (simulates app on page load with fresh tokens) - seedStorage(INITIAL_REFRESH_TOKEN) - const firstRefresh = await client.refreshToken() - expect(firstRefresh).not.toBeNull() - - // Step 2: simulate the hourly expiry — remove just the access token + it.skipIf(!!SKIP_IF_NO_TOKEN)('T7: simulateExpiry — remove access token → getAccessToken() → isAuthenticated()', async () => { + // Simulate the exact hourly logout scenario: + // access token gone, refresh token intact (from T4's rotation) localStorage.removeItem('stuffle_iam_access_token') localStorage.setItem('stuffle_iam_expires_at', (Date.now() - 1000).toString()) - expect(client.isAuthenticated()).toBe(false) // confirms access token gone + expect(sharedClient.isAuthenticated()).toBe(false) - // Step 3: call getAccessToken() — should transparently refresh - const newToken = await client.getAccessToken() + const newToken = await sharedClient.getAccessToken() expect(newToken).toBeTruthy() - expect(client.isAuthenticated()).toBe(true) - console.log(' → isAuthenticated() = true after simulated expiry + refresh') + expect(sharedClient.isAuthenticated()).toBe(true) + console.log(' → ✅ isAuthenticated() = true after simulated expiry + transparent refresh') }) }) diff --git a/tests/setup.ts b/tests/setup.ts index 27a23ad..aa12761 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -6,8 +6,6 @@ * We wire them into the jsdom window so the SDK can find them. */ -import { beforeEach } from 'vitest' - // Node 18+ crypto → make it available as window.crypto if (typeof window !== 'undefined' && !window.crypto) { Object.defineProperty(window, 'crypto', { @@ -15,13 +13,4 @@ if (typeof window !== 'undefined' && !window.crypto) { writable: false, }) } - -// Clear all stuffle_iam_ localStorage keys before each test -beforeEach(() => { - const keysToRemove: string[] = [] - for (let i = 0; i < localStorage.length; i++) { - const k = localStorage.key(i) - if (k?.startsWith('stuffle_iam_')) keysToRemove.push(k) - } - keysToRemove.forEach(k => localStorage.removeItem(k)) -}) +// Each test manages its own storage state via seedStorage() / clearStorage() helpers.