diff --git a/__tests__/e2e/__snapshots__/stale-auth.e2e.ts.snap b/__tests__/e2e/__snapshots__/stale-auth.e2e.ts.snap index b84379c..8507f6f 100644 --- a/__tests__/e2e/__snapshots__/stale-auth.e2e.ts.snap +++ b/__tests__/e2e/__snapshots__/stale-auth.e2e.ts.snap @@ -23,6 +23,49 @@ exports[`when auth token is stale > allows re-authentication after expired token ↑↓ navigate enter confirm vX.Y.Z" `; +exports[`when auth token is stale > allows signing in after failed refresh and proceeds normally > auth-refresh-failed-then-signed-in 1`] = ` +" Confidence by Spotify https://confidence.spotify.com/ + + Teach your AI Confidence Todo (2/5) + + Plugins give your agent tool Confidence-specific skills — flag ● Check system + management, warehouse setup, migrations, onboarding — no more ● Sign in to Confidence + searching docs yourself. ▶ Set up your agent + ○ Connect tools + ○ Onboard project + + + ────────────────────────────────────────────────────────────────────────────────────────────────── + Which agent tool are you using? + + ❯ Claude Code + Cursor + Codex + Skip (install manually later) + + ↑↓ navigate enter confirm vX.Y.Z" +`; + +exports[`when auth token is stale > falls back to sign-in when choosing existing account and refresh fails > auth-refresh-failed 1`] = ` +" Confidence by Spotify https://confidence.spotify.com/ + + Sign in to Confidence Todo (1/5) + + Sign in so the wizard can create flags and set up your project. ● Check system + ▶ Sign in to Confidence + Your session seems to be expired. Please sign in again. ○ Set up your agent + ○ Connect tools + ○ Onboard project + + + ────────────────────────────────────────────────────────────────────────────────────────────────── + We'll open your browser to sign in. Continue? + + ❯ Sign in to a Confidence account + + enter confirm vX.Y.Z" +`; + exports[`when auth token is stale > prompts user to sign in again instead of using the expired token > auth-expired 1`] = ` " Confidence by Spotify https://confidence.spotify.com/ @@ -43,6 +86,29 @@ exports[`when auth token is stale > prompts user to sign in again instead of usi enter confirm vX.Y.Z" `; +exports[`when auth token is stale > refreshes and authenticates when choosing existing account > auth-refreshed 1`] = ` +" Confidence by Spotify https://confidence.spotify.com/ + + Teach your AI Confidence Todo (2/5) + + Plugins give your agent tool Confidence-specific skills — flag ● Check system + management, warehouse setup, migrations, onboarding — no more ● Sign in to Confidence + searching docs yourself. ▶ Set up your agent + ○ Connect tools + ○ Onboard project + + + ────────────────────────────────────────────────────────────────────────────────────────────────── + Which agent tool are you using? + + ❯ Claude Code + Cursor + Codex + Skip (install manually later) + + ↑↓ navigate enter confirm vX.Y.Z" +`; + exports[`when auth token is stale > shows "Use existing account" when the token is still valid > auth-existing 1`] = ` " Confidence by Spotify https://confidence.spotify.com/ diff --git a/__tests__/e2e/helpers/index.ts b/__tests__/e2e/helpers/index.ts index 5169830..2016adf 100644 --- a/__tests__/e2e/helpers/index.ts +++ b/__tests__/e2e/helpers/index.ts @@ -22,12 +22,14 @@ export function createSession({ extraArgs = [], env = {}, token, + refreshToken = 'e2e-refresh-token', systemPath, }: { project?: ProjectType; extraArgs?: string[]; env?: Record; token?: string; + refreshToken?: string | null; systemPath?: string; } = {}): TerminalSession { const mockBinDir = process.env.E2E_MOCK_BIN_DIR!; @@ -47,7 +49,12 @@ export function createSession({ if (token) { const tokenDir = mkdtempSync(join(tmpdir(), 'e2e-tmp-')); + writeFileSync(join(tokenDir, 'confidence_token'), token, 'utf-8'); + if (refreshToken) { + writeFileSync(join(tokenDir, 'confidence_refresh_token'), refreshToken, 'utf-8'); + } + sessionEnv.TMPDIR = tokenDir; } diff --git a/__tests__/e2e/stale-auth.e2e.ts b/__tests__/e2e/stale-auth.e2e.ts index 11c9811..a75e558 100644 --- a/__tests__/e2e/stale-auth.e2e.ts +++ b/__tests__/e2e/stale-auth.e2e.ts @@ -31,6 +31,34 @@ describe('when auth token is stale', () => { expect(session.snapshot()).toMatchSnapshot('auth-existing'); }); + it('refreshes and authenticates when choosing existing account', async () => { + using session = createSession({ token: buildTestJwt() }); + + await navigatePastWelcome(session); + + // Authenticate — click "Use existing account", token is refreshed via mock server + await session.waitForText('Use existing account'); + await session.sendKey(ENTER); + await session.waitForText('Authenticated'); + + // Continues to InstallPlugins + await session.waitForText('Which agent tool are you using?'); + expect(session.snapshot()).toMatchSnapshot('auth-refreshed'); + }); + + it('falls back to sign-in when choosing existing account and refresh fails', async () => { + using session = createSession({ token: buildTestJwt(), refreshToken: null }); + + await navigatePastWelcome(session); + + // Authenticate — click "Use existing account", refresh fails (no refresh token) + await session.waitForText('Use existing account'); + await session.sendKey(ENTER); + await session.waitForText('session seems to be expired'); + await session.waitForText('Sign in to a Confidence account'); + expect(session.snapshot()).toMatchSnapshot('auth-refresh-failed'); + }); + it('allows re-authentication after expired token and proceeds normally', async () => { using session = createSession({ token: buildExpiredJwt() }); @@ -48,6 +76,28 @@ describe('when auth token is stale', () => { expect(session.snapshot()).toMatchSnapshot('auth-re-authenticated'); }); + it('allows signing in after failed refresh and proceeds normally', async () => { + using session = createSession({ token: buildTestJwt(), refreshToken: null }); + + await navigatePastWelcome(session); + + // Authenticate — click "Use existing account", refresh fails + await session.waitForText('Use existing account'); + await session.sendKey(ENTER); + await session.waitForText('session seems to be expired'); + + // Sign in via browser + await session.waitForText('Sign in to a Confidence account'); + await session.sendKey(ENTER); + await session.waitForText('Waiting for browser'); + await simulateAuthCallback(); + await session.waitForText('Authenticated'); + + // Continues to InstallPlugins + await session.waitForText('Which agent tool are you using?'); + expect(session.snapshot()).toMatchSnapshot('auth-refresh-failed-then-signed-in'); + }); + it('treats a near-expiry token as valid', async () => { const nearExpiryJwt = buildTestJwt({ exp: Math.floor(Date.now() / 1000) + 5 }); using session = createSession({ token: nearExpiryJwt }); diff --git a/__tests__/msw/setup.ts b/__tests__/msw/setup.ts index 9bde1ef..1e7fcdd 100644 --- a/__tests__/msw/setup.ts +++ b/__tests__/msw/setup.ts @@ -7,10 +7,14 @@ const ALLOWED_HOSTS: string[] = []; beforeAll(() => server.listen({ - onUnhandledRequest(request, print) { + onUnhandledRequest(request) { const url = new URL(request.url); + if (ALLOWED_HOSTS.includes(url.hostname)) return; - print.warning(); + + throw new Error( + `[MSW] Unhandled ${request.method} ${url.href}. Add a handler or allowlist the host.`, + ); }, }), ); diff --git a/__tests__/ui/helpers/auth.ts b/__tests__/ui/helpers/auth.ts index c3574ce..7586970 100644 --- a/__tests__/ui/helpers/auth.ts +++ b/__tests__/ui/helpers/auth.ts @@ -1,3 +1,6 @@ +import { writeFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import type { AuthState } from '@lib/session.js'; function base64url(str: string): string { @@ -28,3 +31,29 @@ export function buildAuthState(token?: string): AuthState { region: 'EU', }; } + +export function persistTestTokens(token: string, refreshToken?: string) { + const tokenPath = join(tmpdir(), 'confidence_token'); + const refreshPath = join(tmpdir(), 'confidence_refresh_token'); + const config = { encoding: 'utf-8', mode: 0o600 } as const; + + writeFileSync(tokenPath, token, config); + if (refreshToken) { + writeFileSync(refreshPath, refreshToken, config); + } + + return { + [Symbol.dispose]() { + try { + unlinkSync(tokenPath); + } catch { + // File may not exist + } + try { + unlinkSync(refreshPath); + } catch { + // File may not exist + } + }, + }; +} diff --git a/__tests__/ui/helpers/index.ts b/__tests__/ui/helpers/index.ts index c8f571a..275c484 100644 --- a/__tests__/ui/helpers/index.ts +++ b/__tests__/ui/helpers/index.ts @@ -5,4 +5,4 @@ export { delay } from './delay.js'; export { waitFor } from './waitFor.js'; export { createProjectDir } from './project.js'; export { createFakeChild, mockNextSpawn } from './spawn.js'; -export { buildTestJwt, buildExpiredJwt, buildAuthState } from './auth.js'; +export { buildTestJwt, buildExpiredJwt, buildAuthState, persistTestTokens } from './auth.js'; diff --git a/__tests__/ui/screens/AuthenticateScreen.test.tsx b/__tests__/ui/screens/AuthenticateScreen.test.tsx index e735608..f5e3377 100644 --- a/__tests__/ui/screens/AuthenticateScreen.test.tsx +++ b/__tests__/ui/screens/AuthenticateScreen.test.tsx @@ -1,21 +1,38 @@ -import { renderScreen, renderApp, createProjectDir, ENTER, waitFor } from '../helpers/index.js'; +import { http, HttpResponse } from 'msw'; +import { + renderScreen, + renderApp, + createProjectDir, + buildTestJwt, + persistTestTokens, + ENTER, + waitFor, +} from '../helpers/index.js'; import { AuthenticateScreen } from '@ui/tui/screens/authenticate/index.js'; import { ScreenId } from '@lib/session.js'; +import { server } from '../../msw/server.js'; + +// Mocking `authenticate` because it opens +// a real browser and starts a local HTTP server. +vi.mock('../../../src/lib/auth.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + authenticate: vi.fn().mockResolvedValue({ + accessToken: 'test-token', + refreshToken: 'test-refresh', + region: 'EU' as const, + workspace: 'test@example.com', + }), + }; +}); -vi.mock('../../../src/lib/auth.js', () => ({ - loadPersistedToken: vi.fn().mockReturnValue(null), - validateToken: vi.fn().mockReturnValue({ valid: false }), - authenticate: vi.fn().mockResolvedValue({ - accessToken: 'test-token', - refreshToken: 'test-refresh', - region: 'EU' as const, - workspace: 'test@example.com', - }), -})); +const testOpts = { screen: ScreenId.Authenticate }; describe('AuthenticateScreen', () => { it('renders title', async () => { - using sut = renderScreen(, { screen: ScreenId.Authenticate }); + using sut = renderScreen(, testOpts); + await waitFor(() => { expect(sut.lastFrame()).toContain('Sign in to Confidence'); }); @@ -23,7 +40,8 @@ describe('AuthenticateScreen', () => { describe('when no existing token', () => { it('shows sign-in option', async () => { - using sut = renderScreen(, { screen: ScreenId.Authenticate }); + using sut = renderScreen(, testOpts); + await waitFor(() => { expect(sut.lastFrame()).toContain('Sign in to a Confidence account'); }); @@ -50,7 +68,7 @@ describe('AuthenticateScreen', () => { }); it('shows authenticated state after sign in', async () => { - using sut = renderScreen(, { screen: ScreenId.Authenticate }); + using sut = renderScreen(, testOpts); sut.stdin.write(ENTER); @@ -62,20 +80,76 @@ describe('AuthenticateScreen', () => { describe('when existing token is found', () => { it('shows existing account options', async () => { - const { loadPersistedToken, validateToken } = await import('../../../src/lib/auth.js'); - vi.mocked(loadPersistedToken).mockReturnValueOnce('existing-jwt'); - vi.mocked(validateToken).mockReturnValueOnce({ - valid: true, - region: 'EU', - workspace: 'existing@example.com', - }); + using _tokens = persistTestTokens(buildTestJwt({ email: 'existing@example.com' })); + + using sut = renderScreen(, testOpts); - using sut = renderScreen(, { screen: ScreenId.Authenticate }); await waitFor(() => { expect(sut.lastFrame()).toContain('existing@example.com'); expect(sut.lastFrame()).toContain('Use existing account'); }); }); + + it('refreshes token when confirming existing account', async () => { + // Arrange + using _tokens = persistTestTokens( + buildTestJwt({ email: 'existing@example.com' }), + 'test-refresh-token', + ); + + server.use( + http.post('https://auth.confidence.dev/oauth/token', () => + HttpResponse.json({ + access_token: buildTestJwt({ email: 'existing@example.com' }), + refresh_token: 'new-refresh-token', + token_type: 'Bearer', + expires_in: 86400, + }), + ), + ); + + using sut = renderScreen(, testOpts); + await waitFor(() => { + expect(sut.lastFrame()).toContain('Use existing account'); + }); + + // Act + sut.stdin.write(ENTER); + + // Assert + await waitFor(() => { + expect(sut.lastFrame()).toContain('Authenticated'); + }); + }); + + it('falls back to sign-in when token refresh fails', async () => { + // Arrange + using _tokens = persistTestTokens( + buildTestJwt({ email: 'existing@example.com' }), + 'test-refresh-token', + ); + + server.use( + http.post( + 'https://auth.confidence.dev/oauth/token', + () => new HttpResponse(null, { status: 401 }), + ), + ); + + using sut = renderScreen(, testOpts); + await waitFor(() => { + expect(sut.lastFrame()).toContain('Use existing account'); + }); + + // Act + sut.stdin.write(ENTER); + + // Assert + await waitFor(() => { + expect(sut.lastFrame()).toContain('session seems to be expired'); + expect(sut.lastFrame()).toContain('Sign in to a Confidence account'); + }); + }); }); describe('when authentication fails', () => { @@ -83,7 +157,7 @@ describe('AuthenticateScreen', () => { const { authenticate } = await import('../../../src/lib/auth.js'); vi.mocked(authenticate).mockRejectedValueOnce(new Error('Network error')); - using sut = renderScreen(, { screen: ScreenId.Authenticate }); + using sut = renderScreen(, testOpts); sut.stdin.write(ENTER); @@ -104,9 +178,10 @@ describe('AuthenticateScreen', () => { region: 'EU' as const, workspace: 'retry@example.com', }); - using sut = renderScreen(, { screen: ScreenId.Authenticate }); - // Act — trigger first attempt (fails) + using sut = renderScreen(, testOpts); + + // Trigger first attempt (fails) sut.stdin.write(ENTER); await waitFor(() => { diff --git a/__tests__/ui/screens/InstallPluginsScreen.test.tsx b/__tests__/ui/screens/InstallPluginsScreen.test.tsx index 0ca79bb..c238ad5 100644 --- a/__tests__/ui/screens/InstallPluginsScreen.test.tsx +++ b/__tests__/ui/screens/InstallPluginsScreen.test.tsx @@ -20,17 +20,6 @@ vi.mock('../../../src/integrations/plugins.js', async (importOriginal) => { }; }); -vi.mock('../../../src/lib/auth.js', () => ({ - loadPersistedToken: vi.fn().mockReturnValue(null), - validateToken: vi.fn().mockReturnValue({ valid: false }), - authenticate: vi.fn().mockResolvedValue({ - accessToken: 'test-token', - refreshToken: 'test-refresh', - region: 'EU' as const, - workspace: 'test@example.com', - }), -})); - describe('InstallPluginsScreen', () => { it('renders title', async () => { using sut = renderScreen(, { screen: ScreenId.InstallPlugins }); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index b4c11fa..0368e9e 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -67,6 +67,15 @@ export function loadPersistedToken(): string | null { } } +function loadPersistedRefreshToken(): string | null { + if (!existsSync(REFRESH_TOKEN_FILE)) return null; + try { + return readFileSync(REFRESH_TOKEN_FILE, 'utf-8').trim(); + } catch { + return null; + } +} + export function validateToken(token: string): { valid: boolean; region?: 'EU' | 'US'; @@ -96,6 +105,42 @@ type AuthResult = { workspace?: string; }; +export async function refreshAccessToken(): Promise { + const refreshToken = loadPersistedRefreshToken(); + if (!refreshToken) { + throw new Error('No refresh token available'); + } + + const body = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: AUTH_CLIENT_ID_LOGIN, + refresh_token: refreshToken, + }); + + const response = await fetch(`${AUTH_BASE_URL}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + throw new Error('Session expired'); + } + + const data = (await response.json()) as TokenResponse; + persistTokens(data.access_token, data.refresh_token); + + const region = extractRegion(data.access_token); + const { workspace } = validateToken(data.access_token); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + region, + workspace, + }; +} + export function authenticate(mode: 'signup' | 'login', signal?: AbortSignal): Promise { const clientId = mode === 'signup' ? AUTH_CLIENT_ID_SIGNUP : AUTH_CLIENT_ID_LOGIN; const { verifier, challenge } = generatePKCE(); diff --git a/src/ui/tui/screens/authenticate/AuthenticateScreen.tsx b/src/ui/tui/screens/authenticate/AuthenticateScreen.tsx index e2965d6..ec51010 100644 --- a/src/ui/tui/screens/authenticate/AuthenticateScreen.tsx +++ b/src/ui/tui/screens/authenticate/AuthenticateScreen.tsx @@ -16,7 +16,7 @@ import * as te from './telemetry-events.js'; export function AuthenticateScreen() { const log = useLogger(ScreenId.Authenticate); - const { phase, error, workspace, startAuth, cancelAuth, confirmExisting, resetToChoose } = + const { phase, error, notice, workspace, startAuth, cancelAuth, confirmExisting, resetToChoose } = useAuthFlow(); useAutoAdvance({ @@ -48,7 +48,13 @@ export function AuthenticateScreen() { - {phase === 'checking' && } + {notice && ( + + {notice} + + )} + + {phase === 'checking' && } {phase === 'waiting-browser' && ( diff --git a/src/ui/tui/screens/authenticate/log-messages.ts b/src/ui/tui/screens/authenticate/log-messages.ts index 5b97481..508eb35 100644 --- a/src/ui/tui/screens/authenticate/log-messages.ts +++ b/src/ui/tui/screens/authenticate/log-messages.ts @@ -9,3 +9,10 @@ export function authCompleted( output: `Authenticated${workspace ? ` as ${workspace}` : ''}${region ? ` (${region})` : ''}`, }; } + +export function authRefreshFailed(): LogMessage { + return { + input: 'Use existing account', + output: 'Session could not be verified — prompting re-authentication', + }; +} diff --git a/src/ui/tui/screens/authenticate/telemetry-events.ts b/src/ui/tui/screens/authenticate/telemetry-events.ts index 49168ed..c7d1f4b 100644 --- a/src/ui/tui/screens/authenticate/telemetry-events.ts +++ b/src/ui/tui/screens/authenticate/telemetry-events.ts @@ -20,6 +20,10 @@ export function authRetried(): TelemetryEvent { return { step: 'authenticate.retry', action: 'retried' }; } +export function authRefreshFailed(): TelemetryEvent { + return { step: 'authenticate.refresh', action: 'failed', sentiment: 'frustrated' }; +} + export function authQuit(): TelemetryEvent { return { step: 'authenticate.quit', diff --git a/src/ui/tui/screens/authenticate/useAuthFlow.ts b/src/ui/tui/screens/authenticate/useAuthFlow.ts index 59117a2..d097f5a 100644 --- a/src/ui/tui/screens/authenticate/useAuthFlow.ts +++ b/src/ui/tui/screens/authenticate/useAuthFlow.ts @@ -1,9 +1,9 @@ import { useRef, useState } from 'react'; -import { authenticate } from '@lib/auth.js'; +import { authenticate, refreshAccessToken } from '@lib/auth.js'; import { $session, store } from '../../store.js'; import { useInitialAuth } from './useInitialAuth.js'; import { track } from '@lib/telemetry.js'; -import { authFailed } from './telemetry-events.js'; +import { authFailed, authRefreshFailed } from './telemetry-events.js'; export type AuthPhase = 'checking' | 'has-existing' | 'choose-action' | 'waiting-browser' | 'authenticated' | 'failed'; @@ -11,6 +11,7 @@ export type AuthPhase = export type AuthFlowState = { phase: AuthPhase; error: string | null; + notice: string | null; workspace: string | null; startAuth: (mode: 'signup' | 'login') => void; cancelAuth: () => void; @@ -22,12 +23,14 @@ export function useAuthFlow(): AuthFlowState { const initial = useInitialAuth(); const [phase, setPhase] = useState(initial.phase); const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); const [workspace, setWorkspace] = useState(initial.workspace); const abortRef = useRef(null); function startAuth(mode: 'signup' | 'login') { setPhase('waiting-browser'); setError(null); + setNotice(null); if ($session.get().dryRun) return startDryRunAuth(); startRealAuth(mode); @@ -88,16 +91,47 @@ export function useAuthFlow(): AuthFlowState { } function confirmExisting() { - setPhase('authenticated'); + setPhase('checking'); + setError(null); + + if ($session.get().dryRun) return confirmDryRun(); + confirmReal(); + + function confirmDryRun() { + setPhase('authenticated'); + } + + function confirmReal() { + refreshAccessToken() + .then((result) => { + store.setAuthState({ + status: 'authenticated', + token: result.accessToken, + refreshToken: result.refreshToken, + region: result.region, + workspace: result.workspace, + }); + setWorkspace(result.workspace ?? null); + setPhase('authenticated'); + }) + .catch(() => { + store.setAuthState({ status: 'idle' }); + setNotice('Your session seems to be expired. Please sign in again.'); + track(authRefreshFailed()); + setPhase('choose-action'); + }); + } } function resetToChoose() { setPhase('choose-action'); + setNotice(null); } return { phase, error, + notice, workspace, startAuth, cancelAuth,