Skip to content

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: returns Available: true, Authorized: <env var is set>
  • Authorize: no-op; returns ErrNotAuthorized with NextStep: NextStepLogin if env not set
  • GetToken: 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; returns Available: <file exists>, Authorized: <not expired>
  • Authorize: shells out to claude --version for sanity; if file missing, prompts user via stdout "Run claude login first, then retry" and returns ErrNotAuthorized
  • GetToken: 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: runs gh auth status and parses output
  • Authorize: shells out to gh auth login if Interactive: true
  • GetToken: runs gh auth token and wraps the output
  • Capabilities.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: true on 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
  • Open should be fast. No network unless Capabilities.NetworkAtOpen: true.
  • Detect should be fast. No network. No prompts.
  • Authorize MAY block on user interaction; respect AuthorizeOptions.Timeout.
  • GetToken should 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 for GetToken in steady state.
  • Close releases 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.Value is opaque-by-convention; every implementation MUST treat it as a secret.
  • Token cache files MUST be 0600 (user-readable only).
  • Audit events. Each GetToken call emits a minimal audit event (source_name, provider_id, sink_id, timestamp, success) — no token value, no scope detail. Audit goes through the existing audit/v1 surface (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: true flag per source, plus an allowlist configurable at the orchestrator level.

Versioning

Per the Extension Points convention:

  • auth-source/v1 is this document.
  • Breaking changes ship as v2 alongside v1 until removal.
  • Adding a new ErrorKind, NextStepHint, or CredentialType is 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