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
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user