auth-source/v1 — Credential Provider Contract¶
Status: locked, v1.0
Lock date: 2026-05-17
Parent epic:
blackrim-vox-bx9— subscription-auth from LLM clients
Why this exists¶
Vox routes voice intent to LLM sinks. Today every LLM sink (llm-anthropic,
llm-openai, …) gets its credential from a single mechanism — typically a
BYOK API key in env or config. That works for hobbyists but adds friction
for users who already pay for and run Claude Code, Codex, Cursor, or
GitHub Copilot: they have a valid session sitting on the machine and have
to set up a second credential just to use Vox.
auth-source/v1 is the abstraction that lets Vox pull credentials from
any of those existing sources. BYOK is one implementation of the
contract; subscription passthroughs are others. They differ only in how
tokens are acquired.
Surface inventory entry¶
| Field | Value |
|---|---|
| Surface | auth-source/v1 |
| Status | Open (this doc) |
| Used by | Every LLM sink, optionally any future sink that needs an external credential |
| Implementation lives in | Per-provider packages under internal/authsource/<name> |
| Registration | Loader registers AuthSources by ProviderID; sink picks at Open() time |
Concepts¶
AuthSource¶
A named credential provider. Examples:
- byok-anthropic — reads ANTHROPIC_API_KEY from env or config
- byok-openai — reads OPENAI_API_KEY
- claude-code — extracts a bearer from Claude Code's local session
- codex — extracts a bearer from Codex's local session
- cursor — uses Cursor's extension API for an authenticated session
- copilot-cli — uses gh auth token for Copilot's GitHub-backed flow
- oauth-generic — a configurable OAuth 2.0 bridge for any provider that
exposes one
The same ProviderID (e.g. anthropic) may be served by multiple
AuthSources (e.g. byok-anthropic and claude-code); the user picks one
per LLM-sink instance via config.
Credential¶
The token an LLM sink needs to make a request. Opaque to Vox; meaningful only to the downstream API.
type Credential struct {
Type CredentialType // "bearer" | "api-key" | "cookie" | "basic" | "custom"
Value string // opaque; do not log
HeaderKey string // e.g. "Authorization", "x-api-key", "Cookie"
Scheme string // e.g. "Bearer ", "" (api keys typically have no scheme)
ExpiresAt time.Time // zero = no expiry
Custom map[string]string // any source-specific extras (rare)
}
The LLM sink applies this to outgoing HTTP requests as:
request.Header[Credential.HeaderKey] = Credential.Scheme + Credential.Value.
DetectResult¶
Fast, side-effect-free check of whether the source can produce a credential on this machine right now without user interaction.
type DetectResult struct {
Available bool // is the underlying provider present at all?
Authorized bool // is there a valid, non-expired token already?
Reason string // human-readable when !Available or !Authorized
NextStep NextStepHint
}
type NextStepHint string
const (
NextStepInstall NextStepHint = "install" // provider binary/app missing
NextStepLogin NextStepHint = "login" // user must complete provider's login flow
NextStepAuthorize NextStepHint = "authorize" // call AuthSource.Authorize
NextStepNone NextStepHint = "none" // ready to use
)
Interface¶
package authsource
import (
"context"
"time"
)
// AuthSource is a credential provider.
type AuthSource interface {
// Identity
Name() string
Capabilities() Capabilities
// Lifecycle
Open(ctx context.Context, config map[string]interface{}) error
Close(ctx context.Context) error
// State queries — fast, no UI, no network unless explicitly cheap
Detect(ctx context.Context) (DetectResult, error)
// Authorization handshake — may block on user interaction
Authorize(ctx context.Context, opts AuthorizeOptions) (AuthorizeResult, error)
// Hot path — called per LLM request
GetToken(ctx context.Context, req TokenRequest) (*Credential, error)
// Diagnostics
Stats() Stats
Health(ctx context.Context) Health
}
// Capabilities advertises what the AuthSource supports.
type Capabilities struct {
ProviderIDs []string // e.g. ["anthropic"]; oauth-generic may list many
Scopes []string // optional scope strings the source can produce
Refreshable bool // tokens self-refresh; caller can long-cache
RequiresInteraction bool // Authorize() will need user input (browser, CLI prompt)
Detectable bool // Detect() can verify availability; if false, caller must ask user
ReadsLocalFile bool // reads from a file on disk (e.g. session store) — relevant for sandboxing
NetworkAtOpen bool // makes a network call during Open() (e.g. token validation)
}
// TokenRequest is what an LLM sink asks for.
type TokenRequest struct {
ProviderID string // which downstream API the sink will talk to
Scopes []string // optional; e.g. ["chat:complete"]
SinkID string // who is asking — for audit logs only
Extra map[string]string // source-specific extras (rare)
}
// AuthorizeOptions controls the handshake.
type AuthorizeOptions struct {
Interactive bool // permit user-facing prompts/browser flows
Timeout time.Duration // give up after this if Interactive
DeviceCode bool // prefer device-code flow over browser when applicable
}
// AuthorizeResult reports what happened.
type AuthorizeResult struct {
Success bool
PromptURL string // for OAuth device-code flows
PromptCode string // user enters this on PromptURL
ExpiresAt time.Time
DetailMsg string
}
// Stats — running counters for diagnostics.
type Stats struct {
TokensIssued uint64
RefreshAttempts uint64
AuthorizationFlows uint64
Failures uint64
LastFailureReason string
LastIssueAt time.Time
}
// Health — current operational state.
type Health struct {
State HealthState
Reason string
}
type HealthState string
const (
HealthOK HealthState = "ok"
HealthDegraded HealthState = "degraded" // can still issue tokens but with caveats
HealthUnhealthy HealthState = "unhealthy" // tokens unavailable
)
Error model¶
type ErrorKind string
const (
ErrNotDetected ErrorKind = "not_detected" // provider binary/session not present
ErrNotAuthorized ErrorKind = "not_authorized" // detected, but user must log in
ErrAuthorizationFailed ErrorKind = "authorization_failed" // handshake failed
ErrTokenExpired ErrorKind = "token_expired" // expired and non-refreshable
ErrUnsupportedProvider ErrorKind = "unsupported_provider" // source doesn't speak that ProviderID
ErrUnsupportedScope ErrorKind = "unsupported_scope" // source can't produce that scope
ErrUserDeclined ErrorKind = "user_declined" // interactive flow rejected by user
ErrTransient ErrorKind = "transient" // retry later
ErrInternal ErrorKind = "internal" // bug / unexpected state
)
type AuthError struct {
Kind ErrorKind
Provider string // ProviderID involved (or "" if N/A)
Inner error // wrapped underlying error
Retry bool // hint: is retry likely to succeed?
}
func (e *AuthError) Error() string { ... }
func (e *AuthError) Unwrap() error { return e.Inner }
Per-provider implementation sketches¶
These are illustrative — each gets a separate bd for actual implementation.
byok-anthropic (the simplest)¶
auth_source: byok-anthropic
config:
env_var: ANTHROPIC_API_KEY # default
# OR file_path: /path/to/key.txt
Detect: returnsAvailable: true, Authorized: <env var is set>Authorize: no-op; returnsErrNotAuthorizedwithNextStep: NextStepLoginif env not setGetToken: returns{Type: "api-key", Value: <env>, HeaderKey: "x-api-key", Scheme: ""}Capabilities.Refreshable: false,RequiresInteraction: false,Detectable: true
claude-code (subscription passthrough)¶
auth_source: claude-code
config:
session_path: ~/.claude/session.json # default; auto-detected
refresh_threshold: 5m # refresh if token expires within this window
Detect: looks for the local session file; parses expiry; returnsAvailable: <file exists>, Authorized: <not expired>Authorize: shells out toclaude --versionfor sanity; if file missing, prompts user via stdout "Runclaude loginfirst, then retry" and returnsErrNotAuthorizedGetToken: reads the session file, extracts the bearer, returns{Type: "bearer", Value: <bearer>, HeaderKey: "Authorization", Scheme: "Bearer "}Capabilities.ProviderIDs: ["anthropic"],RequiresInteraction: false(delegates to Claude Code's own login flow)- Upstream coordination required: confirm Anthropic permits this usage pattern under the Claude Code subscription terms before shipping.
copilot-cli (GitHub-backed)¶
auth_source: copilot-cli
config:
gh_binary: gh # path or "gh" on PATH
Detect: runsgh auth statusand parses outputAuthorize: shells out togh auth loginifInteractive: trueGetToken: runsgh auth tokenand wraps the outputCapabilities.ProviderIDs: ["copilot"]- Upstream coordination required: GitHub Copilot's Terms may limit third-party reuse of the auth token.
oauth-generic (the escape hatch)¶
auth_source: oauth-generic
config:
provider_id: someprovider
authorize_endpoint: https://...
token_endpoint: https://...
client_id: ...
scopes: [chat]
flow: device_code # or "pkce"
token_cache: ~/.vox/auth-cache/<provider_id>.json
- A configurable OAuth 2.0 client. Supports PKCE and device-code flows.
- Caches tokens locally with refresh handling.
RequiresInteraction: trueon first run; subsequent calls use the cache.- This is the fallback for any provider Vox doesn't ship a dedicated source for.
Lifecycle¶
Closed → Open(ctx, config) → Opened
Opened → Detect(ctx) → may report Authorized=false
→ Authorize(ctx) → Authorized=true (or error)
Opened → GetToken(ctx, req) → Credential
Opened → Close(ctx) → Closed
Openshould be fast. No network unlessCapabilities.NetworkAtOpen: true.Detectshould be fast. No network. No prompts.AuthorizeMAY block on user interaction; respectAuthorizeOptions.Timeout.GetTokenshould be fast. May refresh internally if token close to expiry; short network calls OK (e.g. OAuth refresh). Caller can budget on a 1s ceiling forGetTokenin steady state.Closereleases resources (file handles, refresh timers).
Integration with LLM sinks¶
LLM sinks (internal/sink/llmanthropic, etc.) declare an auth_source in
their config:
sinks:
- name: anthropic-via-claude-code
type: llm-anthropic
config:
model: claude-opus-4-7
auth_source: claude-code
auth_source_config:
refresh_threshold: 5m
At sink Open() time:
1. Sink looks up the AuthSource by name (registered by the loader).
2. Sink calls authSource.Open(ctx, config.auth_source_config).
3. Sink calls authSource.Detect(ctx). If Available=false, fail fast
with a clear message ("Claude Code not installed at /Applications/Claude
Code.app — install it or pick a different auth_source").
4. Sink calls authSource.GetToken(ctx, req) on every LLM request; applies
the returned Credential to the HTTP request.
The sink does NOT call authSource.Authorize(ctx) automatically — that's
an explicit operator gesture (typically via vox auth login <source> CLI).
Sinks fail with a clear NextStep: NextStepAuthorize hint when needed.
CLI surface¶
# Inventory installed auth sources
vox auth list-sources
# Detect availability + authorization state of all sources
vox auth status
# Run interactive Authorize on a specific source
vox auth login claude-code
# Pick the default auth source for a given provider
vox auth select --provider anthropic --source claude-code
# Forget cached credentials for a source
vox auth logout claude-code
Each CLI verb is a separate bd under epic blackrim-vox-bx9; 92f
tracks the initial onboarding subcommand set.
Security posture¶
- Tokens MUST NOT be logged.
Credential.Valueis opaque-by-convention; every implementation MUST treat it as a secret. - Token cache files MUST be
0600(user-readable only). - Audit events. Each
GetTokencall emits a minimal audit event (source_name,provider_id,sink_id,timestamp,success) — no token value, no scope detail. Audit goes through the existingaudit/v1surface (open hook, enterprise sink). - No silent renewal of subscription tokens. If a source can't extract a current valid token, it MUST fail loudly with a clear next-step. Vox does not run background login flows.
- Per-source consent flag. Some operators may prohibit subscription
passthroughs entirely (e.g. corporate policy that mandates BYOK). The
loader honors a
disabled: trueflag per source, plus an allowlist configurable at the orchestrator level.
Versioning¶
Per the Extension Points convention:
auth-source/v1is this document.- Breaking changes ship as
v2alongsidev1until removal. - Adding a new
ErrorKind,NextStepHint, orCredentialTypeis non-breaking and lands without a major bump.
What's explicitly NOT in v1¶
- OAuth dynamic client registration. Each non-BYOK source is built
against a known provider; v1 doesn't try to be a generic OAuth toolkit
beyond
oauth-generic. - Token sharing across sinks. Each sink that needs auth opens its own source instance. v2 may add a process-wide credential cache.
- Per-request scope downgrade. v1 assumes the source produces the union of declared scopes; v2 may add request-time scope selection.
- Browser-controlled sources (Cursor, web extension flows). These need the extension API surface they target; the design sketches mention them but actual impl is per-provider bd.
Reference build order¶
| Order | Source | Status | Bead |
|---|---|---|---|
| 1 | byok-anthropic / byok-openai (refactor existing) |
Planned | (refactor of current llm-anthropic env-key path) |
| 2 | claude-code |
Planned | blackrim-vox-p4f |
| 3 | codex |
Planned | blackrim-vox-2o8 |
| 4 | copilot-cli |
Planned | blackrim-vox-d3j |
| 5 | cursor |
Planned | blackrim-vox-7bf |
| 6 | oauth-generic |
Planned | blackrim-vox-8wa |
Upstream coordination¶
Every subscription passthrough source requires explicit confirmation from the upstream provider that the usage pattern is permitted under their subscription terms. This is filed per-source and tracked in each source's own bead. Without that confirmation, the source ships as documented draft but is gated off in the default loader.
| Source | Upstream | Status |
|---|---|---|
claude-code |
Anthropic | Not yet contacted |
codex |
OpenAI | Not yet contacted |
copilot-cli |
GitHub | Not yet contacted |
cursor |
Cursor (Anysphere) | Not yet contacted |
Acceptance criteria for this bead¶
- [x] Contract drafted (this document)
- [x] Interface defined in Go-style pseudo-code
- [x] Error model enumerated
- [x] At least three per-provider sketches walked through
- [x] CLI surface described
- [x] Security posture stated
- [x] Build order recorded with bead references
- [x] Upstream-coordination requirement captured per source