Skip to content
Merged
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
125 changes: 125 additions & 0 deletions src/services/oauth/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
exchangeCodeForTokens,
getOrganizationUUID,
refreshOAuthToken,
registerOauthCallbackRelay,
} from './client.js'

const envKeys = [
Expand Down Expand Up @@ -164,6 +165,130 @@ describe('exchangeCodeForTokens', () => {
})
})


describe('registerOauthCallbackRelay', () => {
const originalSetTimeout = globalThis.setTimeout
const originalMathRandom = Math.random

beforeEach(() => {
process.env.NOUMENA_ISSUER_BASE_URL = 'https://auth.noumena.test'
Math.random = () => 0
globalThis.setTimeout = ((handler: TimerHandler, _timeout?: number, ...args: unknown[]) => {
if (typeof handler === 'function') {
handler(...args)
}
return 0 as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
})

afterEach(() => {
Math.random = originalMathRandom
globalThis.setTimeout = originalSetTimeout
})

function axiosError(params: {
code?: string
message?: string
status?: number
}): unknown {
return Object.assign(
new Error(params.message ?? params.code ?? `status ${params.status}`),
{
isAxiosError: true,
code: params.code,
response:
params.status === undefined
? undefined
: {
status: params.status,
},
},
)
}

it('retries transient timeout errors and preserves the registration payload', async () => {
const postCalls: Array<unknown[]> = []
axios.post = (async (...args: unknown[]) => {
postCalls.push(args)
if (postCalls.length === 1) {
throw axiosError({ code: 'ECONNABORTED', message: 'timeout' })
}
return { data: {} }
}) as typeof axios.post

await registerOauthCallbackRelay({
relayId: 'relay-timeout',
state: 'state-timeout',
timeoutMs: 5000,
})

expect(postCalls).toHaveLength(2)
expect(postCalls[0]?.[0]).toBe(
'https://auth.noumena.test/oauth/callback-relay/register',
)
expect(postCalls[0]?.[1]).toEqual({
relay_id: 'relay-timeout',
state: 'state-timeout',
})
expect(postCalls[0]?.[2]).toMatchObject({ timeout: 5000 })
})

it('retries transient 5xx and 429 registration failures', async () => {
const statuses = [500, 429]
for (const status of statuses) {
const postCalls: Array<unknown[]> = []
axios.post = (async (...args: unknown[]) => {
postCalls.push(args)
if (postCalls.length === 1) {
throw axiosError({ status })
}
return { data: {} }
}) as typeof axios.post

await registerOauthCallbackRelay({
relayId: `relay-${status}`,
state: `state-${status}`,
})

expect(postCalls).toHaveLength(2)
}
})

it('fails fast for non-transient 4xx registration failures', async () => {
const postCalls: Array<unknown[]> = []
axios.post = (async (...args: unknown[]) => {
postCalls.push(args)
throw axiosError({ status: 400, message: 'bad request' })
}) as typeof axios.post

await expect(
registerOauthCallbackRelay({
relayId: 'relay-400',
state: 'state-400',
}),
).rejects.toThrow('OAuth callback relay registration failed: bad request')
expect(postCalls).toHaveLength(1)
})

it('fails fast for non-Axios registration errors', async () => {
const postCalls: Array<unknown[]> = []
axios.post = (async (...args: unknown[]) => {
postCalls.push(args)
throw new Error('programming error')
}) as typeof axios.post

await expect(
registerOauthCallbackRelay({
relayId: 'relay-non-axios',
state: 'state-non-axios',
}),
).rejects.toThrow(
'OAuth callback relay registration failed: programming error',
)
expect(postCalls).toHaveLength(1)
})
})

describe('refreshOAuthToken', () => {
it('preserves requested scopes when the refresh response omits scope', async () => {
process.env.NOUMENA_ISSUER_BASE_URL = 'https://issuer.noumena.test'
Expand Down
71 changes: 60 additions & 11 deletions src/services/oauth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,22 +154,71 @@ function getOauthCallbackRelayEndpoint(path: string): string {
return new URL(path, getOauthTokenUrl()).toString()
}

function isTransientError(error: unknown): boolean {
if (!axios.isAxiosError(error)) {
return false
}
const code = error.code
if (
code === 'ECONNABORTED' ||
code === 'ETIMEDOUT' ||
code === 'ECONNRESET' ||
code === 'ENOTFOUND'
) {
return true
}
const status = error.response?.status ?? 0
return status >= 500 || status === 429
}

async function withTransientRetry<T>(
fn: () => Promise<T>,
attempts = 3,
baseMs = 200,
): Promise<T> {
let lastError: unknown = new Error('withTransientRetry did not execute')
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await fn()
} catch (error) {
lastError = error
if (attempt >= attempts || !isTransientError(error)) {
break
}
logForDebugging(
`Transient retry ${attempt}/${attempts}: ${error instanceof Error ? error.message : String(error)}`,
)
const jitter = Math.random() * baseMs
await new Promise(resolve => setTimeout(resolve, baseMs * 2 ** (attempt - 1) + jitter))
}
}
throw lastError
}

export async function registerOauthCallbackRelay(params: {
relayId: string
state: string
timeoutMs?: number
}): Promise<void> {
await axios.post(
getOauthCallbackRelayEndpoint('/oauth/callback-relay/register'),
{
relay_id: params.relayId,
state: params.state,
},
{
headers: { 'Content-Type': 'application/json' },
timeout: params.timeoutMs ?? 1000,
},
)
try {
await withTransientRetry(() =>
axios.post(
getOauthCallbackRelayEndpoint('/oauth/callback-relay/register'),
{
relay_id: params.relayId,
state: params.state,
},
{
headers: { 'Content-Type': 'application/json' },
timeout: params.timeoutMs ?? 1000,
},
),
)
} catch (error) {
throw new Error(
`OAuth callback relay registration failed: ${error instanceof Error ? error.message : String(error)}`,
)
}
}

export async function pollOauthCallbackRelay(
Expand Down
43 changes: 6 additions & 37 deletions src/services/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ import type {
SubscriptionType,
} from './types.js'

const CALLBACK_RELAY_REGISTRATION_ATTEMPTS = 3
const CALLBACK_RELAY_REGISTRATION_TIMEOUT_MS = 1000
const CALLBACK_RELAY_REGISTRATION_TIMEOUT_MS = 5000
const CALLBACK_RELAY_POLL_INTERVAL_MS = 250
const LOGIN_PROFILE_FETCH_TIMEOUT_MS = 1000

Expand Down Expand Up @@ -70,7 +69,11 @@ export class OAuthService {
const codeChallenge = crypto.generateCodeChallenge(this.codeVerifier)
const state = crypto.generateState()
const manualRelayId = crypto.generateState()
await this.registerCallbackRelayOrThrow(manualRelayId, state)
await client.registerOauthCallbackRelay({
relayId: manualRelayId,
state,
timeoutMs: CALLBACK_RELAY_REGISTRATION_TIMEOUT_MS,
})

// Build auth URLs for both automatic and manual flows
const opts = {
Expand Down Expand Up @@ -160,40 +163,6 @@ export class OAuthService {
}
}

private async registerCallbackRelayOrThrow(
relayId: string,
state: string,
): Promise<void> {
let lastError: unknown
for (let attempt = 1; attempt <= CALLBACK_RELAY_REGISTRATION_ATTEMPTS; attempt += 1) {
try {
await client.registerOauthCallbackRelay({
relayId,
state,
timeoutMs: CALLBACK_RELAY_REGISTRATION_TIMEOUT_MS,
})
if (attempt > 1) {
logForDebugging(
`OAuth callback relay registration recovered on attempt ${attempt}`,
)
}
return
} catch (error) {
lastError = error
logForDebugging(
`OAuth callback relay registration attempt ${attempt} failed: ${oauthErrorMessage(error)}`,
)
if (attempt < CALLBACK_RELAY_REGISTRATION_ATTEMPTS) {
await delay(150 * attempt)
}
}
}

throw new Error(
`OAuth callback relay registration failed after ${CALLBACK_RELAY_REGISTRATION_ATTEMPTS} attempts: ${oauthErrorMessage(lastError)}`,
)
}

private async waitForAuthorizationCode(
state: string,
onReady: () => Promise<void>,
Expand Down
Loading