diff --git a/packages/cli/__tests__/commands/whoami.test.ts b/packages/cli/__tests__/commands/whoami.test.ts index 4a8bb794..1fdab954 100644 --- a/packages/cli/__tests__/commands/whoami.test.ts +++ b/packages/cli/__tests__/commands/whoami.test.ts @@ -1,38 +1,47 @@ -import { buildTestJwt, buildExpiredJwt, prepareAuthTokens } from '@spotify-confidence/testing/auth'; -import { loadPersistedToken, validateToken, 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(validateToken(token!).valid).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', () => { - const sut = validateToken; + it('fails with not-logged-in message when no credentials exist', async () => { + using _auth = prepareAuthTokens('none'); + using output = captureOutput(); - expect(sut(buildExpiredJwt()).valid).toBe(false); + 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/__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/cli/__tests__/utils/auth.test.ts b/packages/cli/__tests__/utils/auth.test.ts new file mode 100644 index 00000000..a89b1309 --- /dev/null +++ b/packages/cli/__tests__/utils/auth.test.ts @@ -0,0 +1,131 @@ +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>(); + +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 tryResolveAuth(); + + expect(sut).toBeTruthy(); + expect(mockAuthenticate).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' }); + server.use(tokenResponse(freshToken)); + + const sut = await tryResolveAuth(); + + expect(sut).toBe(freshToken); + 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(); + const freshToken = buildTestJwt({ email: 'new@example.com' }); + mockAuthenticate.mockResolvedValue({ + accessToken: freshToken, + region: 'EU', + workspace: 'test-ws', + }); + + const sut = await tryResolveAuth(); + + 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(); + server.use(tokenError()); + const freshToken = buildTestJwt({ email: 'reauth@example.com' }); + mockAuthenticate.mockResolvedValue({ + accessToken: freshToken, + region: 'US', + workspace: 'ws-2', + }); + + const sut = await tryResolveAuth(); + + expect(sut).toBe(freshToken); + 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(); + + const sut = await tryResolveAuth(); + + 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(); + + const sut = await tryResolveAuth(); + + 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(); + mockAuthenticate.mockRejectedValue(new Error('Port 8084 in use')); + + const sut = await tryResolveAuth(); + + 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(); + const freshToken = buildTestJwt(); + mockAuthenticate.mockResolvedValue({ + accessToken: freshToken, + region: 'EU', + workspace: 'ws', + }); + + await tryResolveAuth('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..367014ba 100644 --- a/packages/cli/src/commands/whoami.ts +++ b/packages/cli/src/commands/whoami.ts @@ -1,39 +1,39 @@ -import { loadPersistedToken, validateToken, decodeJwtPayload } from '@spotify-confidence/core'; -import { print, fail, extractFlags } from '@output/print.js'; +import { + decodeJwtPayload, + extractRegion, + extractOrganization, + extractWorkspace, +} from '@spotify-confidence/core'; +import { print, extractFlags, fail } from '@output/print.js'; +import { tryLoadAuthToken } from '@utils/auth.js'; 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); + const { token, expired } = await tryLoadAuthToken(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.'); + 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; - const org = - (payload.org_id as string) ?? - (payload['https://confidence.dev/org_login_id'] 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: validation.region ?? 'unknown', - workspace: validation.workspace ?? 'unknown', + organization: extractOrganization(token) ?? 'unknown', + region: extractRegion(token), + workspace: extractWorkspace(token) ?? '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..e2c19862 100644 --- a/packages/cli/src/features/mcp/resolve-token.ts +++ b/packages/cli/src/features/mcp/resolve-token.ts @@ -1,24 +1,22 @@ -import { message, fail } from '@output/print.js'; -import { loadPersistedToken, authenticate, validateToken } from '@spotify-confidence/core'; +import { hint, fail } from '@output/print.js'; +import { authenticate } from '@spotify-confidence/core'; +import { tryLoadAuthToken } 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; + try { + if (!opts?.forceNew) { + const { token } = await tryLoadAuthToken(opts?.profile); + if (token) return token; } - } - try { - message('Authentication required. Opening browser...'); + 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 ed83ebf9..1c158b81 100644 --- a/packages/cli/src/utils/auth.ts +++ b/packages/cli/src/utils/auth.ts @@ -1,28 +1,77 @@ -import { loadPersistedToken, validateToken } from '@spotify-confidence/core'; -import { fail } from '@output/print.js'; +import { + loadPersistedToken, + isValidToken as isValid, + decodeJwtPayload, + refreshAccessToken, + hasExpiredSession, + hasMalformedToken, + authenticate, + isInteractive, +} from '@spotify-confidence/core'; +import { fail, hint } from '@output/print.js'; -export function requireAuth(profile?: string): string | null { +type LoadTokenResult = { + token: string | null; + expired: boolean; +}; + +export async function tryLoadAuthToken(profile?: string): Promise { const token = loadPersistedToken(profile); - if (!token) { - fail('Not logged in. Run "confidence login" first.'); - return null; + if (!token) return { token: null, expired: false }; + if (isValid(token)) return { token, expired: false }; + + try { + decodeJwtPayload(token); + const refreshed = await refreshAccessToken(profile); + return { token: refreshed.accessToken, expired: false }; + } catch (err) { + if (hasMalformedToken(err)) return { token: null, expired: false }; + if (hasExpiredSession(err)) return { token: null, expired: true }; + throw err; } +} + +export async function tryResolveAuth(profile?: string): Promise { + try { + const loaded = await tryLoadAuthToken(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; + } + + 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)', + ); + + const result = await authenticate({ + mode: 'login', + profile, + onUrl: (url) => hint(`If the browser did not open, visit:\n${url}`), + }); + + hint(`Authenticated as ${result.workspace ?? 'unknown'} (${result.region})`); + return result.accessToken; + } catch (err) { + fail(`Login failed: ${(err as Error).message}`); return null; } - - return token; } 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 tryResolveAuth(argv.profile as string | undefined); if (!token) return; + await fn(argv, token); }; } 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/constants.ts b/packages/core/src/auth/authenticate/constants.ts index bdd768b7..b9fe634d 100644 --- a/packages/core/src/auth/authenticate/constants.ts +++ b/packages/core/src/auth/authenticate/constants.ts @@ -8,3 +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 b13d0251..5dc896e8 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, hasMalformedToken } from './utils.js'; diff --git a/packages/core/src/auth/authenticate/oauth.ts b/packages/core/src/auth/authenticate/oauth.ts index ccff5561..8f8bf800 100644 --- a/packages/core/src/auth/authenticate/oauth.ts +++ b/packages/core/src/auth/authenticate/oauth.ts @@ -1,13 +1,15 @@ 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, 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'; import { openBrowser } from '../../system/browser.js'; @@ -35,7 +37,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,14 +54,14 @@ 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; 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, @@ -78,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; } @@ -120,7 +123,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); @@ -140,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 new file mode 100644 index 00000000..5f030325 --- /dev/null +++ b/packages/core/src/auth/authenticate/utils.ts @@ -0,0 +1,9 @@ +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 9c351769..65895eb1 100644 --- a/packages/core/src/auth/index.ts +++ b/packages/core/src/auth/index.ts @@ -1,9 +1,17 @@ export { authenticate, refreshAccessToken, + hasExpiredSession, + hasMalformedToken, AUTH_CALLBACK_PORT, 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..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' { @@ -19,24 +24,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/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(); } 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; } 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'; 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';