auth

package
v1.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 11, 2026 License: Apache-2.0 Imports: 29 Imported by: 0

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.

build_providers.go — the exported OAuth provider assembly (Phase 110d, D-197; absorbs cmd/harbor's applyToolCatalogWiring KEK-resolve → sealer → token store → provider-factory loop from Phase 64a/D-090 + D-095).

BuildProviders walks `cfg.OAuthProviders[]`, constructs the shared crypto chain ONCE (one operator-supplied KEK env var per binary → AES-256-GCM Sealer → StateStore-backed TokenStore), and dispatches each entry to the §4.4 driver registry by `Driver` name. Credentials enter via env-var indirection (CLAUDE.md §7 rule 2 — never hardcoded, never logged): `os.Getenv` resolves `ClientIDEnv` / `ClientSecretEnv` at this boundary, and the KEK is read from the env var named in `cfg.OAuthTokenKEKEnv` (32 hex bytes; the Sealer enforces length). Every failure is loud: empty / wrong-length KEK, missing env-var contents, unknown driver, and factory errors all crash assembly with a wrapped error naming the offending field (CLAUDE.md §13 amendment).

Index

Constants

View Source
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.

View Source
const CallbackPath = "/v1/tools/oauth/callback"

CallbackPath is the documented default mount path for the OAuth callback handler — the path `harbor dev` serves and the shape operators put in OAuthConfig.RedirectURI (`http://<bind>/v1/tools/oauth/callback`). Headless embedders may mount the handler at any path that matches their configured RedirectURI.

View Source
const CallbackRoutePattern = "GET " + CallbackPath

CallbackRoutePattern is the method-qualified http.ServeMux pattern for the default mount (`GET /v1/tools/oauth/callback`).

View Source
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]".

View Source
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).

View Source
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

View Source
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.

View Source
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 BuildProviders added in v1.3.0

func BuildProviders(ctx context.Context, cfg config.ToolsConfig, deps BuildDeps) (map[string]OAuthProvider, error)

BuildProviders constructs the OAuth provider map declared by `cfg.OAuthProviders`, keyed by provider Name. An empty declaration list returns an empty (non-nil) map and never touches the KEK env — the binary boots cleanly when no operator declares OAuth bindings.

func CallbackHandler added in v1.3.0

func CallbackHandler(providers map[string]OAuthProvider, opts ...CallbackOption) http.Handler

CallbackHandler returns the http.Handler that receives the OAuth provider redirect and completes the tool-OAuth flow: it parses (state, code, error) from the query, locates the owning provider across the supplied map via PendingFlow, rebuilds the completing identity from the provider's flow record, and calls CompleteFlow — which exchanges the code, persists the token, and resumes the parked pause record through the unified pause/resume primitive.

Typed error mapping (sentinel → HTTP status):

ErrFlowNotFound                                  → 404
ErrFlowExpired                                   → 410
ErrStateMismatch                                 → 400
ErrExchangeFailed / ErrDiscoveryFailed /
ErrRegistrationFailed (provider-upstream)        → 502
ErrProviderClosed                                → 503
missing state / missing code / upstream `error`  → 400
anything else                                    → 500

An upstream denial (`error=access_denied`, …) is surfaced loud: 400 with the audit-safe reason, AND the flow is consumed via DenyFlow so the pause resumes with DecisionReject instead of hanging to flow-TTL (D-199).

The handler is a plain http.Handler with no dependency on the Protocol server, the dev server, or cmd/harbor — `harbor dev` mounts it at CallbackRoutePattern; a headless embedder mounts it on any mux at the path matching its configured RedirectURI. The pause handle registry is process-local at V1 (RFC §6.3): the callback must land on the same process that parked the run, which is true by construction for `harbor dev` and single-process embedders.

Concurrent reuse (D-025): the handler is a compiled artifact — the provider map is copied at construction and read-only afterwards; per-request state lives on the request goroutine's stack.

func IsCipherCorrupt

func IsCipherCorrupt(err error) bool

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

func MustRegister(name string, factory Factory)

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

func Register(name string, factory Factory) error

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 BuildDeps added in v1.3.0

type BuildDeps struct {
	// State is the runtime's StateStore — the TokenStore persists
	// sealed tokens through it.
	State state.StateStore
	// Bus is the shared event bus (auth events).
	Bus events.EventBus
	// Redactor is the shared audit redactor.
	Redactor audit.Redactor
	// Coordinator is the unified pause/resume primitive — the ONE
	// pause path tool-side OAuth rides on (CLAUDE.md §7 rule 4).
	Coordinator pauseresume.Coordinator
}

BuildDeps bundles the shared runtime collaborators BuildProviders threads into every provider factory. All four fields are mandatory when at least one provider is declared; an empty `cfg.OAuthProviders` list short-circuits before any is read.

type CallbackOption added in v1.3.0

type CallbackOption func(*callbackConfig)

CallbackOption configures CallbackHandler at construction.

func WithCallbackLogger added in v1.3.0

func WithCallbackLogger(l *slog.Logger) CallbackOption

WithCallbackLogger sets the slog.Logger the handler logs through. Defaults to slog.Default(). Log lines carry identity attributes + the provider/source/binding-scope and the (non-secret) state nonce; never the authorization code or token bytes (§7).

func WithSuccessPage added in v1.3.0

func WithSuccessPage(html string) CallbackOption

WithSuccessPage overrides the static HTML success page. The page is served verbatim on every successful completion — callers MUST NOT template flow or token material into it.

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 callback endpoint exposes.
	// Required. Harbor ships the production callback handler:
	// `auth.CallbackHandler` (Phase 111b, D-199), mounted by
	// `harbor dev` at `GET /v1/tools/oauth/callback`
	// (auth.CallbackPath) — point RedirectURI at
	// `http://<bind>/v1/tools/oauth/callback` for the dev binary.
	// Headless embedders mount the same handler on their own mux at
	// whatever path matches this field (see
	// docs/recipes/steer-and-resume-a-run.md).
	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). The production
	// caller is `auth.CallbackHandler` (Phase 111b, D-199) — mounted
	// by `harbor dev` at `GET /v1/tools/oauth/callback` and
	// mountable on any mux by headless embedders.
	CompleteFlow(ctx context.Context, state, code string) (Token, error)

	// PendingFlow reports whether `state` corresponds to an in-flight
	// flow record, returning the record's read-only projection. The
	// callback handler uses it to locate the owning provider across a
	// provider map and to rebuild the completing ctx's identity from
	// the record (the single source of truth for the binding). The
	// lookup does NOT consume the record — completion / denial does.
	PendingFlow(state string) (PendingFlowInfo, bool)

	// DenyFlow consumes the in-flight flow record for `state` and
	// resumes the associated pause record with the typed
	// DecisionReject marker — the fail-loud terminal for an upstream
	// authorization denial (`error=access_denied` on the redirect).
	// The run does not hang to flow-TTL on a denial the provider
	// already made final (Phase 111b plan §Risks; recorded in D-199).
	// `reason` is the audit-safe denial reason (the OAuth `error`
	// code — never token or code material). Sentinels mirror
	// CompleteFlow: ErrFlowNotFound / ErrFlowExpired /
	// ErrStateMismatch / ErrAdminScopeRequired.
	DenyFlow(ctx context.Context, state, reason string) 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 PendingFlowInfo added in v1.3.0

type PendingFlowInfo struct {
	// Source is the ToolSourceID the flow was initiated for.
	Source tools.ToolSourceID
	// BindingScope echoes the originating OAuthConfig.BindingScope.
	BindingScope BindingScope
	// Identity is the (tenant, user, session) triple pinned at
	// initiation time. The callback handler quotes it back onto the
	// completing ctx so CompleteFlow's identity cross-check and the
	// Coordinator's resume-scope check see the parking identity.
	Identity identity.Identity
	// ExpiresAt is when the flow record expires (FlowTTL after
	// initiation). A callback arriving after this time is rejected
	// with ErrFlowExpired.
	ExpiresAt time.Time
}

PendingFlowInfo is the read-only projection of an in-flight authorization-code flow record that `OAuthProvider.PendingFlow` returns. The callback handler (`auth.CallbackHandler`, Phase 111b) consults it to locate the owning provider for a redirect's `state` and to rebuild the completing request's identity scope from the provider's own record — the flow record stays the single source of truth for the identity binding (brief 09: the handler adds zero identity logic of its own).

The PKCE verifier and the pause Token are deliberately NOT exposed: the verifier is flow-secret material, and the pause Token's only legitimate consumer is `CompleteFlow` / `DenyFlow` (resuming a pause without completing the flow is the "bare Resume re-parks immediately" trap — the run would just pause again on the still- missing token).

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) Close

func (p *Provider) Close(_ context.Context) error

Close releases provider resources. Idempotent.

func (*Provider) CompleteFlow

func (p *Provider) CompleteFlow(ctx context.Context, state, code string) (Token, error)

CompleteFlow handles the callback. Exchanges (state, code) for tokens; persists via TokenStore; resumes the parked run via the coordinator; emits tool.auth_completed.

CompleteFlow is the resume half of the tool-OAuth pause. Its production caller is `auth.CallbackHandler` (Phase 111b, D-199), which `harbor dev` mounts at `GET /v1/tools/oauth/callback`; headless embedders mount the same handler (or call CompleteFlow directly) at whatever path matches the configured RedirectURI. A bare Coordinator.Resume without CompleteFlow re-parks the run immediately (the token is still missing), so this method is the ONLY correct completion path.

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) DenyFlow added in v1.3.0

func (p *Provider) DenyFlow(ctx context.Context, state, reason string) error

DenyFlow consumes the flow record for `state` and resumes the associated pause with the typed DecisionReject marker (D-096) — the fail-loud terminal for an upstream authorization denial. The token store is never touched (there is no token); the pause record does not linger to flow-TTL on a denial the authorization server already made final (Phase 111b plan §Risks; recorded in D-199).

Identity + admin-scope checks mirror CompleteFlow: the calling ctx must carry the flow's parking identity, and a ScopeAgent flow requires the control scope.

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

func (p *Provider) PendingFlow(state string) (PendingFlowInfo, bool)

PendingFlow reports whether `state` corresponds to an in-flight flow record, returning the record's read-only PendingFlowInfo projection. The callback handler (`auth.CallbackHandler`) uses it to locate the owning provider and to rebuild the completing ctx's identity from the record. The lookup does NOT consume the record.

func (*Provider) Revoke

func (p *Provider) Revoke(ctx context.Context, source tools.ToolSourceID) error

Revoke removes the token for (ctx identity, source). For ScopeAgent sources, ctx MUST carry the admin scope.

func (*Provider) Token

func (p *Provider) Token(ctx context.Context, source tools.ToolSourceID) (Token, error)

Token implements OAuthProvider.Token.

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

func NewAESGCMSealer(kek []byte) (Sealer, error)

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).

func (Token) SubjectID

func (t Token) SubjectID() string

SubjectID returns the principal-side half of the persistence composite key: UserID for ScopeUser, AgentID for ScopeAgent. Used by TokenStore to construct the StateStore Kind suffix.

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.

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).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL