auth

package
v1.17.0 Latest Latest
Warning

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

Go to latest
Published: Jul 21, 2026 License: Apache-2.0 Imports: 33 Imported by: 0

Documentation

Overview

Package auth ships Harbor's tool-side OAuth subsystem — the TokenStore + OAuthProvider seam every HTTP / MCP / 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 (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:

  • 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 — / the `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: 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 (pattern)

TokenStore is a typed wrapper around the existing state.StateStore the same shape the pause/resume coordinator and the Agent Registry use. 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 set as precedent. 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). 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

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

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

The tool-side approval gates are the sibling consumer layered on the same primitives.

build_providers.go — the exported OAuth provider assembly. Absorbs cmd/harbor's applyToolCatalogWiring KEK-resolve → sealer → token store → provider-factory loop so cmd and devstack share one implementation.

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. Each entry's OWN client credential resolves through the §4.4 credential-source seam (`internal/tools/auth/credsource`): `env` (the default — resolved at boot from `ClientIDEnv` / `ClientSecretEnv`, fail-loud when unset) or `remote` (an authenticated pull from a coordinator endpoint at first need). BuildProviders constructs the source, calls its boot-time `ValidateAtBoot`, and threads it onto `ProviderConfig.CredentialSource`; the driver resolves the credential at its need-point (CLAUDE.md §7 rule 2 — never hardcoded, never logged). 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, an unset env credential, a malformed remote block, unknown driver / source, and factory errors all crash assembly with a wrapped error naming the offending field (CLAUDE.md §13 amendment).

providers.go — the owner-tagged OAuth provider SET.

A ProviderSet HOLDS constructed provider INSTANCES (not driver factories — that is registry.go). It is the runtime home the MCP attach path resolves a connection's `oauth_provider` binding against, and the home a Protocol-installed provider descriptor lands its constructed provider into.

Not the driver registry, not a §4.4 seam

registry.go is the OAuth flow-strategy DRIVER registry (interface + drivers/<name>/ + Resolve factory). This file is a different concern: it holds INSTANCES keyed by bare name. It is deliberately NOT a §4.4 driver seam — it has no drivers/ subdirectory and no internal/drivers/prod registration, because a set of instances has nothing to dispatch. Naming it for what it holds (a provider set) keeps a call site from confusing auth.Register (a driver) with ProviderSet.Install (an instance).

Process-global bare-name resolution + owner-tagged reconcile

Resolution is by BARE NAME across every session (the tool catalog + MCP registry stay process-global and deployment-shared; the set mirrors that model for provider instances). Runtime-installed entries additionally carry an OWNER tag (tenant, agent) used for EXACTLY one thing beyond nothing: scoping the run-start reconcile VIEW so a run only ever uninstalls ITS OWN owner's installed providers — never a boot-seeded provider, never another owner's install. The owner is NOT an isolation principal and never a dispatch or storage key (agent_id is not part of the isolation tuple).

Concurrent reuse

A ProviderSet is a compiled artifact: safe to share across N goroutines. The only mutable state is the entry map, guarded by an RWMutex and documented internally-synchronised. Uninstall removes the entry under the write lock and closes the provider AFTER releasing it, so a Get racing an Uninstall returns the live provider or `false` — never a half-closed handle observed through the map.

Index

Constants

View Source
const (
	// StepProtectedResource is the RFC 9728 protected-resource-metadata hop.
	StepProtectedResource discoveryStep = "protected_resource"
	// StepAuthorizationServer is the RFC 8414 / OIDC authorization-server-
	// metadata hop.
	StepAuthorizationServer discoveryStep = "authorization_server"
)
View Source
const (
	// ReasonCrossOriginNotAllowed — a metadata URL on a different origin than
	// the declared server, without an explicit per-connection allowance.
	ReasonCrossOriginNotAllowed = "cross_origin_not_allowed"
	// ReasonNeedsAllowance — the authorization-server hop, which always needs
	// an explicit per-connection origin allowance, had none for the AS origin.
	ReasonNeedsAllowance = "needs_allowance"
	// ReasonPrivateOrIPLiteral — an allowed cross-origin target resolved to a
	// private-range / loopback / link-local address or was an IP literal.
	ReasonPrivateOrIPLiteral = "private_or_ip_literal"
	// ReasonNotHTTPS — a non-loopback target on a scheme other than https.
	ReasonNotHTTPS = "not_https"
	// ReasonInvalidURL — the metadata URL did not parse / had no host.
	ReasonInvalidURL = "invalid_url"
	// ReasonFetchFailed — the HTTP fetch failed (transport error / non-2xx /
	// redirect bound exceeded / size cap exceeded / timeout).
	ReasonFetchFailed = "fetch_failed"
	// ReasonParseFailed — the fetched document did not decode as the expected
	// metadata shape.
	ReasonParseFailed = "parse_failed"
	// ReasonMetadataAbsent — the document parsed but named no
	// authorization_servers (the 9728 hop) — nothing to walk.
	ReasonMetadataAbsent = "metadata_absent"
	// ReasonTooManyAuthServers — the 9728 doc advertised more authorization
	// servers than the aggregate walk budget allows; the excess is truncated.
	ReasonTooManyAuthServers = "too_many_authorization_servers"
)

Stable, machine-readable reason codes carried on a failed DiscoveryStepStatus. They are report-only strings — never surfaced as an error the runtime acts on.

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"

	// EventTypeToolCredentialExchanged — emitted once per ACTUAL
	// downstream-credential exchange against an external credential
	// broker (the pull-based, non-interactive acquisition strategy;
	// cache hits emit nothing). Payload is
	// ToolCredentialExchangedPayload. SafePayload by construction: it
	// carries the source, binding scope, subject kind, broker host,
	// granted scopes, and expiry — NEVER access / refresh token bytes.
	// The emission satisfies §7's "external credential provisioning
	// requires explicit configuration AND emits audit events".
	EventTypeToolCredentialExchanged events.EventType = "tool.credential_exchanged" //nolint:gosec // G101 false positive: this is a canonical event-type name, not a credential
)

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

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

View Source
const TokenExchangeDriverName = "tokenexchange"

TokenExchangeDriverName is the driver name a Protocol-installed provider descriptor MUST declare (the only writable driver — the non-interactive PULL exchange). Mirrors the tokenexchange driver's self-registration name without importing the driver package here.

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. Harbor
	// uses the existing registry.HasControlScope as the
	// in-process admin discriminator until a later phase wires JWT-side
	// scope claims; the call site flips the bit deliberately.
	ErrAdminScopeRequired = errors.New("auth: admin scope required for ScopeAgent flow")

	// ErrNonInteractive — an authorization-code-flow method
	// (InitiateFlow / CompleteFlow / DenyFlow) was called on a
	// non-interactive acquisition driver (one that pulls credentials
	// from an external authority rather than driving a user through an
	// authorization-code redirect). The typed sentinel is returned
	// rather than a silent no-op (CLAUDE.md §13 forbids silent
	// degradation): a caller that assumes every OAuthProvider can
	// InitiateFlow branches on errors.Is(err, auth.ErrNonInteractive)
	// and skips the interactive prompt. The pull-based drivers surface
	// a broker consent refusal through the SAME typed *ErrAuthRequired
	// the interactive flow uses, so the run still parks on the unified
	// pause/resume primitive — only the completion mechanism differs
	// (a resume re-drives Token() rather than a callback CompleteFlow).
	ErrNonInteractive = errors.New("auth: provider is non-interactive (no authorization-code flow)")
)

Sentinel errors. Callers compare via errors.Is.

View Source
var (
	// ErrUnknownBroker — a descriptor's credential_broker resolves to no
	// boot-declared broker. Loud, listing the declared broker names.
	ErrUnknownBroker = errors.New("auth: credential_broker resolves to no boot-declared broker")
	// ErrBrokerMissingCredentialURL — the resolved broker declares no
	// credential_url, so an installed broker-pull provider has no boot-pinned
	// endpoint to PULL its org client credential from. Fail loud (never a silent
	// wire-supplied fallback).
	ErrBrokerMissingCredentialURL = errors.New("auth: credential_broker declares no credential_url (a Protocol-installed broker-pull provider pulls its org client credential from the boot-pinned credential_url)")
)

ProviderBuilder sentinel errors.

View Source
var (
	// ErrProviderNameEmpty — Install was called with an empty name.
	ErrProviderNameEmpty = errors.New("auth: provider set install: name is empty")
	// ErrProviderNilInstance — Install was called with a nil provider.
	ErrProviderNilInstance = errors.New("auth: provider set install: provider instance is nil")
	// ErrProviderOwnerMissing — Install was called with a zero owner (a
	// runtime-installed entry must carry its (tenant, agent) reconcile owner).
	ErrProviderOwnerMissing = errors.New("auth: provider set install: owner is zero (a runtime-installed provider must carry its (tenant, agent) owner)")
	// ErrProviderBootCollision — Install named a boot-seeded provider. Boot
	// wins; a runtime install never shadows a boot-declared provider.
	ErrProviderBootCollision = errors.New("auth: provider set install: name collides with a boot-declared provider (boot wins; edit yaml + restart)")
	// ErrProviderOwnerCollision — a runtime-installed name is owned by a
	// DIFFERENT owner than the caller. Install refuses shadowing another owner's
	// name; Uninstall refuses dropping another owner's provider (the
	// defense-in-depth store-boundary owner check). In a shared runtime a
	// runtime-installed name is deployment-global (the bounded guarantee: no
	// silent shadow, no cross-owner drop).
	ErrProviderOwnerCollision = errors.New("auth: provider set: name owned by a different owner (a runtime-installed name is deployment-global)")
	// ErrProviderBootProtected — Uninstall named a boot-seeded provider. A boot
	// provider is edited in yaml + restart, never uninstalled over the control
	// plane.
	ErrProviderBootProtected = errors.New("auth: provider set uninstall: name is a boot-declared provider (edit yaml + restart)")
)

ProviderSet sentinel errors.

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

View Source
var ErrDiscoveryRefused = errors.New("auth: oauth requirement discovery fetch refused")

ErrDiscoveryRefused is the sentinel for an SSRF-guardrail refusal during a discovery fetch. It is carried into a DiscoveryStepStatus, never returned to a runtime caller as a hard error.

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.

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: 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 OriginOf added in v1.14.0

func OriginOf(raw string) (string, bool)

OriginOf normalises a raw URL to its scheme://host origin (port-preserving, path/query/fragment stripped), reporting ok=false when the input is unparseable or missing a scheme/host. It is the exported form of the walker's internal origin normaliser so an out-of-package consumer (the MCP registry's discovery-allowance revoke-prune) can match an authorization-server entry's provenance origin against a revoked allow-set using the SAME normalisation the walk used — one normaliser, two call sites (CLAUDE.md §17.6).

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 AuthorizationServerMeta added in v1.13.0

type AuthorizationServerMeta struct {
	// Issuer is the authorization server's issuer identifier.
	Issuer string `json:"issuer"`
	// AuthorizationEndpoint is the RFC 6749 authorization endpoint URL.
	AuthorizationEndpoint string `json:"authorization_endpoint"`
	// TokenEndpoint is the RFC 6749 token endpoint URL. NEVER dialed by Harbor.
	TokenEndpoint string `json:"token_endpoint"`
	// ScopesSupported is the advertised scope list (may be empty).
	ScopesSupported []string `json:"scopes_supported"`
	// CodeChallengeMethodsSupported is the advertised PKCE method list.
	CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
	// RegistrationEndpoint is the optional RFC 7591 dynamic-registration
	// endpoint. REPORTED, never invoked (the custody boundary).
	RegistrationEndpoint string `json:"registration_endpoint,omitempty"`
	// Resource is the optional RFC 8707 resource identifier the AS binds.
	Resource string `json:"resource,omitempty"`
	// SourceURL is the URL the document was fetched from (provenance).
	SourceURL string `json:"source_url"`
}

AuthorizationServerMeta is one RFC 8414 / OIDC authorization-server-metadata document, surfaced VERBATIM. It is inert data — Harbor never dials any of these endpoints. RegistrationEndpoint is reported, never invoked.

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.

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 — 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 BindingState added in v1.14.0

type BindingState int

BindingState is the read-time OAuth binding posture of a tool source under a given identity — the catalog-lens projection the Tools page's OAuth-status facet rides. It is a NEUTRAL runtime enum (no Protocol wire coupling): the catalog annotator maps it onto the wire ToolOAuthStatus.

const (
	// BindingNotConfigured — the source has no OAuthConfig; it requires
	// no OAuth (the wire "n/a").
	BindingNotConfigured BindingState = iota
	// BindingUnbound — the source requires OAuth but no token binding
	// exists for this identity (the wire "Required").
	BindingUnbound
	// BindingBound — a live, unexpired token binding exists (the wire
	// "Bound").
	BindingBound
	// BindingExpired — a token binding exists but has lapsed (the wire
	// "Expired").
	BindingExpired
)

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 Discoverer added in v1.13.0

type Discoverer struct {
	// contains filtered or unexported fields
}

Discoverer walks the OAuth requirement chain under SSRF guardrails. It is a compiled artifact — immutable after construction and safe to share across N concurrent Discover calls (it holds no per-walk state; each walk's state lives in local variables + the returned value). See the concurrent-reuse contract.

func NewDiscoverer added in v1.13.0

func NewDiscoverer(opts ...DiscovererOption) *Discoverer

NewDiscoverer builds a Discoverer with strict SSRF defaults: 8 KiB body cap, 5 s per-fetch timeout, at most 3 redirects, each redirect hop re- validated, and a dial-time control that refuses private-range resolved addresses (defence against DNS rebinding), relaxed for EXACTLY the same-origin protected-resource hop pinned to the connection's declared dial target.

func (*Discoverer) Discover added in v1.13.0

Discover walks the chain for one connection and returns the verbatim requirement plus per-hop statuses. It never returns an error — a refused or failed hop is reported in Status; the caller surfaces the partial result.

type DiscovererOption added in v1.13.0

type DiscovererOption func(*Discoverer)

DiscovererOption configures a Discoverer at construction.

func WithDiscoveryClock added in v1.13.0

func WithDiscoveryClock(now func() time.Time) DiscovererOption

WithDiscoveryClock overrides the wall clock (tests inject a fixed clock).

func WithDiscoveryMaxBodyBytes added in v1.13.0

func WithDiscoveryMaxBodyBytes(n int64) DiscovererOption

WithDiscoveryMaxBodyBytes overrides the per-fetch response size cap.

func WithDiscoveryTimeout added in v1.13.0

func WithDiscoveryTimeout(d time.Duration) DiscovererOption

WithDiscoveryTimeout overrides the per-fetch timeout.

func WithPrivateNetworkAccessForTest added in v1.13.0

func WithPrivateNetworkAccessForTest() DiscovererOption

WithPrivateNetworkAccessForTest relaxes the private-range / IP-literal refusal so httptest fixtures on loopback can be reached.

TEST-ONLY, mechanically enforced: it PANICS when invoked outside a test binary (testing.Testing() is false in the production `harbor` binary), so it can never accidentally disable the load-bearing SSRF guardrail in production. It exists because httptest servers bind loopback and the positive-path chain-walk tests must reach them; the negative guardrail tests deliberately omit it to prove production refuses.

func WithResolverForTest added in v1.14.0

func WithResolverForTest(r *net.Resolver) DiscovererOption

WithResolverForTest injects the name resolver used BOTH for the walk-start dial-pin resolution and the dialer, so a fixture can map a service name to a loopback address without touching the OS resolver.

TEST-ONLY, mechanically enforced: it PANICS when invoked outside a test binary. It does NOT relax any SSRF guardrail — the dial-time pin still refuses any private resolved address that is not the pinned same-origin target — it only redirects name resolution so the compose/k8s posture (a plain-HTTP service name resolving to loopback) can be exercised on the production dial path.

type DiscoveryInput added in v1.13.0

type DiscoveryInput struct {
	// ServerURL is the declared MCP server URL (the origin the 9728 hop
	// defaults to same-origin against). Required.
	ServerURL string
	// ResourceMetadataURL is the `resource_metadata` URL carried on the
	// captured WWW-Authenticate challenge. Empty falls back to
	// `{server-origin}/.well-known/oauth-protected-resource`.
	ResourceMetadataURL string
	// Source is "challenge" or "probe".
	Source string
	// AllowedOrigins is the explicit per-connection cross-origin allowance
	// list (scheme://host[:port]). The AS hop always requires a matching
	// entry; the 9728 hop requires one only when the metadata URL is
	// cross-origin to ServerURL.
	AllowedOrigins []string
}

DiscoveryInput is the per-connection input to one discovery walk.

type DiscoveryStepStatus added in v1.13.0

type DiscoveryStepStatus struct {
	// Step names the hop.
	Step discoveryStep `json:"step"`
	// Target is the URL the hop attempted.
	Target string `json:"target"`
	// OK reports whether the hop succeeded.
	OK bool `json:"ok"`
	// Reason is the stable machine-readable failure code (empty when OK).
	Reason string `json:"reason,omitempty"`
	// Detail is a human-readable message (never carries a credential).
	Detail string `json:"detail,omitempty"`
}

DiscoveryStepStatus is the typed per-hop outcome. A failed step carries a stable Reason code and a human detail; a chain surfaces partially rather than silently empty.

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 the 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 the "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.
	// 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`, 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); the mixed-scope coexistence requirement 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 the pause primitive). 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 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` — 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 — a recorded design decision.
	// `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

	// AllowedDownstreamHosts returns the boot-declared, config/file-only
	// set of downstream connection hosts (host[:port]) this provider is
	// permitted to inject a credential into. It is a credential-sink
	// bound: the credential-plane invariant is that no admin-writable
	// field determines where a credential is sent, so the sink allow-list
	// is fixed at construction and read — never derived from a wire /
	// runtime-add request. An empty result means the provider declares no
	// allowed sink; a caller that injects a bearer (the MCP southbound
	// binding) MUST refuse a binding whose downstream host is not listed,
	// fail-closed. The returned slice is a copy — callers may retain it
	// without aliasing provider state.
	AllowedDownstreamHosts() []string
}

OAuthProvider is the canonical contract for tool-side OAuth. It is transport-agnostic: the HTTP / MCP / 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 OAuthRequirement added in v1.13.0

type OAuthRequirement struct {
	// ResourceMetadataURL is the RFC 9728 protected-resource-metadata URL that
	// was walked (from the challenge, else the server's well-known default).
	ResourceMetadataURL string `json:"resource_metadata_url"`
	// AuthorizationServers is the verbatim RFC 8414 metadata for each
	// advertised authorization server that resolved. May be empty when the
	// AS hop was refused (see Status).
	AuthorizationServers []AuthorizationServerMeta `json:"authorization_servers"`
	// DiscoveredAt is the wall-clock instant discovery ran.
	DiscoveredAt time.Time `json:"discovered_at"`
	// Source is "challenge" (from a 401 WWW-Authenticate step-up) or "probe"
	// (an operator-triggered probe).
	Source string `json:"source"`
	// SourceURL is the resource-metadata URL discovery started from.
	SourceURL string `json:"source_url"`
	// Status is the typed per-hop outcome list — a partial chain reports the
	// hops it refused / failed here rather than surfacing silently empty.
	Status []DiscoveryStepStatus `json:"status"`
}

OAuthRequirement is the discovered advertisement, surfaced as inert data. It is a PROPOSAL an operator confirms — never auto-applied to any config.

type Owner added in v1.14.0

type Owner struct {
	// Tenant is the owning tenant id (the verified caller's tenant at attach /
	// install time).
	Tenant string
	// Agent is the owning agent id — the agent whose agent-config revision the
	// runtime-added entry belongs to. Registration metadata, never an isolation
	// key.
	Agent string
}

Owner is the (tenant, agent) reconcile-view tag stamped on a runtime-added MCP connection or a Protocol-installed OAuth provider. It mirrors the agent-config revision owner that already governs the entry (the agent config scope) and is used for EXACTLY one thing beyond that revision ownership: scoping the run-start reconcile VIEW so a run only ever touches its OWN owner's runtime-added entries — never boot-declared entries, never another owner's runtime-adds.

Owner is deliberately NOT an isolation principal: agent_id is not part of the (tenant, user, session) isolation tuple, so the owner tag is never used as a dispatch key or a storage WHERE clause. Boot-declared infrastructure and the bare-name tool catalog stay process-global and deployment-shared: resolution and dispatch happen by bare name across every session, and boot servers stay visible to every session's read surface. A shared runtime therefore TRUSTS its co-tenant admins for runtime-added connection / provider names — a name collision fails loud rather than silently cross-serving — and a deployment that needs hard isolation of runtime-added tools runs one runtime per tenant, which then gets full isolation for free (one tenant; everything in the global catalog is theirs).

A zero Owner denotes a boot-declared / untagged entry, which the reconcile view never enumerates.

func (Owner) IsZero added in v1.14.0

func (o Owner) IsZero() bool

IsZero reports whether o is the zero (boot-declared / untagged) owner. A zero-owner entry is never enumerated by the owner-scoped reconcile view.

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`) 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 (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: 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) AllowedDownstreamHosts added in v1.14.0

func (p *Provider) AllowedDownstreamHosts() []string

AllowedDownstreamHosts implements OAuthProvider. The bare interactive Provider carries no southbound-binding sink allow-list of its own — the allow-list is a driver-boundary concern declared per operator-config entry and carried by the wrapping driver (the `oauth2` / `tokenexchange` drivers store it and answer here). A direct `*Provider` used as an OAuthProvider therefore declares no allowed sink, so any southbound binding against it is refused fail-closed until a driver supplies the allow-list.

func (*Provider) BindingStatus added in v1.14.0

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

BindingStatus reports the read-time OAuth binding posture of source under the ctx identity WITHOUT triggering a refresh or any network call — the catalog-projection read path. It is the observation counterpart to Token (which mutates: refreshes, parks on ErrAuthRequired). A source this provider does not configure returns BindingNotConfigured (the caller should try another provider, or map it to "n/a"). Identity is mandatory — an incomplete ctx triple fails closed.

BindingStatus is safe for concurrent use: it reads the immutable config map + the internally-synchronised TokenStore, holding no per-call state on the provider.

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`, 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 — 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 — a recorded design decision.

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 ProviderBuilder added in v1.14.0

type ProviderBuilder struct {
	// contains filtered or unexported fields
}

ProviderBuilder constructs Protocol-installed broker-pull provider instances at runtime from a boot-declared named credential broker plus a wire descriptor's non-secret scope subset. Every credential-sink-determining value (the token endpoint, the credential-pull endpoint, the allowed downstream hosts, the audience, and the scope ceiling) is read from the boot broker — NONE from the descriptor — so no admin-writable field determines where a credential is sent (the credential-plane invariant). It is built ONCE at boot (the shared crypto chain constructed alongside BuildProviders) and is safe for concurrent reuse (immutable after construction).

func NewProviderBuilder added in v1.14.0

func NewProviderBuilder(ctx context.Context, cfg config.ToolsConfig, deps BuildDeps) (*ProviderBuilder, error)

NewProviderBuilder builds the runtime provider builder from the boot config. It constructs the shared crypto chain (KEK → sealer → token store) ONCE so an installed provider shares the same token store as the boot providers, and captures the boot broker set by name. When no broker is declared it returns a builder whose Build fails loud with ErrUnknownBroker (nothing to resolve against) — never a nil-panic. deps' four fields are mandatory when at least one broker is declared.

func (*ProviderBuilder) BrokerNames added in v1.14.0

func (b *ProviderBuilder) BrokerNames() []string

BrokerNames returns the sorted boot-declared broker names (for a fail-loud error message and the Console's broker picker feed).

func (*ProviderBuilder) Build added in v1.14.0

func (b *ProviderBuilder) Build(ctx context.Context, name, brokerName string, scopes []string) (OAuthProvider, error)

Build constructs a broker-pull provider instance named `name`, resolving `brokerName` against the boot broker set and requesting `scopes` (clamped to the broker's boot scope ceiling by the tokenexchange driver). An unknown broker or a broker without a credential_url fails loud; the credential source is the boot-pinned remote PULL. The descriptor NEVER contributes a URL, an env-var name, or a secret — only the scope subset.

type ProviderConfig

type ProviderConfig struct {
	// Name is the operator-facing provider name. Surfaced in error
	// messages.
	Name string
	// CredentialSource resolves the provider's OWN client credential
	// (`client_id` / `client_secret`) — either at boot (env) or lazily
	// at first use (remote). Required; drivers fail closed when nil.
	CredentialSource credsource.Source
	// 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
	// AllowedDownstreamHosts is the boot-declared, config/file-only set of
	// downstream connection hosts (host[:port]) a southbound binding may
	// inject this provider's credential into. The credential-plane
	// invariant: no admin-writable field determines a credential sink, so
	// this allow-list is fixed at construction. A bearer-injecting binding
	// whose downstream host is absent is refused fail-closed. Every driver
	// stores it and answers `OAuthProvider.AllowedDownstreamHosts`.
	AllowedDownstreamHosts []string
	// Audience is the boot-declared token-audience ceiling. When non-empty
	// it is the authority for the exchanged token's audience — NOT the
	// caller-chosen provider name — closing the audience-picking lever. An
	// empty Audience preserves the legacy audience-from-name behaviour
	// (backward-compatible; opt-in hardening).
	Audience string
	// ScopeCeiling is the boot-declared scope ceiling. When non-empty the
	// requested scopes (`Scopes`) are INTERSECTED against it — a requested
	// scope outside the ceiling is dropped, never honoured. An empty
	// ceiling preserves the legacy pass-through behaviour.
	ScopeCeiling []string
	// ResourceIndicator is the boot-declared RFC 8707 `resource` value the
	// `tokenexchange` driver carries as the `resource` form parameter on every
	// exchange, and verifies the returned token's `aud` against (when the
	// token is JWT-shaped). Empty preserves today's behaviour (no `resource`
	// sent, no audience check). Ignored by the interactive `oauth2` driver.
	ResourceIndicator string
	// IncludeActorToken opts the `tokenexchange` driver into carrying the
	// run's verified acting principal (`agent_id`, when present on ctx) as an
	// RFC 8693 `actor_token`. Default false. Ignored by `oauth2`.
	IncludeActorToken bool
}

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 through the credsource.Source seam (§7 rule 2 — env-var indirection or an authenticated remote pull; never hardcoded, never logged). `BuildProviders` constructs the source from the provider's declared `credential_source` and threads it here as `CredentialSource`; the driver calls `CredentialSource.Resolve(ctx)` at its credential-need point. A driver that resolves an empty credential fails closed. Direct constructors (headless embedders, tests) supply `credsource.Static(id, secret)` so there is exactly ONE credential-resolution path (§13, no dual "static fields" path).

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.
	// 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 ProviderSet added in v1.14.0

type ProviderSet interface {
	// Get resolves a provider by BARE name (boot-seeded or runtime-installed).
	// The bool reports presence.
	Get(name string) (OAuthProvider, bool)
	// Names returns every resolvable provider name (boot + installed), sorted —
	// for a fail-loud "registered: …" message.
	Names() []string
	// InstalledFor returns the names of the RUNTIME-INSTALLED providers owned by
	// owner (never boot-seeded, never another owner's), sorted — the owner-scoped
	// reconcile view. A zero owner returns nil.
	InstalledFor(owner Owner) []string
	// Install adds (or upserts) a runtime-installed provider under name, tagged
	// with owner. A name colliding with a boot-seeded provider fails loud
	// (ErrProviderBootCollision); a name colliding with a DIFFERENT owner's
	// install fails loud (ErrProviderOwnerCollision); a re-install by the SAME
	// owner replaces (and closes) the prior instance. owner must be non-zero.
	Install(owner Owner, name string, p OAuthProvider) error
	// Uninstall removes the runtime-installed provider under name IFF owner
	// matches the installed entry's owner, and CLOSES it (so a still-bound
	// connection's next call fails LOUD, never an unauthenticated dial). A
	// missing name is an idempotent no-op; a boot-seeded (zero-owner) name is
	// refused (ErrProviderBootProtected) — boot providers are edited in yaml +
	// restart, never uninstalled over the control plane; a cross-owner drop is
	// refused (ErrProviderOwnerCollision).
	//
	// Owner-scoping is enforced HERE, at the store boundary, independently of
	// caller-side owner resolution — a second, independent owner check
	// (defense in depth). The
	// `agent_config.remove_oauth_provider` handler already resolves the name
	// within the caller's OWN active agent-config revision (under lockAgent), so
	// the store check is the second, independent gate: the store refuses a drop
	// whose owner does not match the entry's owner, mirroring Install's
	// owner-collision refusal. Never weakens the credential-sink invariant
	// (owner is a reconcile-view tag, never a credential sink).
	Uninstall(ctx context.Context, owner Owner, name string) error
}

ProviderSet is the runtime home of constructed OAuth provider instances the MCP attach path resolves a binding against. Resolution is by bare name (process-global); runtime-installed entries carry an owner tag for reconcile-view scoping only. See the file doc for the full contract.

func NewProviderSet added in v1.14.0

func NewProviderSet(seed map[string]OAuthProvider) ProviderSet

NewProviderSet builds a ProviderSet seeded from the boot-built provider map (BuildProviders' result plus any injected providers). Seeded entries are boot-owned (zero owner): resolvable by bare name, never reconcile-enumerated, never uninstallable over the control plane. A nil seed yields an empty set.

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: 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.
	// 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.

type ToolCredentialExchangedPayload added in v1.9.0

type ToolCredentialExchangedPayload struct {
	events.SafeSealed
	// Source is the ToolSourceID the exchanged credential authorises.
	Source string
	// BindingScope is "user" or "agent" — echoes the attachment's
	// binding scope (the V1 `tokenexchange` driver serves "user").
	BindingScope string
	// SubjectKind names which principal the broker minted the token
	// for: "user" for a user-bound exchange. Never the subject value
	// itself (that is identity, carried on the event's Identity
	// quadruple), only the kind.
	SubjectKind string
	// BrokerHost is the host of the configured broker `token_url` —
	// the external authority the exchange targeted. Host only; never
	// the full URL with query material.
	BrokerHost string
	// GrantedScopes is the scope list the broker granted (may be a
	// subset of the requested scopes).
	GrantedScopes []string
	// ExpiresAt is the broker-advertised wall-clock validity of the
	// exchanged token. Zero when the broker advertised no expiry. The
	// runtime's in-memory cache-serve horizon is bounded by (never
	// exceeds) this value.
	ExpiresAt time.Time
	// AudienceVerified reports whether the exchanged token's `aud` claim was
	// checked against the boot-declared RFC 8707 resource indicator. True only
	// when a resource indicator is declared AND the returned token was
	// JWT-shaped (so its `aud` could be read). False is an HONEST no-op — an
	// opaque bearer (RFC 8693 does not require a JWT) or an undeclared resource
	// leaves it false rather than a fabricated pass. A JWT whose `aud` excludes
	// the resource fails the exchange loud and emits nothing.
	AudienceVerified bool
	// ActorAsserted reports whether an RFC 8693 `actor_token` (the run's
	// verified acting principal — `agent_id`) rode the exchange. True only when
	// the provider opted in AND the ctx carried an invoking agent id. Never a
	// client-named value.
	ActorAsserted bool
}

ToolCredentialExchangedPayload is the typed payload for a `tool.credential_exchanged` event — one downstream-credential exchange against an external credential broker (the pull-based `tokenexchange` acquisition strategy). SafePayload by construction: every field is the runtime's own bookkeeping or operator-supplied configuration metadata; NO access / refresh token plaintext, NO broker-response bytes. Emitted once per ACTUAL exchange — a cache hit emits nothing.

Directories

Path Synopsis
Package conformancetest exposes the shared TokenStore + Sealer conformance suite Harbor ships.
Package conformancetest exposes the shared TokenStore + Sealer conformance suite Harbor ships.
Package credsource is the §4.4 seam through which an OAuth provider's OWN client credential (`client_id` / `client_secret`) resolves.
Package credsource is the §4.4 seam through which an OAuth provider's OWN client credential (`client_id` / `client_secret`) resolves.
credsourcetest
Package credsourcetest ships the committed REFERENCE implementation of the coordinator credential-fetch contract: an httptest server that serves an OAuth provider's client credential to the `remote` credential source.
Package credsourcetest ships the committed REFERENCE implementation of the coordinator credential-fetch contract: an httptest server that serves an OAuth provider's client credential to the `remote` credential source.
drivers/env
Package env ships the default credential source: the OAuth provider's client credential is read from the process environment ONCE at boot.
Package env ships the default credential source: the OAuth provider's client credential is read from the process environment ONCE at boot.
drivers/remote
Package remote ships the coordinator-served credential source: the runtime PULLS an OAuth provider's client credential (`client_id` / `client_secret`) from an operator-configured endpoint at first need, authenticated by the runtime's own service token.
Package remote ships the coordinator-served credential source: the runtime PULLS an OAuth provider's client credential (`client_id` / `client_secret`) from an operator-configured endpoint at first need, authenticated by the runtime's own service token.
drivers
oauth2
Package oauth2 ships Harbor's V1 default OAuth provider driver (closes issue #116 and the deferred construction gap).
Package oauth2 ships Harbor's V1 default OAuth provider driver (closes issue #116 and the deferred construction gap).
tokenexchange
Package tokenexchange ships Harbor's pull-based, non-interactive external-credential acquisition strategy: a driver on the OAuth flow-strategy registry that obtains a downstream tool credential (a user's Microsoft 365 token, a Google Workspace token — foreign-IdP tokens used to call third-party tools) from an operator-configured external credential broker (a fleet orchestrator, an enterprise token vault, an STS) via an RFC-8693-shaped token exchange, instead of Harbor's interactive authorization-code flow.
Package tokenexchange ships Harbor's pull-based, non-interactive external-credential acquisition strategy: a driver on the OAuth flow-strategy registry that obtains a downstream tool credential (a user's Microsoft 365 token, a Google Workspace token — foreign-IdP tokens used to call third-party tools) from an operator-configured external credential broker (a fleet orchestrator, an enterprise token vault, an STS) via an RFC-8693-shaped token exchange, instead of Harbor's interactive authorization-code flow.

Jump to

Keyboard shortcuts

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