fix(client): respect server grant_types_supported for refresh; fix test token rotation
Some checks failed
Stuffle/iam-client-sdk/pipeline/pr-main There was a failure building this commit

- refreshToken(): check discovery.grant_types_supported includes 'refresh_token'
  before attempting — respects IAM control-plane toggle (DB config)
- setupAutoRefresh(): guard on autoRefresh config flag (was missing)
- OIDCDiscovery.grant_types_supported: made optional (some OIDC servers omit it)
- Tests: redesign real-IAM suite to share one client+storage across sequential
  tests (handles refresh token rotation — each token is single-use)
- Tests: remove beforeEach storage clear (tests manage their own state)
This commit is contained in:
2026-05-03 20:11:36 +05:30
parent 3c9bc6e57c
commit 7e9faa551c
4 changed files with 67 additions and 60 deletions

View File

@@ -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<TokenResponse | null> {
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);
}

View File

@@ -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[];
}

View File

@@ -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<ReturnType<StuffleIAMClient['refreshToken']>>
// ── 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 T2T7`)
})
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')
})
})

View File

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