From 4b49af7c0bf6e0eaca47152fda60a7943455d5f4 Mon Sep 17 00:00:00 2001 From: John Caveman Date: Thu, 30 Jul 2026 22:52:32 +0800 Subject: [PATCH] fix 'Refresh failed: Invalid token provided' caused by token-fresher request to wrong idc if serviceRegion is not same with ARN region --- src/__tests__/kiro-cli-sync-region.test.ts | 48 ++++++++++++++++++++++ src/__tests__/token-region.test.ts | 40 ++++++++++++++++++ src/core/auth/token-refresher.ts | 27 ++++++++++++ src/plugin/sync/kiro-cli.ts | 24 +++++++++-- 4 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/kiro-cli-sync-region.test.ts create mode 100644 src/__tests__/token-region.test.ts diff --git a/src/__tests__/kiro-cli-sync-region.test.ts b/src/__tests__/kiro-cli-sync-region.test.ts new file mode 100644 index 0000000..e9ba576 --- /dev/null +++ b/src/__tests__/kiro-cli-sync-region.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'bun:test' +import { deriveRegions } from '../plugin/sync/kiro-cli.js' + +// Observed in the wild: an SSO session issued in ap-southeast-1 paired with a +// CodeWhisperer profile in us-east-1. Refreshing at the profile's region fails +// with `invalid_request` / "Invalid token provided" every time, because a +// refresh token is only valid at the OIDC region that issued it. +const SESSION_REGION = 'ap-southeast-1' +const PROFILE_ARN = 'arn:aws:codewhisperer:us-east-1:999995555777:profile/SOMERANDOMAA' + +describe('deriveRegions', () => { + test('keeps the OIDC region from data.region when it differs from the ARN region', () => { + const { serviceRegion, oidcRegion } = deriveRegions(SESSION_REGION, PROFILE_ARN) + + expect(serviceRegion).toBe('us-east-1') + expect(oidcRegion).toBe(SESSION_REGION) + }) + + test('uses data.region for both when there is no profile ARN', () => { + const { serviceRegion, oidcRegion } = deriveRegions(SESSION_REGION, undefined) + + expect(serviceRegion).toBe(SESSION_REGION) + expect(oidcRegion).toBe(SESSION_REGION) + }) + + test('falls back to the ARN region for both when data.region is missing', () => { + for (const missing of [undefined, '', ' ']) { + const { serviceRegion, oidcRegion } = deriveRegions(missing, PROFILE_ARN) + + expect(serviceRegion).toBe('us-east-1') + expect(oidcRegion).toBe('us-east-1') + } + }) + + test('agrees on both regions when the session and profile share a region', () => { + const arn = 'arn:aws:codewhisperer:ap-southeast-1:123:profile/ABC' + const { serviceRegion, oidcRegion } = deriveRegions(SESSION_REGION, arn) + + expect(serviceRegion).toBe(SESSION_REGION) + expect(oidcRegion).toBe(SESSION_REGION) + }) + + test('normalizes an unrecognized data.region instead of trusting it', () => { + const { oidcRegion } = deriveRegions('not-a-region', undefined) + + expect(oidcRegion).toBe('us-east-1') + }) +}) diff --git a/src/__tests__/token-region.test.ts b/src/__tests__/token-region.test.ts new file mode 100644 index 0000000..7ce4418 --- /dev/null +++ b/src/__tests__/token-region.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, mock, test } from 'bun:test' + +let capturedUrl: string | undefined + +mock.module('../kiro/auth.js', () => ({ + decodeRefreshToken: () => ({ refreshToken: 'rt', clientId: 'cid', clientSecret: 'secret' }), + encodeRefreshToken: () => 'encoded-refresh', + accessTokenExpired: () => false +})) + +describe('refreshAccessToken region selection', () => { + test('builds the OIDC refresh URL from oidcRegion, not the service region', async () => { + const originalFetch = globalThis.fetch + ;(globalThis as any).fetch = async (url: string) => { + capturedUrl = url + return { + ok: true, + json: async () => ({ access_token: 'new-access', expires_in: 3600 }) + } as any + } + + try { + const { refreshAccessToken } = await import('../plugin/token.js') + + await refreshAccessToken({ + refresh: 'refresh-token', + access: 'old-access', + expires: 0, + authMethod: 'idc', + region: 'us-east-1', + oidcRegion: 'ap-southeast-1', + profileArn: 'arn:aws:codewhisperer:us-east-1:123:profile/ABC' + } as any) + + expect(capturedUrl).toBe('https://oidc.ap-southeast-1.amazonaws.com/token') + } finally { + globalThis.fetch = originalFetch + } + }) +}) diff --git a/src/core/auth/token-refresher.ts b/src/core/auth/token-refresher.ts index 3a4c3fc..e571f41 100644 --- a/src/core/auth/token-refresher.ts +++ b/src/core/auth/token-refresher.ts @@ -14,6 +14,20 @@ interface TokenRefresherConfig { account_selection_strategy: 'sticky' | 'round-robin' | 'lowest-usage' } +// Observed endpoint responses: +// oidc.{region}.amazonaws.com/token, wrong region -> 400 invalid_request / +// "Invalid token provided" +// oidc.{region}.amazonaws.com/token, spent token -> 400 invalid_grant / +// "Invalid refresh token provided" +function isWrongRegionError(error: any): boolean { + if (error instanceof KiroTokenRefreshError && error.code === 'invalid_request') { + return true + } + const message = error instanceof Error ? error.message : String(error ?? '') + const lower = message.toLowerCase() + return lower.includes('invalid token provided') && !lower.includes('invalid refresh token') +} + export class TokenRefresher { constructor( private config: TokenRefresherConfig, @@ -39,6 +53,18 @@ export class TokenRefresher { await this.repository.save(account) return { account, shouldContinue: false } } catch (e: any) { + if (isWrongRegionError(e)) { + logger.error( + 'Token refresh failed: wrong OIDC region. The refresh token was likely issued at a ' + + 'different region than the one used for this request, so retrying will not help — ' + + 'the account needs its stored region corrected (re-sync from Kiro CLI, or reauth).', + { + email: account.email, + regionUsed: auth.oidcRegion || auth.region, + code: e instanceof KiroTokenRefreshError ? e.code : undefined + } + ) + } return await this.handleRefreshError(e, account, showToast) } } @@ -109,6 +135,7 @@ export class TokenRefresher { error.code === 'HTTP_403' || error.message.includes('Invalid refresh token provided') || error.message.includes('Invalid grant provided') || + error.message.includes('Invalid token provided') || error.message.includes('Client is expired')) ) { this.accountManager.markUnhealthy(account, error.code || error.message) diff --git a/src/plugin/sync/kiro-cli.ts b/src/plugin/sync/kiro-cli.ts index d5d33f9..1f3dd93 100644 --- a/src/plugin/sync/kiro-cli.ts +++ b/src/plugin/sync/kiro-cli.ts @@ -4,6 +4,7 @@ import { extractRegionFromArn, normalizeRegion } from '../../constants' import { createDeterministicAccountId } from '../accounts' import * as logger from '../logger' import { kiroDb } from '../storage/sqlite' +import type { KiroRegion } from '../types' import { fetchUsageLimits } from '../usage' import { findClientCredsRecursive, @@ -19,6 +20,19 @@ import { type SyncedCliAccount } from './stale-accounts' +export function deriveRegions( + dataRegion: unknown, + profileArn: string | undefined +): { serviceRegion: KiroRegion; oidcRegion: KiroRegion } { + const hasDataRegion = typeof dataRegion === 'string' && dataRegion.trim() !== '' + const normalizedDataRegion = normalizeRegion(hasDataRegion ? (dataRegion as string) : undefined) + const serviceRegion = extractRegionFromArn(profileArn) || normalizedDataRegion + return { + serviceRegion, + oidcRegion: hasDataRegion ? normalizedDataRegion : serviceRegion + } +} + export async function syncFromKiroCli() { const dbPath = getCliDbPath() if (!existsSync(dbPath)) return @@ -54,10 +68,7 @@ export async function syncFromKiroCli() { const authMethod = isIdc ? 'idc' : 'desktop' let profileArn: string | undefined = data.profile_arn || data.profileArn if (!profileArn && isIdc) profileArn = activeProfileArn || readActiveProfileArnFromKiroCli() - // serviceRegion wins over data.region: kiro-cli stores data.region as the - // OIDC region (often us-east-1) regardless of where the account actually lives. - const serviceRegion = extractRegionFromArn(profileArn) || normalizeRegion(data.region) - const oidcRegion = serviceRegion + const { serviceRegion, oidcRegion } = deriveRegions(data.region, profileArn) const startUrl: string | undefined = typeof data.start_url === 'string' ? data.start_url @@ -160,8 +171,13 @@ export async function syncFromKiroCli() { const id = createDeterministicAccountId(resolvedEmail, authMethod, clientId, profileArn) const existingById = all.find((a) => a.id === id) + const regionMismatch = + !!existingById && + ((existingById.region || undefined) !== serviceRegion || + (existingById.oidc_region || undefined) !== oidcRegion) if ( existingById && + !regionMismatch && existingById.is_healthy === 1 && existingById.expires_at >= cliExpiresAt && existingById.expires_at > Date.now()