Skip to content
Closed
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
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 true
}
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