Documentation
¶
Overview ¶
Package auth ships Harbor's tool-side OAuth subsystem — the TokenStore + OAuthProvider seam every Phase 27 (HTTP) / Phase 28 (MCP) / Phase 29 (A2A) southbound driver consults when a tool call needs a bearer token. There is one OAuth path: when no usable token exists the provider returns a typed ErrAuthRequired carrying a structured payload (provider, scope, binding-scope, authorize URL); the runtime emits `tool.auth_required` and parks the run via the unified pause/resume primitive (Phase 50 / RFC §3.3). Resume reattaches the freshly-minted token; A2A `AUTH_REQUIRED` translates into the same ErrAuthRequired so the runtime never grows a parallel pause path (CLAUDE.md §13 forbids two parallel implementations of one conceptual feature).
The binding-scope dimension ¶
Tool-side OAuth has two production patterns Harbor supports as first-class peers (brief 09):
- ScopeUser — the token belongs to a Harbor user; the upstream service sees that user doing the action. Examples: personal GitHub, Gmail, Drive, Notion. Lookups key by (tenant, user_id, source).
- ScopeAgent — the token belongs to a Harbor agent (an admin-configured service-account-style principal — Phase 53a / D-059's `agent_id`); the upstream sees the agent (or a shared service account). Examples: a shared Outlook mailbox, an internal Snowflake service account, a Slack bot user. Lookups key by (tenant, agent_id, source).
BindingScope is a **declared config field on OAuthConfig**, never inferred from runtime state. The master-plan acceptance criterion is explicit: "BindingScope is a declared config field, not inferred."
agent_id is NOT an isolation principal ¶
Per CLAUDE.md §6's clarifying note + D-059: agent_id is a *registration identity*, not an isolation filter. Storage scopes by the triple (tenant, user, session); the triple is mandatory on every call. Agent-bound tokens carry the agent_id on the Token value (so the persistence-layer composite key is (tenant, scope, subject_id, source)) but never substitute agent_id for an isolation-tuple element. The session triple still gates which agents the call site can see and address.
Persistence: ride the §4.4 StateStore seam (D-067 pattern) ¶
TokenStore is a typed wrapper around the existing state.StateStore (Phase 07) — the same shape Phase 50's pause/resume coordinator and Phase 53a's Agent Registry use (D-067, D-068). Driver pluralism (in- memory / SQLite / Postgres) lives at the StateStore layer; the TokenStore is a single concrete type that consumes whatever StateStore the binary opened at boot. This avoids the §13 two-parallel-implementations smell ("a token-store driver registry AND a state-store driver registry, both saying 'three V1 drivers'") and inherits the §4.3 deviation language Phase 50 / Phase 53a set as precedent. See phase-30-tool-oauth.md §"Findings I'm departing from".
Encryption at rest ¶
Token plaintext NEVER hits the StateStore unless wrapped through the package-local AES-256-GCM envelope. The KEK is operator-supplied via config (32 raw bytes hex-encoded); a missing / wrong-length KEK fails the boot loud (CLAUDE.md §13 amendment — operator-facing seam must demand explicit configuration; PR #91 / D-082). The encryption envelope carries a fresh 12-byte nonce per-Save and a 4-byte version header so KEK rotation (post-V1) can decrypt legacy records before re-encrypting under the new key.
Audit redaction ¶
Token plaintext never appears in events. The ErrAuthRequired payload is SafePayload by construction (provider name + authorize URL + scopes + binding scope + opaque state token — all caller-controllable surface, no token bytes). Every token-bearing audit emission flows through audit.Redactor; the payload type embeds events.SafeSealed so the bus accepts it under the typed path. Refresh tokens encrypt independently of access tokens — a compromised cache of access tokens does not yield refresh capability.
Concurrent reuse (D-025) ¶
Every constructed artifact in this package — TokenStore, the concrete *Provider, the AESGCMSealer — is safe to share across N concurrent goroutines. Mutable per-call state lives in ctx and arguments; per-source single-flight refresh is documented and guarded by a small map of *singleflight.Group keyed on (tenant, subject, source).
§13 primitive-with-consumer ¶
Phase 30 ships the primitive (OAuthProvider + TokenStore + ErrAuthRequired + the tool.auth_required event). The §13 primitive-with-consumer obligation is discharged in-PR by:
- integration test `test/integration/phase30_tool_oauth_test.go` exercising the full pause/resume cycle against a real Phase 50 Coordinator + a real events.EventBus + a real audit.Redactor + an httptest-backed authorization server doing PKCE + RFC 7591 dynamic client registration + metadata discovery, for BOTH binding scopes, plus the A2A AUTH_REQUIRED convergence assertion.
- the package-local concurrent_test.go pinning D-025 (N≥100 concurrent operations against one shared *Provider).
Phase 31 (tool-side approval gates) will be the next consumer layered on the same primitives.
Index ¶
- Constants
- Variables
- func IsCipherCorrupt(err error) bool
- func IsValidBindingScope(s BindingScope) bool
- func MustRegister(name string, factory Factory)
- func Register(name string, factory Factory) error
- func RegisteredDrivers() []string
- type BindingScope
- type ErrAuthRequired
- type Factory
- type FactoryDeps
- type FlowInitiation
- type OAuthConfig
- type OAuthProvider
- type Provider
- func (p *Provider) Close(_ context.Context) error
- func (p *Provider) CompleteFlow(ctx context.Context, state, code string) (Token, error)
- func (p *Provider) ConfigFor(source tools.ToolSourceID) (OAuthConfig, bool)
- func (p *Provider) InitiateFlow(ctx context.Context, source tools.ToolSourceID) (FlowInitiation, error)
- func (p *Provider) PendingFlow(state string) bool
- func (p *Provider) Revoke(ctx context.Context, source tools.ToolSourceID) error
- func (p *Provider) Token(ctx context.Context, source tools.ToolSourceID) (Token, error)
- type ProviderConfig
- type ProviderDeps
- type Sealer
- type Token
- type TokenStore
- type ToolAuthCompletedPayload
- type ToolAuthRequiredPayload
Constants ¶
const ( // EventTypeToolAuthRequired — emitted when a tool invocation // requires OAuth (no usable token; refresh failed; A2A reported // AUTH_REQUIRED). Payload is ToolAuthRequiredPayload. EventTypeToolAuthRequired events.EventType = "tool.auth_required" // EventTypeToolAuthCompleted — emitted by CompleteFlow on // successful token exchange. Payload is ToolAuthCompletedPayload. EventTypeToolAuthCompleted events.EventType = "tool.auth_completed" )
Canonical tool-auth event types. Registered from this package's init() so a Publish never trips events.ErrUnknownEventType.
`tool.auth_required` and `tool.auth_completed` are the two events Phase 30 emits onto the bus. Both carry caller-controllable surface only (URLs, scopes, source identifiers) — NEVER access / refresh token bytes. Both payload types embed events.SafeSealed so the bus accepts them under the typed path and the redactor is not run on a payload that already contains zero secret-shaped data.
const EnvelopeVersion uint32 = 1
EnvelopeVersion is the on-disk version tag stamped on every encrypted token blob. Bumped only when the envelope format changes in a backwards-incompatible way; v1 = "[4-byte BE version][12-byte nonce][ciphertext+GCM tag]".
const KEKSizeBytes = 32
KEKSizeBytes is the required length of the key-encryption key (AES-256). A wrong-length KEK fails the boot loud per CLAUDE.md §13 amendment (PR #91 / D-082).
const PKCEVerifierLen = 48
PKCEVerifierLen is the byte length of the random verifier (URL-safe base64 expands to 4*ceil(N/3) chars; 48 raw bytes → 64 base64url chars without padding).
Variables ¶
var ( // ErrAuthRequiredSentinel — comparison target for // errors.Is(err, auth.ErrAuthRequiredSentinel). The typed // *ErrAuthRequired's Is() method matches this sentinel. ErrAuthRequiredSentinel = errors.New("auth: authentication required") // ErrIdentityRequired — a Provider / TokenStore method was called // with a context whose identity triple is missing or incomplete. // Fails closed (CLAUDE.md §6 rule 9). ErrIdentityRequired = errors.New("auth: identity triple incomplete") // ErrInvalidBindingScope — OAuthConfig.BindingScope is not one // of the two canonical values. ErrInvalidBindingScope = errors.New("auth: invalid binding scope") // ErrAgentIDRequired — OAuthConfig.BindingScope == ScopeAgent but // AgentID is empty. ErrAgentIDRequired = errors.New("auth: agent_id required for ScopeAgent binding") // ErrConfigInvalid — the OAuthConfig fails structural validation // (missing Source, missing RedirectURI, etc.). ErrConfigInvalid = errors.New("auth: oauth config invalid") // ErrKEKMissing — the configured TokenStore was given an empty // or wrong-length KEK. Fail-loud per CLAUDE.md §13 amendment. ErrKEKMissing = errors.New("auth: encryption KEK missing or invalid length") // ErrTokenNotFound — TokenStore.Get returned no record for // (identity, scope, source). ErrTokenNotFound = errors.New("auth: token not found") // ErrTokenCipherCorrupt — a stored token blob failed AES-GCM // authentication on decrypt. Surfaces tampering or wrong-KEK // loudly rather than returning a half-decoded record. ErrTokenCipherCorrupt = errors.New("auth: stored token cipher corrupt") // ErrFlowNotFound — CompleteFlow called with a State that has no // initiating record (or was already completed). ErrFlowNotFound = errors.New("auth: oauth flow not found for state") // ErrFlowExpired — CompleteFlow called after the initiating // record's ExpiresAt. The pause record is also cleaned up. ErrFlowExpired = errors.New("auth: oauth flow expired") // ErrStateMismatch — CompleteFlow called with a State that // belongs to a different identity / source than the resuming ctx. ErrStateMismatch = errors.New("auth: oauth flow state mismatch") // ErrExchangeFailed — the authorization server rejected the // token exchange (HTTP 4xx / non-OAuth error body). ErrExchangeFailed = errors.New("auth: token exchange failed") // ErrDiscoveryFailed — metadata discovery against // .well-known/oauth-authorization-server failed. ErrDiscoveryFailed = errors.New("auth: oauth metadata discovery failed") // ErrRegistrationFailed — RFC 7591 dynamic client registration // failed. ErrRegistrationFailed = errors.New("auth: dynamic client registration failed") // ErrProviderClosed — any operation called after Close. ErrProviderClosed = errors.New("auth: provider closed") // ErrAdminScopeRequired — a ScopeAgent flow was initiated / // completed / revoked without the admin scope claim. Phase 30 // uses the existing registry.HasControlScope (Phase 53a) as the // in-process admin discriminator until Phase 61 wires JWT-side // scope claims; the call site flips the bit deliberately. ErrAdminScopeRequired = errors.New("auth: admin scope required for ScopeAgent flow") )
Sentinel errors. Callers compare via errors.Is.
var ( // ErrDriverUnknown — Resolve was called with a name no driver has // registered for. The error message lists the registered driver // names so the operator sees the typo. ErrDriverUnknown = errors.New("auth: oauth provider driver not registered") // ErrDriverEmptyName — Register was called with an empty driver // name. Build-time configuration bug. ErrDriverEmptyName = errors.New("auth: oauth provider driver registration: empty name") // ErrDriverNilFactory — Register was called with a nil Factory. // Build-time configuration bug. ErrDriverNilFactory = errors.New("auth: oauth provider driver registration: nil factory") // ErrDriverDuplicate — Register was called twice for the same // driver name. Build-time configuration bug (typically a // double-blank-import). ErrDriverDuplicate = errors.New("auth: oauth provider driver registration: duplicate name") )
Sentinel errors specific to the registry. Driver-internal errors continue to use the package's existing sentinels (`ErrConfigInvalid`, `ErrKEKMissing`, etc.).
Functions ¶
func IsCipherCorrupt ¶
IsCipherCorrupt reports whether err wraps ErrTokenCipherCorrupt. Tiny convenience for callers comparing through errors.Is on the hot path.
func IsValidBindingScope ¶
func IsValidBindingScope(s BindingScope) bool
IsValidBindingScope reports whether s is one of the canonical BindingScope values.
func MustRegister ¶
MustRegister wraps Register and panics on error. The typical driver-side idiom: `init() { auth.MustRegister("oauth2", New) }`. A duplicate-name panic at init signals a build bug (probably two drivers chose the same canonical name); the panic message names the offending driver so the operator's stack trace points at the fix.
func Register ¶
Register installs a Factory under name. Drivers self-register from their package init(); the binary entry point (`cmd/harbor/main.go`) blank-imports each driver to fire the registration.
Re-registering the same name returns `ErrDriverDuplicate` (the caller, an init() function, should panic on this — it signals a build mis-configuration). The function does NOT panic itself so the test suite can exercise the duplicate-name path without bringing the process down.
func RegisteredDrivers ¶
func RegisteredDrivers() []string
RegisteredDrivers returns a sorted list of registered driver names. Useful for boot-log output and `auth.Resolve` error messages.
Types ¶
type BindingScope ¶
type BindingScope string
BindingScope discriminates who an OAuth token belongs to. Configured per OAuth attachment (per MCP server / HTTP tool / A2A peer); the provider routes lookups accordingly. RFC §6.4 + brief 09.
const ( // ScopeUser — token belongs to a Harbor user. Lookups key by // (tenant, user, source). Each user authenticates individually. // The upstream service applies the user's ACLs. ScopeUser BindingScope = "user" // ScopeAgent — token belongs to a Harbor agent (admin-configured // service-account-style principal — Phase 53a / D-059's agent_id). // Lookups key by (tenant, agent_id, source). The admin // authenticates once during agent setup; every user invoking that // agent reuses the agent's token. The upstream sees the agent // doing the action; Harbor's audit captures // (originating user, agent-as-actor). ScopeAgent BindingScope = "agent" )
type ErrAuthRequired ¶
type ErrAuthRequired struct {
// Source is the ToolSourceID that needs auth.
Source tools.ToolSourceID
// SourceName is the human-readable name (echoed from
// OAuthConfig.SourceName); the Console renders this in the
// "Connect <SourceName>" prompt.
SourceName string
// BindingScope discriminates user-bound vs agent-bound — drives
// the Console UX target (user prompt vs admin banner).
BindingScope BindingScope
// AuthorizeURL is the URL to visit to complete OAuth.
AuthorizeURL string
// State is the CSRF token / flow-correlation key. NOT a secret
// (it's a one-time-use nonce); safe to surface in events for
// callback correlation.
State string
// Scopes is the scope list requested.
Scopes []string
// Message is human-readable advisory text. Never includes raw
// upstream-response bytes.
Message string
}
ErrAuthRequired is the typed sentinel returned by Provider.Token when no usable token exists for (identity, source). It is also emitted as the `tool.auth_required` event payload (audit-redacted before emit).
Field set is a SafePayload by construction: every field is caller-controllable / runtime-stamped; NEVER contains AccessToken / RefreshToken plaintext.
func (*ErrAuthRequired) Error ¶
func (e *ErrAuthRequired) Error() string
Error implements the error interface.
func (*ErrAuthRequired) Is ¶
func (e *ErrAuthRequired) Is(target error) bool
Is supports errors.Is comparisons against the sentinel ErrAuthRequiredSentinel.
type Factory ¶
type Factory func(cfg ProviderConfig, deps FactoryDeps) (OAuthProvider, error)
Factory builds an OAuthProvider from a ProviderConfig + FactoryDeps. Drivers self-register one Factory each via init() → Register.
A factory MUST fail closed on missing required deps (Store / Bus / Redactor / Coordinator); the `internal/tools/auth.NewProvider` constructor already enforces this for the `oauth2` driver, but custom drivers MUST honour the same contract.
type FactoryDeps ¶
type FactoryDeps struct {
// Store is the shared TokenStore (one per binary). Mandatory.
Store TokenStore
// Bus is the shared event bus. Mandatory.
Bus events.EventBus
// Redactor is the shared audit redactor. Mandatory.
Redactor audit.Redactor
// Coordinator is the shared unified pause/resume primitive.
// Mandatory.
Coordinator pauseresume.Coordinator
// HTTPClient is the HTTP client drivers use to talk to the
// authorization server. Optional — defaults to
// `&http.Client{Timeout: 30s}`.
HTTPClient *http.Client
// Clock is the wall-clock source. Optional — defaults to
// `time.Now`.
Clock func() time.Time
}
FactoryDeps bundles the shared collaborators every OAuth provider driver consumes. The dev stack constructs the TokenStore + Sealer ONCE (one KEK env var per binary; see `config.ToolsConfig.OAuthTokenKEKEnv`) and passes the same instances into every factory call — so N declared providers share one token store + sealer + bus + redactor + coordinator. This matches Phase 30's architecture where one `*Provider` consumes N `OAuthConfig` entries (the `oauth2` driver constructs one `*Provider` per registry entry; future per-vendor drivers may pool).
type FlowInitiation ¶
type FlowInitiation struct {
// AuthorizeURL is the URL the user / admin visits to grant
// access. Includes the PKCE code_challenge query parameter.
AuthorizeURL string
// State is the CSRF token. The provider persists
// (state → flow record) at InitiateFlow time and consults the
// map at CompleteFlow time.
State string
// PauseToken is the unified pause/resume primitive's opaque Token
// for the run that called InitiateFlow. The runtime uses this on
// CompleteFlow to resume the parked run. Set when the provider
// was constructed with a pause coordinator; empty when called
// out-of-band (admin setup flow with no live run).
PauseToken string
// ExpiresAt is when the flow record (and the corresponding pause
// record) expires. State arriving after this time is rejected
// with ErrFlowExpired.
ExpiresAt time.Time
// BindingScope echoes the OAuthConfig.BindingScope for caller
// convenience.
BindingScope BindingScope
// Source echoes the OAuthConfig.Source.
Source tools.ToolSourceID
}
FlowInitiation is what InitiateFlow returns: the AuthorizeURL the caller hands to the user / admin to complete OAuth out-of-band, plus the State token the callback handler quotes back to CompleteFlow. State doubles as the pause-record correlation key — see brief 09 "State-as-resume-key idea."
type OAuthConfig ¶
type OAuthConfig struct {
// Source is the ToolSourceID this attachment binds to. Required.
Source tools.ToolSourceID
// SourceName is the human-readable name surfaced in
// ErrAuthRequired payloads. Optional.
SourceName string
// BindingScope is ScopeUser or ScopeAgent. Required.
BindingScope BindingScope
// AgentID, when BindingScope == ScopeAgent, names the
// agent_id this attachment is bound to. Phase 53a / D-059's
// registration identity — runtime-instance-local; not part of the
// isolation tuple. Required when BindingScope == ScopeAgent;
// ignored otherwise.
AgentID string
// ClientID is the OAuth client_id. Optional: when empty, RFC 7591
// dynamic client registration is attempted via RegistrationURL or
// discovered via ServerURL/.well-known/oauth-authorization-server.
ClientID string
// ClientSecret is the OAuth client_secret. Optional: PKCE-only
// public clients leave it empty. NEVER logged; redacted on every
// audit emission.
ClientSecret string
// AuthorizeURL is the authorization endpoint. Optional: when
// empty, discovered from ServerURL.
AuthorizeURL string
// TokenURL is the token endpoint. Optional: when empty, discovered
// from ServerURL.
TokenURL string
// RegistrationURL is the RFC 7591 dynamic-client-registration
// endpoint. Optional: when set, the provider attempts dynamic
// registration on first use when ClientID is empty.
RegistrationURL string
// ServerURL is the authorization server base URL for OAuth
// metadata discovery (.well-known/oauth-authorization-server).
// Required when AuthorizeURL / TokenURL / RegistrationURL are
// empty and dynamic resolution is needed; optional otherwise.
ServerURL string
// RedirectURI is the redirect_uri the Harbor Protocol callback
// handler exposes. Required.
RedirectURI string
// Scopes is the requested OAuth scopes list.
Scopes []string
}
OAuthConfig is the per-source OAuth attachment. The caller stores one OAuthConfig per (Source, BindingScope) tuple — the same source may have BOTH a ScopeUser AND a ScopeAgent attachment (rare but legal — e.g. an agent's shared mailbox AND a user's personal inbox); brief 09 §"Mixed-scope coexistence test" makes this a requirement.
func (OAuthConfig) SubjectID ¶
func (c OAuthConfig) SubjectID(id identity.Identity) string
SubjectID extracts the principal-side composite-key component the store uses to scope lookups: UserID for ScopeUser, AgentID for ScopeAgent. Returns the empty string when the binding scope demands the missing field.
func (OAuthConfig) Validate ¶
func (c OAuthConfig) Validate() error
Validate reports whether the OAuthConfig is structurally valid. Returns wrapped sentinels on failure.
type OAuthProvider ¶
type OAuthProvider interface {
// Token returns a fresh access token for (ctx identity, source).
// The token's ownership is determined by the source's
// OAuthConfig.BindingScope:
// - ScopeUser: lookup keyed by (tenant, user_id, source)
// - ScopeAgent: lookup keyed by (tenant, agent_id, source)
// When no token exists or refresh fails irrecoverably, returns a
// typed *ErrAuthRequired (which the runtime catches to emit
// `tool.auth_required` and park the run via Phase 50). When the
// stored token is expired, the provider attempts a single
// in-flight refresh per (subject, source) before falling through
// to *ErrAuthRequired.
Token(ctx context.Context, source tools.ToolSourceID) (Token, error)
// InitiateFlow begins an authorization-code flow. Returns the
// AuthorizeURL the caller hands to the user / admin and the
// State value the callback handler will quote back. For
// ScopeAgent sources, the calling ctx MUST carry the admin scope
// (via registry.WithControlScope) — fails ErrAdminScopeRequired
// otherwise.
//
// When a pause coordinator was wired into the provider at
// construction (the production path) AND the calling ctx carries
// the pause request payload, InitiateFlow allocates a pause
// record via the unified pause/resume primitive (Phase 50) and
// returns its opaque PauseToken on FlowInitiation.PauseToken.
InitiateFlow(ctx context.Context, source tools.ToolSourceID) (FlowInitiation, error)
// CompleteFlow exchanges (state, code) for tokens, persists them
// via TokenStore, and (when a pause was allocated by
// InitiateFlow) resumes the parked run via the coordinator.
// Returns the persisted Token (caller almost never needs it but
// it is returned for confirmation / testing).
CompleteFlow(ctx context.Context, state, code string) (Token, error)
// Revoke removes the token for (ctx identity, source). For
// ScopeAgent sources, ctx MUST carry the admin scope. Idempotent.
Revoke(ctx context.Context, source tools.ToolSourceID) error
// Close releases provider resources (in-flight singleflights,
// HTTP client connections, cached metadata). Idempotent.
Close(ctx context.Context) error
}
OAuthProvider is the canonical contract for tool-side OAuth. It is transport-agnostic: Phase 27 (HTTP) / Phase 28 (MCP) / Phase 29 (A2A) drivers all call the same Token method. The provider routes between user-bound and agent-bound lookups based on the source's OAuthConfig.BindingScope.
Identity is mandatory on every method; a missing triple fails closed (ErrIdentityRequired).
func Resolve ¶
func Resolve(ctx context.Context, driver string, cfg ProviderConfig, deps FactoryDeps) (OAuthProvider, error)
Resolve constructs the OAuthProvider for cfg by dispatching to the driver named in `driver`. Returns wrapped `ErrDriverUnknown` when the driver has not registered; otherwise delegates to the driver's Factory (whose own validation surfaces fail-closed errors).
`ctx` is honoured for the construction itself; drivers should observe `ctx.Err()` between long phases of work (e.g. HTTP discovery probes).
type Provider ¶
type Provider struct {
// contains filtered or unexported fields
}
Provider is the V1 concrete OAuthProvider implementation.
Concurrent reuse (D-025): every field below is set once at construction (deps + immutable maps protected by mu).
func NewProvider ¶
func NewProvider(configs []OAuthConfig, deps ProviderDeps) (*Provider, error)
NewProvider constructs a Provider from configs + deps.
configs is the operator-supplied set of OAuthConfigs (one per (Source, BindingScope) tuple). Each must Validate; a malformed config fails NewProvider loud rather than degrading silently.
deps's Store / Bus / Redactor / Coordinator are mandatory. A nil dep is rejected at construction (fail-loud per CLAUDE.md §13 amendment).
func (*Provider) CompleteFlow ¶
CompleteFlow handles the callback. Exchanges (state, code) for tokens; persists via TokenStore; resumes the parked run via the coordinator; emits tool.auth_completed.
func (*Provider) ConfigFor ¶
func (p *Provider) ConfigFor(source tools.ToolSourceID) (OAuthConfig, bool)
ConfigFor returns a copy of the OAuthConfig for source, or false when no attachment is configured. Useful for transport drivers that need to inspect the binding scope before invoking Token (e.g. to decide whether to include an `Authorization` header at all).
func (*Provider) InitiateFlow ¶
func (p *Provider) InitiateFlow(ctx context.Context, source tools.ToolSourceID) (FlowInitiation, error)
InitiateFlow allocates a fresh flow record + pause-record out-of-band of a Token() call. Used by admin setup flows: the admin clicks "Connect <SourceName>" in the Console; the Console calls InitiateFlow; the admin completes OAuth; CompleteFlow reattaches the token. ScopeAgent flows require admin scope on ctx (registry.WithControlScope) — fails ErrAdminScopeRequired otherwise.
func (*Provider) PendingFlow ¶
PendingFlow reports whether `state` corresponds to an in-flight flow record (useful for the callback handler to short-circuit validation before the token-exchange round-trip).
type ProviderConfig ¶
type ProviderConfig struct {
// Name is the operator-facing provider name. Surfaced in error
// messages.
Name string
// ClientID is the resolved OAuth client_id (read from the env
// var named in `config.ToolOAuthProviderConfig.ClientIDEnv`).
// Required — drivers fail closed when empty.
ClientID string
// ClientSecret is the resolved OAuth client_secret (read from
// the env var named in
// `config.ToolOAuthProviderConfig.ClientSecretEnv`). Required —
// drivers fail closed when empty. NEVER logged.
ClientSecret string
// Scopes is the requested OAuth scope list.
Scopes []string
// AuthURL is the authorization endpoint.
AuthURL string
// TokenURL is the token endpoint.
TokenURL string
// RedirectURL is the redirect_uri the Harbor Protocol callback
// handler exposes.
RedirectURL string
// Extra is the driver-specific extras map. Reserved for future
// drivers' per-flow knobs; unused by the V1 `oauth2` driver.
Extra map[string]string
}
ProviderConfig is the boundary type the registry exposes to drivers. It mirrors `config.ToolOAuthProviderConfig` (the operator-facing YAML shape) but lives in `internal/tools/auth` so concrete drivers can depend on it without forcing the `internal/config` package to import driver internals. The dev stack maps the YAML struct onto this struct at the boundary.
Credentials enter via env-var indirection (§7 rule 2): `ClientID` / `ClientSecret` are the **already-resolved** secret values (the dev-stack reads `os.Getenv(cfg.ClientIDEnv)` etc. before calling the factory). A driver that finds them empty fails closed.
type ProviderDeps ¶
type ProviderDeps struct {
// Store is the TokenStore the provider reads / writes tokens
// through. Mandatory.
Store TokenStore
// Bus is the event bus the provider emits tool.auth_required /
// tool.auth_completed events on. Mandatory.
Bus events.EventBus
// Redactor processes the ToolAuthRequiredPayload before emission.
// Mandatory.
Redactor audit.Redactor
// Coordinator is the unified pause/resume primitive (Phase 50).
// Mandatory — InitiateFlow allocates a pause record on it;
// CompleteFlow resumes through it.
Coordinator pauseresume.Coordinator
// HTTPClient is the client the provider uses to talk to the
// authorization server (discovery / dynamic registration / token
// exchange). Optional — defaults to http.DefaultClient with a
// 30s timeout shim.
HTTPClient *http.Client
// Clock is the wall-clock source. Optional — defaults to
// time.Now.
Clock func() time.Time
// FlowTTL is how long an initiated flow remains
// CompleteFlow-able. Optional — defaults to 10 minutes.
FlowTTL time.Duration
}
ProviderDeps bundles the collaborators a Provider needs. The production binary wires all four; tests may stub the bus / pauser / redactor with in-memory equivalents that satisfy the same interface.
type Sealer ¶
type Sealer interface {
Seal(plaintext []byte) ([]byte, error)
Open(ciphertext []byte) ([]byte, error)
}
Sealer encrypts / decrypts token plaintext for at-rest storage. The envelope is "[4-byte BE version][12-byte fresh nonce][AES-GCM ciphertext+tag]" so KEK rotation (post-V1) can decrypt legacy records before re-encrypting under the new key.
Concurrent reuse (D-025): The constructed Sealer is safe for N concurrent goroutines — cipher.AEAD's Seal / Open are concurrency-safe per crypto/cipher's documented contract, and the Sealer holds only the immutable AEAD reference after construction.
func NewAESGCMSealer ¶
NewAESGCMSealer constructs a Sealer over AES-256-GCM keyed on kek. Returns wrapped ErrKEKMissing on wrong-length kek.
type Token ¶
type Token struct {
// Source is the ToolSourceID this token authorises.
Source tools.ToolSourceID
// BindingScope is ScopeUser or ScopeAgent — matches the
// originating OAuthConfig.BindingScope.
BindingScope BindingScope
// TenantID is always set.
TenantID string
// UserID is set when BindingScope == ScopeUser; empty otherwise.
UserID string
// AgentID is set when BindingScope == ScopeAgent; empty otherwise.
// Phase 53a registration identity — never substituted for an
// isolation-tuple element.
AgentID string
// AccessToken — bearer credential. NEVER logged, NEVER emitted on
// the bus, NEVER persisted in plaintext (encryption-at-rest is
// enforced in the store layer).
AccessToken string
// RefreshToken — refresh credential. NEVER logged. Encrypted
// independently of AccessToken so a compromised access cache does
// not yield refresh capability.
RefreshToken string
// TokenType is conventionally "Bearer".
TokenType string
// ExpiresAt is the wall-clock expiry of AccessToken. Zero means
// "no expiry advertised" — the provider treats this as
// long-lived but still validates via 401 retry.
ExpiresAt time.Time
// Scopes is the scope list granted by the authorization server.
// May be a subset of the requested scopes.
Scopes []string
// LastRefreshedAt is the wall-clock time of the most recent
// successful refresh; zero on first issuance.
LastRefreshedAt time.Time
}
Token is what TokenStore persists. The OAuthProvider is the only component that ever sees AccessToken / RefreshToken in plaintext — callers receive a copy of this struct only through Provider.Token (which is itself called only by trusted tool drivers immediately before composing the upstream request).
type TokenStore ¶
type TokenStore interface {
// Get returns the Token matching (ctx identity, scope, subject,
// source). Returns (Token{}, false, nil) on miss;
// (Token{}, false, err) on store failure or cipher corruption.
// The store decrypts the stored ciphertext before returning;
// callers see plaintext.
Get(ctx context.Context, scope BindingScope, subjectID string, source tools.ToolSourceID) (Token, bool, error)
// Put encrypts t.AccessToken / t.RefreshToken and persists. The
// composite key is (ctx identity, t.BindingScope, t.SubjectID(),
// t.Source). When a token already exists for that key, it is
// overwritten (the token model is upsert, not append).
Put(ctx context.Context, t Token) error
// Delete removes the token at (ctx identity, scope, subject,
// source). Idempotent — no error when the record does not exist.
Delete(ctx context.Context, scope BindingScope, subjectID string, source tools.ToolSourceID) error
}
TokenStore persists access + refresh tokens with encryption at rest. Identity is mandatory: a missing triple fails closed (ErrIdentityRequired). Storage scopes by (tenant, user, session); the composite-key suffix includes (BindingScope, SubjectID, Source) — never substitutes agent_id for an isolation-tuple element.
func NewTokenStore ¶
func NewTokenStore(store state.StateStore, sealer Sealer) (TokenStore, error)
NewTokenStore constructs a TokenStore over the given StateStore + Sealer. Both are mandatory — a nil store / nil sealer is rejected at construction (fail-loud per CLAUDE.md §13 amendment).
type ToolAuthCompletedPayload ¶
type ToolAuthCompletedPayload struct {
events.SafeSealed
// Source is the ToolSourceID for which auth completed.
Source string
// BindingScope echoes the originating attachment.
BindingScope string
// State is the CSRF / flow-correlation key used by callers
// observing the matching `tool.auth_required` event.
State string
// PauseToken is the unified pause/resume Coordinator's Token —
// observers can correlate this to the pause.resumed event.
PauseToken string
}
ToolAuthCompletedPayload is the typed payload for a `tool.auth_completed` event. SafePayload by construction — token bytes never appear.
type ToolAuthRequiredPayload ¶
type ToolAuthRequiredPayload struct {
events.SafeSealed
// Source is the ToolSourceID that needs auth.
Source string
// SourceName is the human-readable name (from
// OAuthConfig.SourceName); the Console renders this in the
// "Connect <SourceName>" prompt.
SourceName string
// BindingScope is "user" or "agent" — drives the Console UX
// target (per-user prompt vs admin-targeted banner).
BindingScope string
// AuthorizeURL is the URL to visit to complete OAuth.
AuthorizeURL string
// State is the CSRF / flow-correlation key. Not a secret; used
// by the callback handler to correlate the resume with the
// pause record.
State string
// PauseToken is the unified pause/resume Coordinator's Token —
// the runtime uses this to find the parked run on resume.
PauseToken string
// Scopes is the OAuth scopes requested.
Scopes []string
}
ToolAuthRequiredPayload is the typed payload for a `tool.auth_required` event. SafePayload by construction: every field is the runtime's own bookkeeping or operator-supplied configuration metadata; no token plaintext, no upstream-response bytes.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package conformancetest exposes the shared TokenStore + Sealer conformance suite Phase 30 ships.
|
Package conformancetest exposes the shared TokenStore + Sealer conformance suite Phase 30 ships. |
|
drivers
|
|
|
oauth2
Package oauth2 ships Harbor's V1 default OAuth provider driver (D-095, closes issue #116 and D-090's deferred construction gap).
|
Package oauth2 ships Harbor's V1 default OAuth provider driver (D-095, closes issue #116 and D-090's deferred construction gap). |