From 8ebb21514f0ca294f564ffedf59ce8cde3c2730f Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Mon, 19 Oct 2026 09:51:12 +0200 Subject: [PATCH 01/10] feat: autorun login for authed commands --- packages/cli/__tests__/utils/auth.test.ts | 155 ++++++++++++++++++ packages/cli/src/commands/whoami.ts | 35 ++-- .../cli/src/features/mcp/resolve-token.ts | 10 +- packages/cli/src/utils/auth.ts | 69 ++++++-- packages/testing/src/auth/tokens.ts | 5 + packages/testing/src/auth/types.ts | 3 +- 6 files changed, 238 insertions(+), 39 deletions(-) create mode 100644 packages/cli/__tests__/utils/auth.test.ts diff --git a/packages/cli/__tests__/utils/auth.test.ts b/packages/cli/__tests__/utils/auth.test.ts new file mode 100644 index 00000000..1d7c94ef --- /dev/null +++ b/packages/cli/__tests__/utils/auth.test.ts @@ -0,0 +1,155 @@ +import { prepareAuthTokens } from '@spotify-confidence/testing/auth'; +import { buildTestJwt } from '@spotify-confidence/testing/auth'; +import { captureOutput } from '../helpers/capture.js'; +import { simulateTTY } from '../helpers/simulate-tty.js'; + +import type { AuthResult } from '@spotify-confidence/core'; + +const mockAuthenticate = vi.fn<() => Promise>(); +const mockRefreshAccessToken = vi.fn<() => Promise>(); +const mockIsInteractive = vi.fn<() => boolean>(); + +vi.mock('@spotify-confidence/core', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + authenticate: (...args: unknown[]) => mockAuthenticate(...(args as [])), + refreshAccessToken: (...args: unknown[]) => mockRefreshAccessToken(...(args as [])), + isInteractive: () => mockIsInteractive(), + }; +}); + +const { tryResolveAuthToken: resolveToken } = await import('@utils/auth.js'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('resolveToken', () => { + it('returns a valid persisted token without triggering login', async () => { + using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); + + const sut = await resolveToken(); + + expect(sut).toBeTruthy(); + expect(mockAuthenticate).not.toHaveBeenCalled(); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + expect(output.stdout).toBe(''); + }); + + it('refreshes an expired token when refresh token is available', async () => { + using _auth = prepareAuthTokens('stale'); + using output = captureOutput(); + const freshToken = buildTestJwt({ email: 'refreshed@example.com' }); + mockRefreshAccessToken.mockResolvedValue({ + accessToken: freshToken, + region: 'EU', + workspace: 'test-ws', + }); + + const sut = await resolveToken(); + + expect(sut).toBe(freshToken); + expect(mockRefreshAccessToken).toHaveBeenCalled(); + expect(mockAuthenticate).not.toHaveBeenCalled(); + expect(output.stdout).toBe(''); + }); + + it('triggers interactive login when not logged in and TTY is available', async () => { + using _auth = prepareAuthTokens('none'); + using _tty = simulateTTY(true); + using output = captureOutput(); + mockIsInteractive.mockReturnValue(true); + const freshToken = buildTestJwt({ email: 'new@example.com' }); + mockAuthenticate.mockResolvedValue({ + accessToken: freshToken, + region: 'EU', + workspace: 'test-ws', + }); + + const sut = await resolveToken(); + + expect(sut).toBe(freshToken); + expect(mockAuthenticate).toHaveBeenCalled(); + expect(output.stdout).toContain('Not logged in to Confidence'); + expect(output.stdout).toContain('Opening browser'); + expect(output.stdout).toContain('Authenticated as test-ws (EU)'); + }); + + it('triggers login after refresh failure in interactive mode', async () => { + using _auth = prepareAuthTokens('stale'); + using _tty = simulateTTY(true); + using output = captureOutput(); + mockIsInteractive.mockReturnValue(true); + mockRefreshAccessToken.mockRejectedValue(new Error('Session expired')); + const freshToken = buildTestJwt({ email: 'reauth@example.com' }); + mockAuthenticate.mockResolvedValue({ + accessToken: freshToken, + region: 'US', + workspace: 'ws-2', + }); + + const sut = await resolveToken(); + + expect(sut).toBe(freshToken); + expect(mockRefreshAccessToken).toHaveBeenCalled(); + expect(mockAuthenticate).toHaveBeenCalled(); + expect(output.stdout).toContain('Session expired'); + expect(output.stdout).toContain('Opening browser to re-authenticate'); + }); + + it('fails with error in non-interactive mode when not logged in', async () => { + using _auth = prepareAuthTokens('none'); + using output = captureOutput(); + mockIsInteractive.mockReturnValue(false); + + const sut = await resolveToken(); + + expect(sut).toBeNull(); + expect(output.stderr).toContain('Not logged in'); + expect(mockAuthenticate).not.toHaveBeenCalled(); + }); + + it('fails with error in non-interactive mode when token is expired', async () => { + using _auth = prepareAuthTokens('expired'); + using output = captureOutput(); + mockIsInteractive.mockReturnValue(false); + + const sut = await resolveToken(); + + expect(sut).toBeNull(); + expect(output.stderr).toContain('Token expired'); + expect(mockAuthenticate).not.toHaveBeenCalled(); + }); + + it('reports login failure when authentication throws', async () => { + using _auth = prepareAuthTokens('none'); + using _tty = simulateTTY(true); + using output = captureOutput(); + mockIsInteractive.mockReturnValue(true); + mockAuthenticate.mockRejectedValue(new Error('Port 8084 in use')); + + const sut = await resolveToken(); + + expect(sut).toBeNull(); + expect(output.stderr).toContain('Login failed'); + }); + + it('passes profile through to authenticate', async () => { + using _auth = prepareAuthTokens('none'); + using _tty = simulateTTY(true); + using _output = captureOutput(); + mockIsInteractive.mockReturnValue(true); + const freshToken = buildTestJwt(); + mockAuthenticate.mockResolvedValue({ + accessToken: freshToken, + region: 'EU', + workspace: 'ws', + }); + + await resolveToken('staging'); + + expect(mockAuthenticate).toHaveBeenCalledWith(expect.objectContaining({ profile: 'staging' })); + }); +}); diff --git a/packages/cli/src/commands/whoami.ts b/packages/cli/src/commands/whoami.ts index 93ef0bee..6eacabd0 100644 --- a/packages/cli/src/commands/whoami.ts +++ b/packages/cli/src/commands/whoami.ts @@ -1,30 +1,25 @@ -import { loadPersistedToken, validateToken, decodeJwtPayload } from '@spotify-confidence/core'; -import { print, fail, extractFlags } from '@output/print.js'; +import { decodeJwtPayload } from '@spotify-confidence/core'; +import { print, extractFlags } from '@output/print.js'; +import { tryResolveAuthToken } from '@utils/auth.js'; + +const ORG_ID_KEY = 'https://confidence.dev/org_login_id'; +const REGION_KEY = 'https://confidence.dev/region'; +const ACCOUNT_KEY = 'https://confidence.dev/account_name'; export const whoamiCommand = { command: 'whoami', describe: 'Show current user, org, region, and token expiry', - handler(argv: Record) { + async handler(argv: Record) { const profile = argv.profile as string | undefined; - const token = loadPersistedToken(profile); - if (!token) { - fail('Not logged in. Run "confidence login" first.'); - return; - } - - const validation = validateToken(token); - if (!validation.valid) { - fail('Token is expired. Run "confidence login" to re-authenticate.'); - return; - } + const token = await tryResolveAuthToken(profile); + if (!token) return; const payload = decodeJwtPayload(token); const email = (payload.email as string) ?? undefined; - const org = - (payload.org_id as string) ?? - (payload['https://confidence.dev/org_login_id'] as string) ?? - undefined; + const org = (payload.org_id as string) ?? (payload[ORG_ID_KEY] as string) ?? undefined; + const region = payload[REGION_KEY] === 'US' ? 'US' : 'EU'; + const workspace = (payload[ACCOUNT_KEY] as string) ?? (payload.email as string) ?? undefined; const exp = payload.exp as number | undefined; const expiresAt = exp ? new Date(exp * 1000).toISOString() : 'unknown'; @@ -32,8 +27,8 @@ export const whoamiCommand = { data: { email: email ?? 'unknown', organization: org ?? 'unknown', - region: validation.region ?? 'unknown', - workspace: validation.workspace ?? 'unknown', + region, + workspace: workspace ?? 'unknown', expires: expiresAt, profile: profile ?? 'default', }, diff --git a/packages/cli/src/features/mcp/resolve-token.ts b/packages/cli/src/features/mcp/resolve-token.ts index 15d8193b..e3cf70d3 100644 --- a/packages/cli/src/features/mcp/resolve-token.ts +++ b/packages/cli/src/features/mcp/resolve-token.ts @@ -1,16 +1,14 @@ import { message, fail } from '@output/print.js'; -import { loadPersistedToken, authenticate, validateToken } from '@spotify-confidence/core'; +import { authenticate } from '@spotify-confidence/core'; +import { tryLoadToken } from '@utils/auth.js'; export async function resolveAuthToken(opts?: { forceNew?: boolean; profile?: string; }): Promise { if (!opts?.forceNew) { - const existing = loadPersistedToken(opts?.profile); - if (existing) { - const { valid } = validateToken(existing); - if (valid) return existing; - } + const { token } = await tryLoadToken(opts?.profile); + if (token) return token; } try { diff --git a/packages/cli/src/utils/auth.ts b/packages/cli/src/utils/auth.ts index ed83ebf9..1dabf9b5 100644 --- a/packages/cli/src/utils/auth.ts +++ b/packages/cli/src/utils/auth.ts @@ -1,28 +1,73 @@ -import { loadPersistedToken, validateToken } from '@spotify-confidence/core'; -import { fail } from '@output/print.js'; +import { + loadPersistedToken, + validateToken, + refreshAccessToken, + authenticate, + isInteractive, +} from '@spotify-confidence/core'; +import { fail, message } from '@output/print.js'; -export function requireAuth(profile?: string): string | null { - const token = loadPersistedToken(profile); - if (!token) { - fail('Not logged in. Run "confidence login" first.'); - return null; +type LoadTokenResult = { + token: string | null; + expired: boolean; +}; + +export async function tryLoadToken(profile?: string): Promise { + const existing = loadPersistedToken(profile); + if (!existing) return { token: null, expired: false }; + + const { valid } = validateToken(existing); + if (valid) return { token: existing, expired: false }; + + try { + const refreshed = await refreshAccessToken(profile); + return { token: refreshed.accessToken, expired: false }; + } catch { + return { token: null, expired: true }; } +} + +export async function tryResolveAuthToken(profile?: string): Promise { + const loaded = await tryLoadToken(profile); + if (loaded.token) return loaded.token; - const validation = validateToken(token); - if (!validation.valid) { - fail('Token expired. Run "confidence login" to re-authenticate.'); + if (!isInteractive()) { + fail( + loaded.expired + ? 'Token expired. Run "confidence login" to re-authenticate.' + : 'Not logged in. Run "confidence login" first.', + ); return null; } - return token; + try { + message( + loaded.expired + ? 'Session expired. Opening browser to re-authenticate...' + : 'Not logged in to Confidence. Opening browser to authenticate...', + ); + + const result = await authenticate({ + mode: 'login', + profile, + onUrl: (url) => message(`If the browser did not open, visit:\n${url}`), + }); + + message(`Authenticated as ${result.workspace ?? 'unknown'} (${result.region})`); + return result.accessToken; + } catch { + fail('Login failed. Try running "confidence login" to re-authenticate.'); + return null; + } } export function withAuth( fn: (argv: Record, token: string) => Promise, ): (argv: Record) => Promise { return async (argv) => { - const token = requireAuth(argv.profile as string | undefined); + const token = await tryResolveAuthToken(argv.profile as string | undefined); if (!token) return; + await fn(argv, token); }; } diff --git a/packages/testing/src/auth/tokens.ts b/packages/testing/src/auth/tokens.ts index af871b38..3404459d 100644 --- a/packages/testing/src/auth/tokens.ts +++ b/packages/testing/src/auth/tokens.ts @@ -20,6 +20,11 @@ const SCAFFOLDS: Record void> = { writeCredentials(dir, { accessToken: buildTestJwt({ exp: Math.floor(Date.now() / 1000) - 3600 }), }), + stale: (dir) => + writeCredentials(dir, { + accessToken: buildTestJwt({ exp: Math.floor(Date.now() / 1000) - 3600 }), + refreshToken: 'test-refresh-token', + }), }; type Credentials = { diff --git a/packages/testing/src/auth/types.ts b/packages/testing/src/auth/types.ts index 24d2aa43..a0e8e1ae 100644 --- a/packages/testing/src/auth/types.ts +++ b/packages/testing/src/auth/types.ts @@ -8,5 +8,6 @@ * - `'valid'` — valid JWT for `existing@example.com`, no refresh token * - `'with-refresh'` — valid JWT + refresh token (enables token refresh flow) * - `'expired'` — expired JWT (triggers auth-expired detection) + * - `'stale'` — expired JWT + refresh token (triggers refresh-before-login) */ -export type TokenType = 'none' | 'valid' | 'with-refresh' | 'expired'; +export type TokenType = 'none' | 'valid' | 'with-refresh' | 'expired' | 'stale'; From 782de4de08960fdec853ff6e74f7153cffb6c44e Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Mon, 19 Oct 2026 09:53:43 +0200 Subject: [PATCH 02/10] refactor: split JWT helpers --- .../cli/__tests__/commands/whoami.test.ts | 8 ++--- packages/cli/src/commands/whoami.ts | 20 ++++++------ packages/cli/src/utils/auth.ts | 10 +++--- packages/core/__tests__/auth/jwt.test.ts | 31 ++++++++++--------- packages/core/src/auth/authenticate/oauth.ts | 6 ++-- packages/core/src/auth/index.ts | 8 ++++- packages/core/src/auth/jwt.ts | 27 +++++++--------- packages/core/src/integrations/mcp/servers.ts | 5 ++- .../ui/screens/authenticate/useInitialAuth.ts | 18 +++++++---- .../ui/screens/connect-tools/useMcpConnect.ts | 4 +-- 10 files changed, 70 insertions(+), 67 deletions(-) diff --git a/packages/cli/__tests__/commands/whoami.test.ts b/packages/cli/__tests__/commands/whoami.test.ts index 4a8bb794..1b02f03c 100644 --- a/packages/cli/__tests__/commands/whoami.test.ts +++ b/packages/cli/__tests__/commands/whoami.test.ts @@ -1,5 +1,5 @@ import { buildTestJwt, buildExpiredJwt, prepareAuthTokens } from '@spotify-confidence/testing/auth'; -import { loadPersistedToken, validateToken, decodeJwtPayload } from '@spotify-confidence/core'; +import { loadPersistedToken, isValidToken, decodeJwtPayload } from '@spotify-confidence/core'; describe('whoami prerequisites', () => { it('loads and validates a persisted token', () => { @@ -8,7 +8,7 @@ describe('whoami prerequisites', () => { const token = loadPersistedToken(); expect(token).not.toBeNull(); - expect(validateToken(token!).valid).toBe(true); + expect(isValidToken(token!)).toBe(true); }); it('decodes email from the token payload', () => { @@ -21,9 +21,7 @@ describe('whoami prerequisites', () => { }); it('detects an expired token', () => { - const sut = validateToken; - - expect(sut(buildExpiredJwt()).valid).toBe(false); + expect(isValidToken(buildExpiredJwt())).toBe(false); }); it('reads CONFIDENCE_TOKEN env var over persisted tokens', () => { diff --git a/packages/cli/src/commands/whoami.ts b/packages/cli/src/commands/whoami.ts index 6eacabd0..9ed6df1e 100644 --- a/packages/cli/src/commands/whoami.ts +++ b/packages/cli/src/commands/whoami.ts @@ -1,11 +1,12 @@ -import { decodeJwtPayload } from '@spotify-confidence/core'; +import { + decodeJwtPayload, + extractRegion, + extractOrganization, + extractWorkspace, +} from '@spotify-confidence/core'; import { print, extractFlags } from '@output/print.js'; import { tryResolveAuthToken } from '@utils/auth.js'; -const ORG_ID_KEY = 'https://confidence.dev/org_login_id'; -const REGION_KEY = 'https://confidence.dev/region'; -const ACCOUNT_KEY = 'https://confidence.dev/account_name'; - export const whoamiCommand = { command: 'whoami', describe: 'Show current user, org, region, and token expiry', @@ -17,18 +18,15 @@ export const whoamiCommand = { const payload = decodeJwtPayload(token); const email = (payload.email as string) ?? undefined; - const org = (payload.org_id as string) ?? (payload[ORG_ID_KEY] as string) ?? undefined; - const region = payload[REGION_KEY] === 'US' ? 'US' : 'EU'; - const workspace = (payload[ACCOUNT_KEY] as string) ?? (payload.email as string) ?? undefined; const exp = payload.exp as number | undefined; const expiresAt = exp ? new Date(exp * 1000).toISOString() : 'unknown'; print({ data: { email: email ?? 'unknown', - organization: org ?? 'unknown', - region, - workspace: workspace ?? 'unknown', + organization: extractOrganization(token) ?? 'unknown', + region: extractRegion(token), + workspace: extractWorkspace(token) ?? 'unknown', expires: expiresAt, profile: profile ?? 'default', }, diff --git a/packages/cli/src/utils/auth.ts b/packages/cli/src/utils/auth.ts index 1dabf9b5..25d349af 100644 --- a/packages/cli/src/utils/auth.ts +++ b/packages/cli/src/utils/auth.ts @@ -1,6 +1,6 @@ import { loadPersistedToken, - validateToken, + isValidToken as isValid, refreshAccessToken, authenticate, isInteractive, @@ -13,11 +13,9 @@ type LoadTokenResult = { }; export async function tryLoadToken(profile?: string): Promise { - const existing = loadPersistedToken(profile); - if (!existing) return { token: null, expired: false }; - - const { valid } = validateToken(existing); - if (valid) return { token: existing, expired: false }; + const token = loadPersistedToken(profile); + if (!token) return { token: null, expired: false }; + if (isValid(token)) return { token, expired: false }; try { const refreshed = await refreshAccessToken(profile); diff --git a/packages/core/__tests__/auth/jwt.test.ts b/packages/core/__tests__/auth/jwt.test.ts index 84856673..25b9f9a7 100644 --- a/packages/core/__tests__/auth/jwt.test.ts +++ b/packages/core/__tests__/auth/jwt.test.ts @@ -1,5 +1,5 @@ import { buildTestJwt, buildExpiredJwt } from '@spotify-confidence/testing/auth'; -import { decodeJwtPayload, validateToken } from '@auth/jwt.js'; +import { decodeJwtPayload, isValidToken, extractWorkspace } from '@auth/jwt.js'; describe('decodeJwtPayload', () => { it('decodes a valid JWT payload', () => { @@ -18,30 +18,31 @@ describe('decodeJwtPayload', () => { }); }); -describe('validateToken', () => { - it('returns valid for a non-expired token', () => { - const sut = validateToken; +describe('isValidToken', () => { + it('returns true for a non-expired token', () => { const token = buildTestJwt({ 'https://confidence.dev/region': 'EU' }); - const result = sut(token); - - expect(result.valid).toBe(true); - expect(result.region).toBe('EU'); + expect(isValidToken(token)).toBe(true); }); - it('returns invalid for an expired token', () => { - const sut = validateToken; - - expect(sut(buildExpiredJwt()).valid).toBe(false); + it('returns false for an expired token', () => { + expect(isValidToken(buildExpiredJwt())).toBe(false); }); +}); - it('extracts the workspace from account_name or email', () => { - const sut = validateToken; +describe('extractWorkspace', () => { + it('extracts workspace from account_name', () => { const token = buildTestJwt({ 'https://confidence.dev/account_name': 'Acme Corp', email: 'user@acme.com', }); - expect(sut(token).workspace).toBe('Acme Corp'); + expect(extractWorkspace(token)).toBe('Acme Corp'); + }); + + it('falls back to email when account_name is absent', () => { + const token = buildTestJwt({ email: 'user@acme.com' }); + + expect(extractWorkspace(token)).toBe('user@acme.com'); }); }); diff --git a/packages/core/src/auth/authenticate/oauth.ts b/packages/core/src/auth/authenticate/oauth.ts index ccff5561..acd4f4dc 100644 --- a/packages/core/src/auth/authenticate/oauth.ts +++ b/packages/core/src/auth/authenticate/oauth.ts @@ -1,7 +1,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { env } from '../../system/env.js'; import { readCredentials, writeCredentials } from '../credentials/index.js'; -import { extractRegion, extractOrganization, validateToken } from '../jwt.js'; +import { extractRegion, extractOrganization, extractWorkspace } from '../jwt.js'; import { successPage, errorPage, exchangeErrorPage } from '../callback-pages.js'; import { AUTH_BASE_URL, @@ -59,7 +59,7 @@ export async function refreshAccessToken(profile?: string): Promise persistTokens(data.access_token, data.refresh_token, profile); const region = extractRegion(data.access_token); - const { workspace } = validateToken(data.access_token); + const workspace = extractWorkspace(data.access_token); return { accessToken: data.access_token, refreshToken: data.refresh_token, @@ -120,7 +120,7 @@ export function authenticate(opts: { persistTokens(access_token, refresh_token, opts.profile); const region = extractRegion(access_token); - const { workspace } = validateToken(access_token); + const workspace = extractWorkspace(access_token); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(successPage); diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts index 9c351769..298bcccb 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -5,5 +5,11 @@ export { type AuthResult, } from './authenticate/index.js'; export { ensureDir, loadPersistedToken, clearTokens } from './credentials/index.js'; -export { decodeJwtPayload, extractRegion, validateToken } from './jwt.js'; +export { + decodeJwtPayload, + extractOrganization, + extractRegion, + extractWorkspace, + isValidToken, +} from './jwt.js'; export { successPage, errorPage, exchangeErrorPage } from './callback-pages.js'; diff --git a/packages/core/src/auth/jwt.ts b/packages/core/src/auth/jwt.ts index 0e175298..06609267 100644 --- a/packages/core/src/auth/jwt.ts +++ b/packages/core/src/auth/jwt.ts @@ -19,24 +19,21 @@ export function extractOrganization(token: string): string | undefined { return organization ?? undefined; } -export function validateToken(token: string): { - valid: boolean; - region?: 'EU' | 'US'; - workspace?: string; -} { +export function extractWorkspace(token: string): string | undefined { + const payload = decodeJwtPayload(token); + return ( + (payload['https://confidence.dev/account_name'] as string) ?? + (payload.email as string) ?? + undefined + ); +} + +export function isValidToken(token: string): boolean { try { const payload = decodeJwtPayload(token); const exp = payload.exp as number | undefined; - if (exp && Date.now() / 1000 > exp) { - return { valid: false }; - } - const region = extractRegion(token); - const workspace = - (payload['https://confidence.dev/account_name'] as string) ?? - (payload.email as string) ?? - undefined; - return { valid: true, region, workspace }; + return !exp || Date.now() / 1000 <= exp; } catch { - return { valid: false }; + return false; } } diff --git a/packages/core/src/integrations/mcp/servers.ts b/packages/core/src/integrations/mcp/servers.ts index 5b2394cf..6fb98eb9 100644 --- a/packages/core/src/integrations/mcp/servers.ts +++ b/packages/core/src/integrations/mcp/servers.ts @@ -1,4 +1,4 @@ -import { validateToken } from '../../auth/jwt.js'; +import { isValidToken } from '../../auth/jwt.js'; import { env } from '../../system/env.js'; const MCP_BASE_URL = env('CONFIDENCE_MCP_URL', 'https://mcp.confidence.dev'); @@ -64,8 +64,7 @@ export async function verifyMcpServer( opts?: McpVerifyOpts, ): Promise { if (opts?.authToken) { - const { valid } = validateToken(opts.authToken); - if (!valid) return 'auth-expired'; + if (!isValidToken(opts.authToken)) return 'auth-expired'; } const server = MCP_SERVERS[serverName]; diff --git a/packages/quickstart/src/ui/screens/authenticate/useInitialAuth.ts b/packages/quickstart/src/ui/screens/authenticate/useInitialAuth.ts index d828ab1c..cb85e0d4 100644 --- a/packages/quickstart/src/ui/screens/authenticate/useInitialAuth.ts +++ b/packages/quickstart/src/ui/screens/authenticate/useInitialAuth.ts @@ -1,5 +1,10 @@ import { useEffect, useState } from 'react'; -import { loadPersistedToken, validateToken } from '@spotify-confidence/core'; +import { + loadPersistedToken, + isValidToken, + extractRegion, + extractWorkspace, +} from '@spotify-confidence/core'; import { useSession, store } from '../../store.js'; import type { AuthPhase } from './useAuthFlow.js'; @@ -24,16 +29,17 @@ function resolveAuth(dryRun: boolean): ResolvedAuth { const existing = loadPersistedToken(); if (existing) { - const result = validateToken(existing); - if (result.valid) { + if (isValidToken(existing)) { + const region = extractRegion(existing); + const workspace = extractWorkspace(existing); return { phase: 'has-existing', - workspace: result.workspace ?? null, + workspace: workspace ?? null, authState: { status: 'authenticated', token: existing, - region: result.region, - workspace: result.workspace, + region, + workspace, }, }; } diff --git a/packages/quickstart/src/ui/screens/connect-tools/useMcpConnect.ts b/packages/quickstart/src/ui/screens/connect-tools/useMcpConnect.ts index 31d8432f..69815bcc 100644 --- a/packages/quickstart/src/ui/screens/connect-tools/useMcpConnect.ts +++ b/packages/quickstart/src/ui/screens/connect-tools/useMcpConnect.ts @@ -11,7 +11,7 @@ import { persistMcpPreference, MCP_SERVERS, getIntegration, - validateToken, + isValidToken, ScreenId, } from '@spotify-confidence/core'; import { useLogger } from '../../hooks/useLog.js'; @@ -144,7 +144,7 @@ export function useMcpConnect(): McpConnectState { } const token = $session.get().authState.token; - if (!token || !validateToken(token).valid) { + if (!token || !isValidToken(token)) { setPhase(resolvePhaseFromStatuses(statuses)); return; } From bd7bf122af51ee301f0442ee2edffeb552284982 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Mon, 19 Oct 2026 09:55:04 +0200 Subject: [PATCH 03/10] chore: better naming --- packages/cli/__tests__/utils/auth.test.ts | 2 +- packages/cli/src/commands/whoami.ts | 4 ++-- packages/cli/src/features/mcp/resolve-token.ts | 4 ++-- packages/cli/src/utils/auth.ts | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/cli/__tests__/utils/auth.test.ts b/packages/cli/__tests__/utils/auth.test.ts index 1d7c94ef..9c3347eb 100644 --- a/packages/cli/__tests__/utils/auth.test.ts +++ b/packages/cli/__tests__/utils/auth.test.ts @@ -19,7 +19,7 @@ vi.mock('@spotify-confidence/core', async (importOriginal) => { }; }); -const { tryResolveAuthToken: resolveToken } = await import('@utils/auth.js'); +const { tryResolveAuth: resolveToken } = await import('@utils/auth.js'); beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/cli/src/commands/whoami.ts b/packages/cli/src/commands/whoami.ts index 9ed6df1e..123c0b0c 100644 --- a/packages/cli/src/commands/whoami.ts +++ b/packages/cli/src/commands/whoami.ts @@ -5,7 +5,7 @@ import { extractWorkspace, } from '@spotify-confidence/core'; import { print, extractFlags } from '@output/print.js'; -import { tryResolveAuthToken } from '@utils/auth.js'; +import { tryResolveAuth } from '@utils/auth.js'; export const whoamiCommand = { command: 'whoami', @@ -13,7 +13,7 @@ export const whoamiCommand = { async handler(argv: Record) { const profile = argv.profile as string | undefined; - const token = await tryResolveAuthToken(profile); + const token = await tryResolveAuth(profile); if (!token) return; const payload = decodeJwtPayload(token); diff --git a/packages/cli/src/features/mcp/resolve-token.ts b/packages/cli/src/features/mcp/resolve-token.ts index e3cf70d3..25148f8b 100644 --- a/packages/cli/src/features/mcp/resolve-token.ts +++ b/packages/cli/src/features/mcp/resolve-token.ts @@ -1,13 +1,13 @@ import { message, fail } from '@output/print.js'; import { authenticate } from '@spotify-confidence/core'; -import { tryLoadToken } from '@utils/auth.js'; +import { tryLoadAuthToken } from '@utils/auth.js'; export async function resolveAuthToken(opts?: { forceNew?: boolean; profile?: string; }): Promise { if (!opts?.forceNew) { - const { token } = await tryLoadToken(opts?.profile); + const { token } = await tryLoadAuthToken(opts?.profile); if (token) return token; } diff --git a/packages/cli/src/utils/auth.ts b/packages/cli/src/utils/auth.ts index 25d349af..03244051 100644 --- a/packages/cli/src/utils/auth.ts +++ b/packages/cli/src/utils/auth.ts @@ -12,7 +12,7 @@ type LoadTokenResult = { expired: boolean; }; -export async function tryLoadToken(profile?: string): Promise { +export async function tryLoadAuthToken(profile?: string): Promise { const token = loadPersistedToken(profile); if (!token) return { token: null, expired: false }; if (isValid(token)) return { token, expired: false }; @@ -25,8 +25,8 @@ export async function tryLoadToken(profile?: string): Promise { } } -export async function tryResolveAuthToken(profile?: string): Promise { - const loaded = await tryLoadToken(profile); +export async function tryResolveAuth(profile?: string): Promise { + const loaded = await tryLoadAuthToken(profile); if (loaded.token) return loaded.token; if (!isInteractive()) { @@ -63,7 +63,7 @@ export function withAuth( fn: (argv: Record, token: string) => Promise, ): (argv: Record) => Promise { return async (argv) => { - const token = await tryResolveAuthToken(argv.profile as string | undefined); + const token = await tryResolveAuth(argv.profile as string | undefined); if (!token) return; await fn(argv, token); From 5d5258c4fa4d40c973240d8963b011405174a69e Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Mon, 19 Oct 2026 10:07:19 +0200 Subject: [PATCH 04/10] chore: extend `isInteractive` with `stdout` --- packages/cli/__tests__/helpers/simulate-tty.ts | 7 +++++-- packages/core/src/system/env.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/__tests__/helpers/simulate-tty.ts b/packages/cli/__tests__/helpers/simulate-tty.ts index 6cc82c9d..ebc1077c 100644 --- a/packages/cli/__tests__/helpers/simulate-tty.ts +++ b/packages/cli/__tests__/helpers/simulate-tty.ts @@ -1,13 +1,16 @@ export function simulateTTY(isTTY: boolean): Disposable { - const originalTTY = process.stdin.isTTY; + const originalStdinTTY = process.stdin.isTTY; + const originalStdoutTTY = process.stdout.isTTY; const originalCI = process.env.CI; process.stdin.isTTY = (isTTY || undefined) as typeof process.stdin.isTTY; + process.stdout.isTTY = (isTTY || undefined) as typeof process.stdout.isTTY; if (isTTY) delete process.env.CI; return { [Symbol.dispose]: () => { - process.stdin.isTTY = originalTTY; + process.stdin.isTTY = originalStdinTTY; + process.stdout.isTTY = originalStdoutTTY; if (originalCI !== undefined) process.env.CI = originalCI; else delete process.env.CI; }, diff --git a/packages/core/src/system/env.ts b/packages/core/src/system/env.ts index e2077eab..1104a535 100644 --- a/packages/core/src/system/env.ts +++ b/packages/core/src/system/env.ts @@ -30,5 +30,5 @@ export function isDebug(): boolean { } export function isInteractive(): boolean { - return Boolean(process.stdin.isTTY) && !isCI(); + return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) && !isCI(); } From 6dbe48905da1fc3a0bd0c1e88537d4f4c4af0b9c Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Mon, 19 Oct 2026 10:29:34 +0200 Subject: [PATCH 05/10] chore: clean up test --- packages/cli/__tests__/utils/auth.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/cli/__tests__/utils/auth.test.ts b/packages/cli/__tests__/utils/auth.test.ts index 9c3347eb..67d586aa 100644 --- a/packages/cli/__tests__/utils/auth.test.ts +++ b/packages/cli/__tests__/utils/auth.test.ts @@ -21,10 +21,6 @@ vi.mock('@spotify-confidence/core', async (importOriginal) => { const { tryResolveAuth: resolveToken } = await import('@utils/auth.js'); -beforeEach(() => { - vi.clearAllMocks(); -}); - describe('resolveToken', () => { it('returns a valid persisted token without triggering login', async () => { using _auth = prepareAuthTokens('valid'); From b8f944826df927b98727b39d1297defab1e1ea36 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Mon, 19 Oct 2026 10:43:16 +0200 Subject: [PATCH 06/10] test: extract utils, reduce mocking --- packages/cli/__tests__/utils/auth.test.ts | 56 +++++++--------------- packages/testing/src/index.ts | 1 + packages/testing/src/msw/handlers/auth.ts | 18 ++++++- packages/testing/src/msw/handlers/index.ts | 2 +- 4 files changed, 37 insertions(+), 40 deletions(-) diff --git a/packages/cli/__tests__/utils/auth.test.ts b/packages/cli/__tests__/utils/auth.test.ts index 67d586aa..a89b1309 100644 --- a/packages/cli/__tests__/utils/auth.test.ts +++ b/packages/cli/__tests__/utils/auth.test.ts @@ -1,36 +1,28 @@ -import { prepareAuthTokens } from '@spotify-confidence/testing/auth'; -import { buildTestJwt } from '@spotify-confidence/testing/auth'; +import { prepareAuthTokens, buildTestJwt } from '@spotify-confidence/testing/auth'; +import { server, tokenResponse, tokenError } from '@spotify-confidence/testing'; import { captureOutput } from '../helpers/capture.js'; import { simulateTTY } from '../helpers/simulate-tty.js'; import type { AuthResult } from '@spotify-confidence/core'; const mockAuthenticate = vi.fn<() => Promise>(); -const mockRefreshAccessToken = vi.fn<() => Promise>(); -const mockIsInteractive = vi.fn<() => boolean>(); - -vi.mock('@spotify-confidence/core', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - authenticate: (...args: unknown[]) => mockAuthenticate(...(args as [])), - refreshAccessToken: (...args: unknown[]) => mockRefreshAccessToken(...(args as [])), - isInteractive: () => mockIsInteractive(), - }; -}); -const { tryResolveAuth: resolveToken } = await import('@utils/auth.js'); +vi.mock('@spotify-confidence/core', async (importOriginal) => ({ + ...(await importOriginal()), + authenticate: (...args: unknown[]) => mockAuthenticate(...(args as [])), +})); + +const { tryResolveAuth } = await import('@utils/auth.js'); describe('resolveToken', () => { it('returns a valid persisted token without triggering login', async () => { using _auth = prepareAuthTokens('valid'); using output = captureOutput(); - const sut = await resolveToken(); + const sut = await tryResolveAuth(); expect(sut).toBeTruthy(); expect(mockAuthenticate).not.toHaveBeenCalled(); - expect(mockRefreshAccessToken).not.toHaveBeenCalled(); expect(output.stdout).toBe(''); }); @@ -38,16 +30,11 @@ describe('resolveToken', () => { using _auth = prepareAuthTokens('stale'); using output = captureOutput(); const freshToken = buildTestJwt({ email: 'refreshed@example.com' }); - mockRefreshAccessToken.mockResolvedValue({ - accessToken: freshToken, - region: 'EU', - workspace: 'test-ws', - }); + server.use(tokenResponse(freshToken)); - const sut = await resolveToken(); + const sut = await tryResolveAuth(); expect(sut).toBe(freshToken); - expect(mockRefreshAccessToken).toHaveBeenCalled(); expect(mockAuthenticate).not.toHaveBeenCalled(); expect(output.stdout).toBe(''); }); @@ -56,7 +43,6 @@ describe('resolveToken', () => { using _auth = prepareAuthTokens('none'); using _tty = simulateTTY(true); using output = captureOutput(); - mockIsInteractive.mockReturnValue(true); const freshToken = buildTestJwt({ email: 'new@example.com' }); mockAuthenticate.mockResolvedValue({ accessToken: freshToken, @@ -64,7 +50,7 @@ describe('resolveToken', () => { workspace: 'test-ws', }); - const sut = await resolveToken(); + const sut = await tryResolveAuth(); expect(sut).toBe(freshToken); expect(mockAuthenticate).toHaveBeenCalled(); @@ -77,8 +63,7 @@ describe('resolveToken', () => { using _auth = prepareAuthTokens('stale'); using _tty = simulateTTY(true); using output = captureOutput(); - mockIsInteractive.mockReturnValue(true); - mockRefreshAccessToken.mockRejectedValue(new Error('Session expired')); + server.use(tokenError()); const freshToken = buildTestJwt({ email: 'reauth@example.com' }); mockAuthenticate.mockResolvedValue({ accessToken: freshToken, @@ -86,10 +71,9 @@ describe('resolveToken', () => { workspace: 'ws-2', }); - const sut = await resolveToken(); + const sut = await tryResolveAuth(); expect(sut).toBe(freshToken); - expect(mockRefreshAccessToken).toHaveBeenCalled(); expect(mockAuthenticate).toHaveBeenCalled(); expect(output.stdout).toContain('Session expired'); expect(output.stdout).toContain('Opening browser to re-authenticate'); @@ -98,9 +82,8 @@ describe('resolveToken', () => { it('fails with error in non-interactive mode when not logged in', async () => { using _auth = prepareAuthTokens('none'); using output = captureOutput(); - mockIsInteractive.mockReturnValue(false); - const sut = await resolveToken(); + const sut = await tryResolveAuth(); expect(sut).toBeNull(); expect(output.stderr).toContain('Not logged in'); @@ -110,9 +93,8 @@ describe('resolveToken', () => { it('fails with error in non-interactive mode when token is expired', async () => { using _auth = prepareAuthTokens('expired'); using output = captureOutput(); - mockIsInteractive.mockReturnValue(false); - const sut = await resolveToken(); + const sut = await tryResolveAuth(); expect(sut).toBeNull(); expect(output.stderr).toContain('Token expired'); @@ -123,10 +105,9 @@ describe('resolveToken', () => { using _auth = prepareAuthTokens('none'); using _tty = simulateTTY(true); using output = captureOutput(); - mockIsInteractive.mockReturnValue(true); mockAuthenticate.mockRejectedValue(new Error('Port 8084 in use')); - const sut = await resolveToken(); + const sut = await tryResolveAuth(); expect(sut).toBeNull(); expect(output.stderr).toContain('Login failed'); @@ -136,7 +117,6 @@ describe('resolveToken', () => { using _auth = prepareAuthTokens('none'); using _tty = simulateTTY(true); using _output = captureOutput(); - mockIsInteractive.mockReturnValue(true); const freshToken = buildTestJwt(); mockAuthenticate.mockResolvedValue({ accessToken: freshToken, @@ -144,7 +124,7 @@ describe('resolveToken', () => { workspace: 'ws', }); - await resolveToken('staging'); + await tryResolveAuth('staging'); expect(mockAuthenticate).toHaveBeenCalledWith(expect.objectContaining({ profile: 'staging' })); }); diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts index a573f51e..742b28da 100644 --- a/packages/testing/src/index.ts +++ b/packages/testing/src/index.ts @@ -4,6 +4,7 @@ export * from './env/index.js'; export * from './terminal/index.js'; export { server } from './msw/server.js'; export { handlers } from './msw/handlers.js'; +export { tokenResponse, tokenError } from './msw/handlers/auth.js'; export { FLAGS_EU_BASE, FLAGS_US_BASE } from './msw/handlers/flags.js'; export { registryReturns, registryFails, NPM_REGISTRY_URL } from './msw/handlers/registry.js'; export { http, HttpResponse } from 'msw'; diff --git a/packages/testing/src/msw/handlers/auth.ts b/packages/testing/src/msw/handlers/auth.ts index a5ef4f96..fcbad67c 100644 --- a/packages/testing/src/msw/handlers/auth.ts +++ b/packages/testing/src/msw/handlers/auth.ts @@ -1,7 +1,9 @@ import { http, HttpResponse } from 'msw'; +const AUTH_URL = 'https://auth.confidence.dev/oauth/token'; + export const authHandlers = [ - http.post('https://auth.confidence.dev/oauth/token', () => { + http.post(AUTH_URL, () => { return HttpResponse.json({ access_token: 'test-access-token', refresh_token: 'test-refresh-token', @@ -10,3 +12,17 @@ export const authHandlers = [ }); }), ]; + +export function tokenResponse(accessToken: string) { + return http.post(AUTH_URL, () => + HttpResponse.json({ + access_token: accessToken, + refresh_token: 'test-refresh-token', + token_type: 'Bearer', + }), + ); +} + +export function tokenError(status = 401) { + return http.post(AUTH_URL, () => HttpResponse.json({}, { status })); +} diff --git a/packages/testing/src/msw/handlers/index.ts b/packages/testing/src/msw/handlers/index.ts index b442cae5..bee7063c 100644 --- a/packages/testing/src/msw/handlers/index.ts +++ b/packages/testing/src/msw/handlers/index.ts @@ -1,4 +1,4 @@ -export { authHandlers } from './auth.js'; +export { authHandlers, tokenResponse, tokenError } from './auth.js'; export { mcpHandlers } from './mcp.js'; export { telemetryHandlers } from './telemetry.js'; export { skillsHandlers } from './skills.js'; From b2ced545b85f66373b4518673eb906e3abb5dd82 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Mon, 19 Oct 2026 14:26:03 +0200 Subject: [PATCH 07/10] fix: add timeout to auto-login so withAuth commands don't hang forever Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/utils/auth.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/auth.ts b/packages/cli/src/utils/auth.ts index 03244051..4df9ceb9 100644 --- a/packages/cli/src/utils/auth.ts +++ b/packages/cli/src/utils/auth.ts @@ -41,13 +41,14 @@ export async function tryResolveAuth(profile?: string): Promise { try { message( loaded.expired - ? 'Session expired. Opening browser to re-authenticate...' - : 'Not logged in to Confidence. Opening browser to authenticate...', + ? 'Session expired. Opening browser to re-authenticate... (Ctrl+C to cancel)' + : 'Not logged in to Confidence. Opening browser to authenticate... (Ctrl+C to cancel)', ); const result = await authenticate({ mode: 'login', profile, + signal: AbortSignal.timeout(120_000), onUrl: (url) => message(`If the browser did not open, visit:\n${url}`), }); From 1fb78d9a432975ed39257aaa1536dc409218a0da Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Mon, 19 Oct 2026 14:37:21 +0200 Subject: [PATCH 08/10] fix: only fall through to login on auth rejection, not on network errors Transient failures (timeout, DNS, VPN drop) now propagate instead of being swallowed as "expired". A hasExpiredSession helper checks the error cause so tryLoadAuthToken only triggers the browser login flow when the refresh token is genuinely invalid. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/utils/auth.ts | 28 ++++++++++--------- .../core/src/auth/authenticate/constants.ts | 2 ++ packages/core/src/auth/authenticate/index.ts | 1 + packages/core/src/auth/authenticate/oauth.ts | 5 ++-- packages/core/src/auth/authenticate/utils.ts | 5 ++++ packages/core/src/auth/index.ts | 1 + 6 files changed, 27 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/auth/authenticate/utils.ts diff --git a/packages/cli/src/utils/auth.ts b/packages/cli/src/utils/auth.ts index 4df9ceb9..2f313a9f 100644 --- a/packages/cli/src/utils/auth.ts +++ b/packages/cli/src/utils/auth.ts @@ -2,6 +2,7 @@ import { loadPersistedToken, isValidToken as isValid, refreshAccessToken, + hasExpiredSession, authenticate, isInteractive, } from '@spotify-confidence/core'; @@ -20,25 +21,26 @@ export async function tryLoadAuthToken(profile?: string): Promise { - const loaded = await tryLoadAuthToken(profile); - if (loaded.token) return loaded.token; + try { + const loaded = await tryLoadAuthToken(profile); + if (loaded.token) return loaded.token; - if (!isInteractive()) { - fail( - loaded.expired - ? 'Token expired. Run "confidence login" to re-authenticate.' - : 'Not logged in. Run "confidence login" first.', - ); - return null; - } + if (!isInteractive()) { + fail( + loaded.expired + ? 'Token expired. Run "confidence login" to re-authenticate.' + : 'Not logged in. Run "confidence login" first.', + ); + return null; + } - try { message( loaded.expired ? 'Session expired. Opening browser to re-authenticate... (Ctrl+C to cancel)' diff --git a/packages/core/src/auth/authenticate/constants.ts b/packages/core/src/auth/authenticate/constants.ts index bdd768b7..23620276 100644 --- a/packages/core/src/auth/authenticate/constants.ts +++ b/packages/core/src/auth/authenticate/constants.ts @@ -8,3 +8,5 @@ export const AUTH_SCOPE = 'openid profile email offline_access'; export const AUTH_CLIENT_ID_SIGNUP = '82qMvwZvqd3t3S0gRDvs8R53TehQXSJY'; export const AUTH_CLIENT_ID_LOGIN = '2fG3H4RhlAbIZm9Rfn32zTaILH7w1X4w'; export const AUTH_CALLBACK_PORT = 8084; + +export const SESSION_EXPIRED_ERR = 'session_expired'; diff --git a/packages/core/src/auth/authenticate/index.ts b/packages/core/src/auth/authenticate/index.ts index b13d0251..43167efe 100644 --- a/packages/core/src/auth/authenticate/index.ts +++ b/packages/core/src/auth/authenticate/index.ts @@ -1,2 +1,3 @@ export { authenticate, refreshAccessToken, type AuthResult } from './oauth.js'; export { AUTH_CALLBACK_PORT } from './constants.js'; +export { hasExpiredSession } from './utils.js'; diff --git a/packages/core/src/auth/authenticate/oauth.ts b/packages/core/src/auth/authenticate/oauth.ts index acd4f4dc..85b1fdcf 100644 --- a/packages/core/src/auth/authenticate/oauth.ts +++ b/packages/core/src/auth/authenticate/oauth.ts @@ -8,6 +8,7 @@ import { AUTH_CLIENT_ID_SIGNUP, AUTH_CLIENT_ID_LOGIN, AUTH_CALLBACK_PORT, + SESSION_EXPIRED_ERR, } from './constants.js'; import { generatePKCE } from './pkce.js'; import { openBrowser } from '../../system/browser.js'; @@ -35,7 +36,7 @@ function resolveOrganization(profile?: string): string | undefined { export async function refreshAccessToken(profile?: string): Promise { const creds = readCredentials(profile); if (!creds?.refreshToken) { - throw new Error('No refresh token available'); + throw new Error('No refresh token available', { cause: SESSION_EXPIRED_ERR }); } const body = new URLSearchParams({ @@ -52,7 +53,7 @@ export async function refreshAccessToken(profile?: string): Promise }); if (!response.ok) { - throw new Error('Session expired'); + throw new Error('Session expired', { cause: SESSION_EXPIRED_ERR }); } const data = (await response.json()) as TokenResponse; diff --git a/packages/core/src/auth/authenticate/utils.ts b/packages/core/src/auth/authenticate/utils.ts new file mode 100644 index 00000000..f8d6f264 --- /dev/null +++ b/packages/core/src/auth/authenticate/utils.ts @@ -0,0 +1,5 @@ +import { SESSION_EXPIRED_ERR } from './constants.js'; + +export function hasExpiredSession(err: unknown) { + return (err as Error).cause === SESSION_EXPIRED_ERR; +} diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts index 298bcccb..c8846cea 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -1,6 +1,7 @@ export { authenticate, refreshAccessToken, + hasExpiredSession, AUTH_CALLBACK_PORT, type AuthResult, } from './authenticate/index.js'; From b98ccdc82f5f65f4bfc021b9fed25ba06a95f940 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Mon, 19 Oct 2026 14:39:09 +0200 Subject: [PATCH 09/10] fix: surface actual error message on login failure Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/utils/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/auth.ts b/packages/cli/src/utils/auth.ts index 2f313a9f..42f350bc 100644 --- a/packages/cli/src/utils/auth.ts +++ b/packages/cli/src/utils/auth.ts @@ -56,8 +56,8 @@ export async function tryResolveAuth(profile?: string): Promise { message(`Authenticated as ${result.workspace ?? 'unknown'} (${result.region})`); return result.accessToken; - } catch { - fail('Login failed. Try running "confidence login" to re-authenticate.'); + } catch (err) { + fail(`Login failed: ${(err as Error).message}`); return null; } } From 04772debc0677def4a35ce35d02e0af1cd1c9327 Mon Sep 17 00:00:00 2001 From: Alex Bespoyasov Date: Mon, 19 Oct 2026 15:03:49 +0200 Subject: [PATCH 10/10] refactor: harden auth error handling and keep progress out of command output - whoami is now read-only: uses tryLoadAuthToken instead of tryResolveAuth so it never opens a browser login - decodeJwtPayload throws with { cause: MALFORMED_TOKEN_ERR } so corrupt tokens are treated as "not logged in", not "expired" - authenticate() defaults to a 120s AbortSignal timeout so callers can't accidentally hang - Add hint() helper that writes to stdout only in interactive mode, replacing message() for auth progress so piped/JSON output stays clean - MCP resolveAuthToken catches tryLoadAuthToken throws and adds timeout Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/__tests__/commands/whoami.test.ts | 51 +++++++++++-------- packages/cli/src/commands/whoami.ts | 15 ++++-- .../cli/src/features/mcp/resolve-token.ts | 16 +++--- packages/cli/src/output/print.ts | 7 ++- packages/cli/src/utils/auth.ts | 15 +++--- .../core/src/auth/authenticate/constants.ts | 2 + packages/core/src/auth/authenticate/index.ts | 2 +- packages/core/src/auth/authenticate/oauth.ts | 22 ++++---- packages/core/src/auth/authenticate/utils.ts | 6 ++- packages/core/src/auth/index.ts | 1 + packages/core/src/auth/jwt.ts | 13 +++-- 11 files changed, 94 insertions(+), 56 deletions(-) diff --git a/packages/cli/__tests__/commands/whoami.test.ts b/packages/cli/__tests__/commands/whoami.test.ts index 1b02f03c..1fdab954 100644 --- a/packages/cli/__tests__/commands/whoami.test.ts +++ b/packages/cli/__tests__/commands/whoami.test.ts @@ -1,36 +1,47 @@ -import { buildTestJwt, buildExpiredJwt, prepareAuthTokens } from '@spotify-confidence/testing/auth'; -import { loadPersistedToken, isValidToken, decodeJwtPayload } from '@spotify-confidence/core'; +import { prepareAuthTokens } from '@spotify-confidence/testing/auth'; +import { whoamiCommand } from '@commands/whoami.js'; +import { captureOutput } from '../helpers/capture.js'; +import { createRunner } from '../helpers/run-command.js'; -describe('whoami prerequisites', () => { - it('loads and validates a persisted token', () => { +const run = createRunner(whoamiCommand); + +describe('whoami', () => { + it('prints user info when logged in', async () => { using _auth = prepareAuthTokens('valid'); + using output = captureOutput(); - const token = loadPersistedToken(); + await run(['whoami']); - expect(token).not.toBeNull(); - expect(isValidToken(token!)).toBe(true); + expect(output.stdout).toContain('existing@example.com'); + expect(process.exitCode).not.toBe(1); }); - it('decodes email from the token payload', () => { - using _auth = prepareAuthTokens('valid'); - const token = loadPersistedToken()!; + it('fails with expired message when token is expired', async () => { + using _auth = prepareAuthTokens('expired'); + using output = captureOutput(); - const payload = decodeJwtPayload(token); + await run(['whoami']); - expect(payload.email).toBe('existing@example.com'); + expect(output.stderr).toContain('Run "confidence login"'); + expect(process.exitCode).toBe(1); }); - it('detects an expired token', () => { - expect(isValidToken(buildExpiredJwt())).toBe(false); + it('fails with not-logged-in message when no credentials exist', async () => { + using _auth = prepareAuthTokens('none'); + using output = captureOutput(); + + await run(['whoami']); + + expect(output.stderr).toContain('Not logged in'); + expect(process.exitCode).toBe(1); }); - it('reads CONFIDENCE_TOKEN env var over persisted tokens', () => { - using _auth = prepareAuthTokens('valid'); - const envToken = buildTestJwt({ email: 'ci@example.com' }); - vi.stubEnv('CONFIDENCE_TOKEN', envToken); + it('never triggers a browser login', async () => { + using _auth = prepareAuthTokens('expired'); + using _output = captureOutput(); - const sut = loadPersistedToken(); + await run(['whoami']); - expect(sut).toBe(envToken); + expect(_output.stdout).not.toContain('Opening browser'); }); }); diff --git a/packages/cli/src/commands/whoami.ts b/packages/cli/src/commands/whoami.ts index 123c0b0c..367014ba 100644 --- a/packages/cli/src/commands/whoami.ts +++ b/packages/cli/src/commands/whoami.ts @@ -4,8 +4,8 @@ import { extractOrganization, extractWorkspace, } from '@spotify-confidence/core'; -import { print, extractFlags } from '@output/print.js'; -import { tryResolveAuth } from '@utils/auth.js'; +import { print, extractFlags, fail } from '@output/print.js'; +import { tryLoadAuthToken } from '@utils/auth.js'; export const whoamiCommand = { command: 'whoami', @@ -13,8 +13,15 @@ export const whoamiCommand = { async handler(argv: Record) { const profile = argv.profile as string | undefined; - const token = await tryResolveAuth(profile); - if (!token) return; + const { token, expired } = await tryLoadAuthToken(profile); + if (!token) { + fail( + expired + ? 'Auth expired. Run "confidence login" to re-authenticate.' + : 'Not logged in. Run "confidence login" first.', + ); + return; + } const payload = decodeJwtPayload(token); const email = (payload.email as string) ?? undefined; diff --git a/packages/cli/src/features/mcp/resolve-token.ts b/packages/cli/src/features/mcp/resolve-token.ts index 25148f8b..e2c19862 100644 --- a/packages/cli/src/features/mcp/resolve-token.ts +++ b/packages/cli/src/features/mcp/resolve-token.ts @@ -1,4 +1,4 @@ -import { message, fail } from '@output/print.js'; +import { hint, fail } from '@output/print.js'; import { authenticate } from '@spotify-confidence/core'; import { tryLoadAuthToken } from '@utils/auth.js'; @@ -6,17 +6,17 @@ export async function resolveAuthToken(opts?: { forceNew?: boolean; profile?: string; }): Promise { - if (!opts?.forceNew) { - const { token } = await tryLoadAuthToken(opts?.profile); - if (token) return token; - } - try { - message('Authentication required. Opening browser...'); + if (!opts?.forceNew) { + const { token } = await tryLoadAuthToken(opts?.profile); + if (token) return token; + } + + hint('Authentication required. Opening browser... (Ctrl+C to cancel)'); const result = await authenticate({ mode: 'login', profile: opts?.profile, - onUrl: (url) => message(`If the browser did not open, visit:\n${url}`), + onUrl: (url) => hint(`If the browser did not open, visit:\n${url}`), }); return result.accessToken; } catch (err) { diff --git a/packages/cli/src/output/print.ts b/packages/cli/src/output/print.ts index 1b35846f..da42be63 100644 --- a/packages/cli/src/output/print.ts +++ b/packages/cli/src/output/print.ts @@ -1,4 +1,4 @@ -import { extractText, type CallToolResult } from '@spotify-confidence/core'; +import { extractText, isInteractive, type CallToolResult } from '@spotify-confidence/core'; import type { OutputFormat } from './detect.js'; import { resolveFormat } from './detect.js'; import { formatJson } from './json.js'; @@ -46,6 +46,11 @@ export function warn(text: string): void { process.stderr.write(`Warning: ${text}\n`); } +export function hint(text: string): void { + if (!isInteractive()) return; + process.stdout.write(text + '\n'); +} + export function fail(text?: string): void { if (text) error(text); process.exitCode = 1; diff --git a/packages/cli/src/utils/auth.ts b/packages/cli/src/utils/auth.ts index 42f350bc..1c158b81 100644 --- a/packages/cli/src/utils/auth.ts +++ b/packages/cli/src/utils/auth.ts @@ -1,12 +1,14 @@ import { loadPersistedToken, isValidToken as isValid, + decodeJwtPayload, refreshAccessToken, hasExpiredSession, + hasMalformedToken, authenticate, isInteractive, } from '@spotify-confidence/core'; -import { fail, message } from '@output/print.js'; +import { fail, hint } from '@output/print.js'; type LoadTokenResult = { token: string | null; @@ -19,11 +21,13 @@ export async function tryLoadAuthToken(profile?: string): Promise { return null; } - message( + hint( loaded.expired ? 'Session expired. Opening browser to re-authenticate... (Ctrl+C to cancel)' : 'Not logged in to Confidence. Opening browser to authenticate... (Ctrl+C to cancel)', @@ -50,11 +54,10 @@ export async function tryResolveAuth(profile?: string): Promise { const result = await authenticate({ mode: 'login', profile, - signal: AbortSignal.timeout(120_000), - onUrl: (url) => message(`If the browser did not open, visit:\n${url}`), + onUrl: (url) => hint(`If the browser did not open, visit:\n${url}`), }); - message(`Authenticated as ${result.workspace ?? 'unknown'} (${result.region})`); + hint(`Authenticated as ${result.workspace ?? 'unknown'} (${result.region})`); return result.accessToken; } catch (err) { fail(`Login failed: ${(err as Error).message}`); diff --git a/packages/core/src/auth/authenticate/constants.ts b/packages/core/src/auth/authenticate/constants.ts index 23620276..b9fe634d 100644 --- a/packages/core/src/auth/authenticate/constants.ts +++ b/packages/core/src/auth/authenticate/constants.ts @@ -8,5 +8,7 @@ export const AUTH_SCOPE = 'openid profile email offline_access'; export const AUTH_CLIENT_ID_SIGNUP = '82qMvwZvqd3t3S0gRDvs8R53TehQXSJY'; export const AUTH_CLIENT_ID_LOGIN = '2fG3H4RhlAbIZm9Rfn32zTaILH7w1X4w'; export const AUTH_CALLBACK_PORT = 8084; +export const AUTH_TIMEOUT_MS = 120_000; export const SESSION_EXPIRED_ERR = 'session_expired'; +export const MALFORMED_TOKEN_ERR = 'malformed_token'; diff --git a/packages/core/src/auth/authenticate/index.ts b/packages/core/src/auth/authenticate/index.ts index 43167efe..5dc896e8 100644 --- a/packages/core/src/auth/authenticate/index.ts +++ b/packages/core/src/auth/authenticate/index.ts @@ -1,3 +1,3 @@ export { authenticate, refreshAccessToken, type AuthResult } from './oauth.js'; export { AUTH_CALLBACK_PORT } from './constants.js'; -export { hasExpiredSession } from './utils.js'; +export { hasExpiredSession, hasMalformedToken } from './utils.js'; diff --git a/packages/core/src/auth/authenticate/oauth.ts b/packages/core/src/auth/authenticate/oauth.ts index 85b1fdcf..8f8bf800 100644 --- a/packages/core/src/auth/authenticate/oauth.ts +++ b/packages/core/src/auth/authenticate/oauth.ts @@ -8,6 +8,7 @@ import { AUTH_CLIENT_ID_SIGNUP, AUTH_CLIENT_ID_LOGIN, AUTH_CALLBACK_PORT, + AUTH_TIMEOUT_MS, SESSION_EXPIRED_ERR, } from './constants.js'; import { generatePKCE } from './pkce.js'; @@ -79,9 +80,10 @@ export function authenticate(opts: { const organization = opts.mode === 'login' ? resolveOrganization(opts.profile) : undefined; const { verifier, challenge } = generatePKCE(); const redirectUri = `http://localhost:${AUTH_CALLBACK_PORT}/callback`; + const signal = opts.signal ?? AbortSignal.timeout(AUTH_TIMEOUT_MS); return new Promise((resolve, reject) => { - if (opts.signal?.aborted) { + if (signal.aborted) { reject(new Error('Authentication cancelled')); return; } @@ -141,16 +143,14 @@ export function authenticate(opts: { openBrowser(authUrl); }); - if (opts.signal) { - opts.signal.addEventListener( - 'abort', - () => { - server.close(); - reject(new Error('Authentication cancelled')); - }, - { once: true }, - ); - } + signal.addEventListener( + 'abort', + () => { + server.close(); + reject(new Error('Authentication cancelled')); + }, + { once: true }, + ); server.on('error', (err) => { reject( diff --git a/packages/core/src/auth/authenticate/utils.ts b/packages/core/src/auth/authenticate/utils.ts index f8d6f264..5f030325 100644 --- a/packages/core/src/auth/authenticate/utils.ts +++ b/packages/core/src/auth/authenticate/utils.ts @@ -1,5 +1,9 @@ -import { SESSION_EXPIRED_ERR } from './constants.js'; +import { SESSION_EXPIRED_ERR, MALFORMED_TOKEN_ERR } from './constants.js'; export function hasExpiredSession(err: unknown) { return (err as Error).cause === SESSION_EXPIRED_ERR; } + +export function hasMalformedToken(err: unknown) { + return (err as Error).cause === MALFORMED_TOKEN_ERR; +} diff --git a/packages/core/src/auth/index.ts b/packages/core/src/auth/index.ts index c8846cea..65895eb1 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -2,6 +2,7 @@ export { authenticate, refreshAccessToken, hasExpiredSession, + hasMalformedToken, AUTH_CALLBACK_PORT, type AuthResult, } from './authenticate/index.js'; diff --git a/packages/core/src/auth/jwt.ts b/packages/core/src/auth/jwt.ts index 06609267..55d35a58 100644 --- a/packages/core/src/auth/jwt.ts +++ b/packages/core/src/auth/jwt.ts @@ -1,8 +1,13 @@ +import { MALFORMED_TOKEN_ERR } from './authenticate/constants.js'; + export function decodeJwtPayload(token: string): Record { - const parts = token.split('.'); - if (parts.length < 2) throw new Error('Invalid JWT'); - const payload = Buffer.from(parts[1], 'base64url').toString('utf-8'); - return JSON.parse(payload) as Record; + try { + const parts = token.split('.'); + const payload = Buffer.from(parts[1], 'base64url').toString('utf-8'); + return JSON.parse(payload) as Record; + } catch { + throw new Error('Invalid JWT', { cause: MALFORMED_TOKEN_ERR }); + } } export function extractRegion(token: string): 'EU' | 'US' {