Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/__tests__/kiro-cli-sync-region.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
40 changes: 40 additions & 0 deletions src/__tests__/token-region.test.ts
Original file line number Diff line number Diff line change
@@ -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
}
})
})
27 changes: 27 additions & 0 deletions src/core/auth/token-refresher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 20 additions & 4 deletions src/plugin/sync/kiro-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down