Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion src/services/oauth/verbooStartupAuth.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { expect, test } from 'bun:test'
import { afterEach, expect, test } from 'bun:test'

import { getCLIEntitlementDeniedMessage } from './cliEntitlement.js'
import {
HEADLESS_UNAUTHENTICATED_MESSAGE,
VERBOO_API_KEY_INVALID_MESSAGE,
headlessSessionFailureError,
readHeadlessVerbooApiKey,
validateVerbooApiKey,
} from './verbooStartupAuth.js'

test('explains each denied CLI entitlement without referring to router models', () => {
expect(getCLIEntitlementDeniedMessage('past_due')).toContain(
Expand All @@ -11,3 +18,68 @@ test('explains each denied CLI entitlement without referring to router models',
'assinatura Verboo Code ativa',
)
})

const originalApiKey = process.env.ANTHROPIC_API_KEY

afterEach(() => {
if (originalApiKey === undefined) {
delete process.env.ANTHROPIC_API_KEY
} else {
process.env.ANTHROPIC_API_KEY = originalApiKey
}
})

test('headless Verboo API key is read from ANTHROPIC_API_KEY when it is vbk_', () => {
process.env.ANTHROPIC_API_KEY = 'vbk_from_env_key_ok'
expect(readHeadlessVerbooApiKey()).toBe('vbk_from_env_key_ok')
})

test('headless Verboo API key ignores non-vbk env and uses the FD fallback', () => {
process.env.ANTHROPIC_API_KEY = 'sk-ant-not-a-verboo-key'
expect(
readHeadlessVerbooApiKey(() => 'vbk_from_fd_key_ok'),
).toBe('vbk_from_fd_key_ok')
})

test('headless Verboo API key is absent when neither env nor FD is vbk_', () => {
delete process.env.ANTHROPIC_API_KEY
expect(readHeadlessVerbooApiKey(() => null)).toBeUndefined()
})

test('validates vbk_ against the router models endpoint, not /api/me', async () => {
const urls: string[] = []
const result = await validateVerbooApiKey(
'vbk_test_key_long_enough',
async (url, config) => {
urls.push(String(url))
expect(
(config as { headers: { Authorization: string } }).headers
.Authorization,
).toBe('Bearer vbk_test_key_long_enough')
return { status: 200, data: { data: [{ id: 'model' }] } }
},
)
expect(result).toBe('ok')
expect(urls[0]).toContain('/router/v1/models')
expect(urls[0]).not.toContain('/api/me')
})

test('a 401 from the router marks the vbk_ key unauthorized', async () => {
const result = await validateVerbooApiKey(
'vbk_expired_or_wrong',
async () => ({ status: 401, data: { error: 'invalid or expired token' } }),
)
expect(result).toBe('unauthorized')
})

test('headless failure with an invalid vbk_ is specific, not the OAuth login prompt', () => {
expect(headlessSessionFailureError({ kind: 'invalid-api-key' }).message).toBe(
VERBOO_API_KEY_INVALID_MESSAGE,
)
expect(VERBOO_API_KEY_INVALID_MESSAGE).toBe('API key inválida ou expirada')
expect(
headlessSessionFailureError({ kind: 'unauthenticated' }).message,
).toBe(HEADLESS_UNAUTHENTICATED_MESSAGE)
expect(HEADLESS_UNAUTHENTICATED_MESSAGE).toContain('verboo /login')
expect(HEADLESS_UNAUTHENTICATED_MESSAGE).not.toContain('API key')
})
136 changes: 126 additions & 10 deletions src/services/oauth/verbooStartupAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { runOAuthLoginFlow } from '../../cli/handlers/auth.js'
import {
getOauthConfig,
isVerbooMode,
VERBOO_ROUTER_URL,
} from '../../constants/oauth.js'
import { getApiKeyFromFileDescriptor } from '../../utils/authFileDescriptor.js'
import {
checkAndRefreshOAuthTokenIfNeeded,
clearOAuthTokenCache,
Expand Down Expand Up @@ -56,8 +58,106 @@ import {
export type VerbooSessionResult =
| { kind: 'ok'; tokens: OAuthTokens; refreshed: boolean }
| { kind: 'unauthenticated' }
| { kind: 'invalid-api-key' }
| { kind: 'degraded'; reason: string }

export const VERBOO_API_KEY_PREFIX = 'vbk_'
export const VERBOO_API_KEY_INVALID_MESSAGE = 'API key inválida ou expirada'
export const HEADLESS_UNAUTHENTICATED_MESSAGE =
'Não autenticado no Verboo. Execute `verboo /login` em um terminal interativo antes de usar o modo headless.'

type RouterGet = (
url: string,
config?: {
headers?: Record<string, string>
timeout?: number
validateStatus?: () => boolean
},
) => Promise<{ status: number; data?: unknown }>

function isVerbooApiKey(value: string | null | undefined): value is string {
return Boolean(value?.startsWith(VERBOO_API_KEY_PREFIX))
}

/** ANTHROPIC_API_KEY first (what the desktop injects), then the FD equivalent. */
export function readHeadlessVerbooApiKey(
fromFd: () => string | null = getApiKeyFromFileDescriptor,
): string | undefined {
const fromEnv = process.env.ANTHROPIC_API_KEY?.trim()
if (isVerbooApiKey(fromEnv)) return fromEnv
const fd = fromFd()?.trim()
if (isVerbooApiKey(fd)) return fd
return undefined
}

/**
* Router `/models` is the endpoint the desktop already uses with Bearer vbk_
* (model_service.rs). `/api/me` is the OAuth account API — fake vbk_ and fake
* JWT both return the same 401, so we do not claim it accepts API keys.
*/
export async function validateVerbooApiKey(
key: string,
get: RouterGet = axios.get,
): Promise<'ok' | 'unauthorized' | 'error'> {
const endpoint = `${VERBOO_ROUTER_URL}/models`
try {
const response = await get(endpoint, {
headers: {
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json',
},
timeout: 5_000,
validateStatus: () => true,
})
if (response.status === 200) return 'ok'
if (response.status === 401 || response.status === 403) return 'unauthorized'
return 'error'
} catch {
return 'error'
}
}

function apiKeySessionTokens(key: string): OAuthTokens {
return {
accessToken: key,
refreshToken: null,
expiresAt: null,
scopes: [],
subscriptionType: null,
rateLimitTier: null,
}
}

async function sessionFromVerbooApiKey(): Promise<VerbooSessionResult> {
const key = readHeadlessVerbooApiKey()
if (!key) return { kind: 'unauthenticated' }
const check = await validateVerbooApiKey(key)
if (check === 'ok') {
return { kind: 'ok', tokens: apiKeySessionTokens(key), refreshed: false }
}
if (check === 'unauthorized') return { kind: 'invalid-api-key' }
return { kind: 'degraded', reason: 'API key validation failed' }
}

async function unauthenticatedOrApiKey(): Promise<VerbooSessionResult> {
const apiKeySession = await sessionFromVerbooApiKey()
if (apiKeySession.kind !== 'unauthenticated') return apiKeySession
return { kind: 'unauthenticated' }
}

function isApiKeySession(tokens: OAuthTokens): boolean {
return tokens.accessToken.startsWith(VERBOO_API_KEY_PREFIX)
}

export function headlessSessionFailureError(
session: { kind: 'invalid-api-key' | 'unauthenticated' },
): Error {
if (session.kind === 'invalid-api-key') {
return new Error(VERBOO_API_KEY_INVALID_MESSAGE)
}
return new Error(HEADLESS_UNAUTHENTICATED_MESSAGE)
}

export type VerbooLoginPreflightResult =
| {
kind: 'ready'
Expand Down Expand Up @@ -146,7 +246,9 @@ export async function validateVerbooSession(): Promise<VerbooSessionResult> {

const tokens = await getClaudeAIOAuthTokensAsync()
if (!tokens?.accessToken) {
return { kind: 'unauthenticated' }
// OAuth is primary; vbk_ is the headless fallback (desktop injects it
// as ANTHROPIC_API_KEY).
return sessionFromVerbooApiKey()
}

let result = await callApiMe(tokens.accessToken)
Expand All @@ -158,10 +260,10 @@ export async function validateVerbooSession(): Promise<VerbooSessionResult> {
if (!didOAuthRefreshRecover(outcome)) {
return outcome === 'transient_error'
? { kind: 'degraded', reason: 'temporary OAuth refresh failure' }
: { kind: 'unauthenticated' }
: unauthenticatedOrApiKey()
}
const refreshed = await getClaudeAIOAuthTokensAsync()
if (!refreshed?.accessToken) return { kind: 'unauthenticated' }
if (!refreshed?.accessToken) return unauthenticatedOrApiKey()
result = await callApiMe(refreshed.accessToken)
if (result.status === 'ok' && result.data) {
persistAccount(result.data)
Expand All @@ -170,7 +272,7 @@ export async function validateVerbooSession(): Promise<VerbooSessionResult> {
} catch (err) {
logError(err as Error)
}
return { kind: 'unauthenticated' }
return unauthenticatedOrApiKey()
}

if (result.status === 'ok') {
Expand All @@ -184,7 +286,7 @@ export async function validateVerbooSession(): Promise<VerbooSessionResult> {
}

if (result.status === 'unauthorized') {
return { kind: 'unauthenticated' }
return unauthenticatedOrApiKey()
}

// Erro de rede / 5xx: deixar passar com warning para não bloquear startup
Expand Down Expand Up @@ -341,9 +443,19 @@ export async function preflightVerbooLogin(): Promise<VerbooLoginPreflightResult
if (session.kind === 'unauthenticated') {
return { kind: 'needs-oauth', reason: 'unauthenticated' }
}
if (session.kind === 'invalid-api-key') {
return { kind: 'degraded', reason: VERBOO_API_KEY_INVALID_MESSAGE }
}
if (session.kind === 'degraded') {
return { kind: 'degraded', reason: session.reason }
}
if (isApiKeySession(session.tokens)) {
return {
kind: 'ready',
tokens: session.tokens,
refreshed: session.refreshed,
}
}

const terms = await fetchVerbooTermsStatus(session.tokens.accessToken)
if (terms.kind === 'unauthorized') {
Expand Down Expand Up @@ -392,8 +504,10 @@ export async function ensureVerbooAuthenticated(
const session = await validateVerbooSession()

if (session.kind === 'ok') {
await ensureVerbooTermsAccepted(session.tokens.accessToken)
await ensureCLIEntitlement(session.tokens.accessToken)
if (!isApiKeySession(session.tokens)) {
await ensureVerbooTermsAccepted(session.tokens.accessToken)
await ensureCLIEntitlement(session.tokens.accessToken)
}
await loadVerbooCatalog(session.tokens.accessToken)
await primeCodexCatalogIfAuthenticated()
await primeClaudeCatalogIfAuthenticated()
Expand All @@ -419,11 +533,13 @@ export async function ensureVerbooAuthenticated(
return
}

if (session.kind === 'invalid-api-key') {
throw headlessSessionFailureError(session)
}

// unauthenticated → precisa abrir login. Só faz sentido em TTY.
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error(
'Não autenticado no Verboo. Execute `verboo /login` em um terminal interativo antes de usar o modo headless.',
)
throw headlessSessionFailureError(session)
}

process.stdout.write(
Expand Down
39 changes: 39 additions & 0 deletions src/utils/secureStorage/platformStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,45 @@ describe("Secure Storage Platform Implementations", () => {
});
});

/**
* Issue #77 — DPAPI file must be UTF-8 without BOM.
*
* LIMIT: form-only. These tests assert the generated PowerShell script.
* Real Windows PowerShell 5.1 / .NET Framework / DPAPI is not exercisable
* on this host (macOS).
*/
describe("Windows DPAPI write encoding (issue #77)", () => {
function updateScript(): string {
windowsCredentialStorage.update(testData);
return execaCalls()[0][1][1];
}

function writePath(script: string): string {
const idx = script.indexOf("[System.IO.File]::WriteAllText");
expect(idx).toBeGreaterThan(-1);
return script.slice(idx);
}

test("WriteAllText uses UTF8Encoding($false) and not Encoding::UTF8", () => {
const write = writePath(updateScript());

expect(write).toContain("New-Object System.Text.UTF8Encoding($false)");
expect(write).not.toContain("[System.Text.Encoding]::UTF8");
expect(write).not.toMatch(/\bOut-File\b/);
expect(write).not.toMatch(/\bSet-Content\b/);
});

test("post-write validation re-reads bytes and FromBase64String-decodes without BOM strip", () => {
const write = writePath(updateScript());

expect(write).toContain("[System.IO.File]::ReadAllBytes");
expect(write).toContain("[Convert]::FromBase64String");
expect(write).toContain("-cne $protectedBase64");
expect(write).toMatch(/Write-Error/);
expect(write).toMatch(/exit\s+1/);
});
});

describe("Windows PowerShell Escaping", () => {
test("escapes single quotes and prevents $ expansion", () => {
const dataWithDollar = {
Expand Down
9 changes: 8 additions & 1 deletion src/utils/secureStorage/windowsCredentialStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,15 @@ export const windowsCredentialStorage: SecureStorage = {
[System.IO.File]::WriteAllText(
$path,
$protectedBase64,
[System.Text.Encoding]::UTF8
(New-Object System.Text.UTF8Encoding($false))
)
$writtenBytes = [System.IO.File]::ReadAllBytes($path)
$writtenText = [System.Text.Encoding]::ASCII.GetString($writtenBytes)
if ($writtenText -cne $protectedBase64) {
Write-Error 'DPAPI post-write validation failed: file is not exact UTF-8 base64 without BOM'
exit 1
}
[void][Convert]::FromBase64String($writtenText)
} catch {
Write-Error $_.Exception.Message
exit 1
Expand Down
Loading