Documentation
¶
Overview ¶
Package connoauth provides a single shared implementation of the OAuth 2.1 authorization_code flow for outbound connections (MCP gateways, HTTP API gateways, future connection kinds). The platform composes multiple toolkit families that all need the same flow: browser sign-in → callback → encrypted refresh token persisted → silent access-token minting on tool calls. Forking that flow per kind produced two parallel codepaths with subtly different bugs; this package replaces both.
The package owns:
- Persistent token storage (connection_oauth_tokens), keyed by (connection_kind, connection_name).
- Per-request access-token acquisition with silent refresh, via golang.org/x/oauth2. After every successful refresh exchange the package explicitly persists the result — including any rotated refresh token — back to the store, solving the class of bug where a rotated refresh token is not re-persisted and the next process restart replays the dead one.
- Initial authorization_code exchange used by the admin OAuth callback handler.
- Distinction between revoked refresh (RFC 6749 §5.2 invalid_grant at HTTP 400 → admin must reconnect) and transient failures (network, 5xx → retry without deleting the row).
The package does NOT own PKCE state, HTTP routing, or the authorization-URL builder — those live in pkg/admin where the public callback is registered. The per-kind connection config (auth URL, token URL, client id/secret, scopes) is supplied to this package by callers via the Config struct.
Index ¶
- Constants
- Variables
- type AdvisoryLocker
- type Config
- type ConfigResolver
- type ExchangeInput
- type ExchangeResult
- type FieldEncryptor
- type Key
- type MemoryStore
- func (s *MemoryStore) Delete(_ context.Context, key Key) error
- func (s *MemoryStore) Get(_ context.Context, key Key) (*PersistedToken, error)
- func (s *MemoryStore) List(_ context.Context) ([]PersistedToken, error)
- func (s *MemoryStore) Lock(_ context.Context, key Key) (func(), error)
- func (s *MemoryStore) Set(_ context.Context, t PersistedToken) error
- type NoopLocker
- type OAuthStatus
- type PersistedToken
- type PostgresLocker
- type PostgresStore
- func (s *PostgresStore) Delete(ctx context.Context, key Key) error
- func (s *PostgresStore) Get(ctx context.Context, key Key) (*PersistedToken, error)
- func (s *PostgresStore) List(ctx context.Context) ([]PersistedToken, error)
- func (s *PostgresStore) Lock(ctx context.Context, key Key) (func(), error)
- func (s *PostgresStore) Set(ctx context.Context, t PersistedToken) error
- type Refresher
- type RefresherConfig
- type RevocationEvent
- type Source
- type Store
Constants ¶
const ( // KindMCP is the MCP gateway toolkit family (composes upstream MCP // servers behind one platform-side server). KindMCP = "mcp" // KindAPI is the HTTP API gateway toolkit family (treats an OpenAPI // REST service as a tool surface). KindAPI = "api" )
Kind constants for the connection_kind column. New connection kinds that participate in this shared OAuth flow add a constant here and extend the per-kind dispatch in pkg/admin's connection OAuth handler.
Variables ¶
var ErrConfigNotResolvable = errors.New("connoauth: connection config not resolvable")
ErrConfigNotResolvable signals that the refresher should skip a row without treating it as a failure. Distinct sentinel so the loop's classifier doesn't have to string-match.
var ErrNeedsReauth = errors.New("connoauth: connection needs admin reconnect")
ErrNeedsReauth is the structured signal that no access token can be minted without operator interaction. Produced when:
- No refresh token is persisted (initial Connect required, or a prior revoked-refresh cleared the row).
- The IdP-disclosed refresh deadline has passed.
- The IdP rejected the most recent refresh attempt with RFC 6749 §5.2 invalid_grant at HTTP 400 (revoked / expired refresh).
Transient failures (network drops, 5xx, request cancellation) DO NOT produce this error — they surface as transport errors so the caller can retry without forcing the operator to reconnect.
var ErrTokenNotFound = errors.New("connoauth: token not found")
ErrTokenNotFound is returned by Store.Get when no token row exists for the supplied (kind, name). Callers treat this as "needs reauthentication" and surface a Connect button in the admin UI.
Functions ¶
This section is empty.
Types ¶
type AdvisoryLocker ¶ added in v1.61.0
type AdvisoryLocker interface {
// TryLock attempts to acquire a non-blocking lock keyed on
// (kind, name). Returns (release, true, nil) on acquire,
// (nil, false, nil) when contended (some other replica owns it),
// (nil, false, err) on operational failure (DB unreachable).
//
//nolint:revive // named returns are intentional on this contract
TryLock(ctx context.Context, k Key) (release func(), ok bool, err error)
}
AdvisoryLocker prevents two replicas from refreshing the same connection simultaneously. The Postgres implementation uses pg_try_advisory_lock — non-blocking, so a contended row is silently skipped on that tick; the next replica picks it up on its own tick.
Single-replica deployments can pass NoopLocker to skip locking entirely. The refresher remains correct without locks; the cost is only redundant IdP round-trips when multiple replicas race.
type Config ¶
type Config struct {
// Grant is the OAuth flow the connection uses. One of
// `authorization_code` (browser-driven, refresh-token-persisting)
// or `client_credentials` (machine-to-machine). Surfaced through
// Status so the admin UI can adapt its prompts. The Source itself
// uses the value only for status reporting — refresh-token
// exchanges are identical for both grants once the initial token
// has been persisted.
Grant string
// AuthorizationURL is the IdP's authorization endpoint (where the
// operator's browser is sent to sign in). Used only by the admin
// handler's authorization-URL builder, not by this package's
// token operations, but kept here so a single Config value carries
// every field the flow needs.
AuthorizationURL string
// TokenURL is the IdP's token endpoint. Used by both the initial
// code→token exchange and every silent refresh.
TokenURL string
// ClientID identifies the platform's registration with the IdP.
ClientID string
// ClientSecret is the matching credential. Stored encrypted in
// connection_instances; decrypted by the connection-store layer
// before reaching this struct.
ClientSecret string
// Scopes is the space-delimited list of OAuth scopes negotiated
// with the IdP. Operators of Keycloak/Auth0/Okta typically need
// `offline_access` (or vendor equivalent) for the IdP to issue a
// refresh token at all.
Scopes []string
// EndpointAuthStyle selects how the client credentials reach the
// token endpoint. Defaults to AuthStyleInHeader (HTTP Basic) per
// OAuth 2.1's recommended style; AuthStyleInParams sends them in
// the form body (some legacy IdPs require this).
EndpointAuthStyle oauth2.AuthStyle
// Prompt is the optional OIDC `prompt` parameter
// (RFC OIDC §3.1.2.1). Common values: empty (default), "login",
// "consent", "select_account". Pure-OAuth providers that don't
// recognize it should leave this empty so the IdP doesn't reject
// the authorize request with invalid_request.
Prompt string
}
Config carries the per-connection OAuth 2.1 settings required by the authorization_code flow. Built by callers (admin handler / toolkit Authenticator) from their respective per-kind connection config schemas — connoauth itself is kind-agnostic.
SECURITY: Config carries the client secret in memory; do not log it. The Source's error returns are sanitized to keep secret material out of model/log output (see source.go:tokenFetchError).
type ConfigResolver ¶ added in v1.61.0
type ConfigResolver interface {
// ResolveConfig returns the OAuth config for the persisted (kind,
// name) row. Returns ErrConfigNotResolvable when the connection
// no longer exists OR is no longer configured for
// authorization_code OAuth — the refresher treats this as
// "skip" rather than "fail" so a deleted-connection row can't
// stop the loop from processing the rest.
ResolveConfig(ctx context.Context, key Key) (Config, error)
// MaxLifetime returns the operator-configured wall-clock max
// lifetime for the refresh token (e.g., 30d for Salesforce, 90d
// for Microsoft). Zero means rely on the IdP-disclosed
// refresh_expires_at and access-token expiry only.
MaxLifetime(ctx context.Context, key Key) time.Duration
}
ConfigResolver is the small surface the refresher needs to obtain per-(kind, name) connoauth.Config and the operator-configured max-lifetime hint without importing the platform package (which would create an import cycle). The platform provides a concrete implementation that delegates to ConnectionStore + per-kind OAuthKindHandlers + the toolkit-specific config schema.
type ExchangeInput ¶
type ExchangeInput struct {
// Config is the per-connection IdP settings. Required.
Config Config
// Code is the IdP-issued authorization code from the callback's
// `code` query parameter.
Code string
// CodeVerifier is the PKCE verifier the admin handler stored at
// oauth-start time. Required by RFC 7636 §4.5.
CodeVerifier string
// RedirectURI must exactly match the redirect_uri used at
// oauth-start AND registered with the IdP. Required by RFC 6749
// §4.1.3.
RedirectURI string
}
ExchangeInput collects the parameters of an authorization_code token exchange. The admin callback handler builds this from the PKCE state + code + per-kind connection config and hands it to Exchange().
type ExchangeResult ¶
type ExchangeResult struct {
AccessToken string
RefreshToken string
ExpiresAt time.Time
RefreshExpiresAt time.Time
Scope string
// IDToken is the OIDC ID token if the IdP issued one (Keycloak,
// Okta, Auth0 typically do when `openid` is in scope). Reserved
// for future use by the admin callback (e.g., extracting the
// AuthenticatedBy claim); the current callers use the started_by
// captured at oauth-start.
IDToken string
}
ExchangeResult is the parsed response from an authorization_code exchange. Mirrors the token-endpoint response shape with the addition of RefreshExpiresAt (Keycloak's refresh_expires_in resolved to an absolute deadline).
func Exchange ¶
func Exchange(ctx context.Context, in ExchangeInput) (*ExchangeResult, error)
Exchange performs the authorization_code → tokens POST against the IdP's token endpoint. Replaces the two duplicate exchangeAuthorizationCode / exchangeAPIGatewayCode functions from the per-kind handlers — both old kinds funnel through this code now, so security guards (timeout, redirect-refusal, body cap) cannot drift between kinds.
type FieldEncryptor ¶
type FieldEncryptor interface {
Encrypt(plaintext string) (string, error)
Decrypt(ciphertext string) (string, error)
}
FieldEncryptor abstracts the platform's at-rest field encryption so this package doesn't import pkg/platform (which would create a dependency cycle via the toolkit composition wiring). The platform supplies its concrete FieldEncryptor at startup; the noop fallback below is used in dev when ENCRYPTION_KEY is unset (the platform logs a startup warning on this code path).
type Key ¶
type Key struct {
// Kind is one of the KindXxx constants. Empty is invalid; the
// Store.Get / Set / Delete methods reject zero-value keys with a
// validation error rather than silently writing to an empty-kind
// row that would never be read back by a real consumer.
Kind string
// Name matches the connection_instances.name column for the same
// (kind, name) pair. Stable across saves of the same connection.
Name string
}
Key uniquely identifies one (connection_kind, connection_name) row in connection_oauth_tokens. Distinct from a bare string so callers can't accidentally pass the wrong identifier — every Store method takes a Key.
type MemoryStore ¶
type MemoryStore struct {
// contains filtered or unexported fields
}
MemoryStore is a process-local Store used in tests and as a fallback when no database is configured. Tokens DO NOT survive process restarts.
func NewMemoryStore ¶
func NewMemoryStore() *MemoryStore
NewMemoryStore returns an in-process Store. Production deployments use NewPostgresStore.
func (*MemoryStore) Delete ¶
func (s *MemoryStore) Delete(_ context.Context, key Key) error
Delete removes the in-memory token row for the key. Idempotent.
func (*MemoryStore) Get ¶
func (s *MemoryStore) Get(_ context.Context, key Key) (*PersistedToken, error)
Get returns the in-memory token or ErrTokenNotFound.
func (*MemoryStore) List ¶ added in v1.61.0
func (s *MemoryStore) List(_ context.Context) ([]PersistedToken, error)
List returns metadata for every persisted row. AccessToken is blanked and RefreshToken is replaced with the refreshTokenSentinel when the underlying row has a non-empty refresh token (and left empty otherwise). The sentinel matches PostgresStore.List's behavior so the Refresher's `if row.RefreshToken == "" { return }` branch works the same against either backend — without this alignment, a Refresher backed by MemoryStore would silently skip every row.
func (*MemoryStore) Lock ¶ added in v1.61.9
func (s *MemoryStore) Lock(_ context.Context, key Key) (func(), error)
Lock acquires an exclusive per-key mutex. The returned release function is idempotent and safe to defer. The context is accepted for interface symmetry with PostgresStore (which observes ctx during the wait); the in-process mutex acquisition itself does not block on I/O so ctx is never consulted here.
func (*MemoryStore) Set ¶
func (s *MemoryStore) Set(_ context.Context, t PersistedToken) error
Set stores a token in process memory, stamping UpdatedAt.
type NoopLocker ¶ added in v1.61.0
type NoopLocker struct{}
NoopLocker always grants the lock. Use in single-replica deployments and in tests.
type OAuthStatus ¶
type OAuthStatus struct {
// Configured indicates the connection is set up for the
// authorization_code grant. False means the status endpoint was
// hit for a non-OAuth connection (or one with a different grant)
// and the UI should hide the OAuth block entirely.
Configured bool `json:"configured"`
// TokenAcquired is true when a non-empty access_token sits in the
// persisted row. False after a revoked-refresh cleanup or before
// the first Connect.
TokenAcquired bool `json:"token_acquired"`
// ExpiresAt is the access-token deadline. Zero when no token has
// been acquired.
ExpiresAt time.Time `json:"expires_at,omitzero"`
// LastRefreshedAt is the persisted row's UpdatedAt — the time of
// the most recent successful write (initial Connect or silent
// refresh).
LastRefreshedAt time.Time `json:"last_refreshed_at,omitzero"`
// HasRefreshToken is true when a refresh token sits in the row.
// False for IdPs that don't issue refresh tokens, OR after a
// revoked-refresh cleanup.
HasRefreshToken bool `json:"has_refresh_token"`
// RefreshExpiresAt is the IdP-disclosed refresh-token deadline.
// Zero when the IdP did not disclose one. The admin UI renders an
// em dash for zero, not "never".
RefreshExpiresAt time.Time `json:"refresh_expires_at,omitzero"`
// LastError is the most recent surfaced failure (transport,
// IdP-rejected, persistence). Cleared by a subsequent success.
LastError string `json:"last_error,omitempty"`
// TokenURL is the IdP's token endpoint, surfaced so the admin
// status panel can show "auth against https://iam.example.com/..."
// at a glance. Operator-authored config; safe to expose.
TokenURL string `json:"token_url,omitempty"`
// Scope is the space-delimited scope string negotiated with the
// IdP. Surfaced so operators can verify offline_access is present
// on Keycloak/Auth0/Okta IdPs.
Scope string `json:"scope,omitempty"`
// AuthenticatedBy is the email/id of the operator who completed
// the browser flow. Empty for never-authorized connections.
AuthenticatedBy string `json:"authenticated_by,omitempty"`
// AuthenticatedAt is when the most recent successful Connect
// completed. Initial-Connect time, not last-refresh time.
AuthenticatedAt time.Time `json:"authenticated_at,omitzero"`
// NeedsReauth is true when no access token can be minted without
// operator interaction. The admin UI surfaces a Connect button
// when this is true.
NeedsReauth bool `json:"needs_reauth,omitempty"`
// Grant is the OAuth flow this connection uses
// (`authorization_code` or `client_credentials`). Surfaced so the
// admin UI can adapt prompts (the Connect button only applies to
// authorization_code; client_credentials has no human-in-the-loop
// step). Empty for non-OAuth connections.
Grant string `json:"grant,omitempty"`
// LastRevocation, when present, describes the most recent IdP-
// driven revocation (refresh_failed_revoked /
// token_deleted_revoked) for the connection. Populated by the
// admin status handler from the authevents history; never set by
// the Source's own Status() because the Source doesn't know
// about the events table. The UI uses it to render the "revoked"
// state of the OAuth status card (distinct from "never
// connected") so an operator immediately sees that a Connect is
// needed because the IdP killed the chain, not because the
// connection was never set up.
LastRevocation *RevocationEvent `json:"last_revocation,omitempty"`
}
OAuthStatus is the snapshot exposed via the admin status endpoint. All fields are safe to expose to operators (no secret material). Mirrors the union of the two prior per-kind status structs so the frontend status card renders identically for any kind.
type PersistedToken ¶
type PersistedToken struct {
Key Key
AccessToken string
RefreshToken string
ExpiresAt time.Time
RefreshExpiresAt time.Time
Scope string
AuthenticatedBy string
AuthenticatedAt time.Time
UpdatedAt time.Time
}
PersistedToken is the row shape stored in connection_oauth_tokens. Tokens are stored encrypted at rest by the platform's FieldEncryptor; this struct carries plaintext values across the Store API boundary.
RefreshExpiresAt is optional — populated only when the IdP returned refresh_expires_in (Keycloak does; many others do not). Zero means the row column is NULL; callers MUST NOT interpret zero as "never expires" — it means the IdP did not disclose a deadline.
type PostgresLocker ¶ added in v1.61.0
type PostgresLocker struct {
// contains filtered or unexported fields
}
PostgresLocker wraps a *sql.DB with pg_try_advisory_lock semantics. The lock is held in the session associated with the *sql.Conn the locker checks out from the pool; release() closes the connection back to the pool which Postgres treats as a session end (and auto-releases the lock).
func NewPostgresLocker ¶ added in v1.61.0
func NewPostgresLocker(db *sql.DB) *PostgresLocker
NewPostgresLocker wraps db with advisory-lock helpers. Multi-replica deployments pass this; single-replica or no-DB deployments pass NoopLocker{}.
func (*PostgresLocker) TryLock ¶ added in v1.61.0
TryLock acquires a session-scoped advisory lock keyed by hashtext('connoauth-refresh:<kind>/<name>'). The hash uses FNV here (Postgres-equivalent stability is not required because every lock key is held by the same process for at most one tick) and converts to a bigint for pg_try_advisory_lock.
type PostgresStore ¶
type PostgresStore struct {
// contains filtered or unexported fields
}
PostgresStore is the SQL-backed Store against connection_oauth_tokens (migration 000039). Replaces the two per-kind stores from earlier (gateway_oauth_tokens + apigateway_oauth_tokens) — both old kinds now live in this single table keyed by (connection_kind, connection_name).
func NewPostgresStore ¶
func NewPostgresStore(db *sql.DB, enc FieldEncryptor) *PostgresStore
NewPostgresStore wires a Store to the supplied database. Pass enc=nil to disable at-rest encryption (refresh tokens stored unencrypted — dev-only). The platform's startup code passes its FieldEncryptor when ENCRYPTION_KEY is set and logs a warning when it isn't.
func (*PostgresStore) Delete ¶
func (s *PostgresStore) Delete(ctx context.Context, key Key) error
Delete removes the row for key. Idempotent — missing rows do not produce an error. Used by Source on revoked-refresh cleanup and by the admin reacquire path.
func (*PostgresStore) Get ¶
func (s *PostgresStore) Get(ctx context.Context, key Key) (*PersistedToken, error)
Get reads the row for key, decrypting access/refresh tokens via the configured FieldEncryptor.
func (*PostgresStore) List ¶ added in v1.61.0
func (s *PostgresStore) List(ctx context.Context) ([]PersistedToken, error)
List returns metadata for every row in connection_oauth_tokens. Access/refresh tokens are NOT returned — the refresher uses this to find rows that need refresh; the per-row Get loads the secret material when it actually refreshes. Avoiding the decrypt path on the listing query keeps the refresher cheap (no per-row encryption round-trip just to enumerate).
func (*PostgresStore) Lock ¶ added in v1.61.9
func (s *PostgresStore) Lock(ctx context.Context, key Key) (func(), error)
Lock acquires a Postgres session-scoped advisory lock for key. Held across processes: two replicas calling Lock for the same key serialize at the database level. The lock is auto-released if the holding session disconnects, so a crashed holder cannot deadlock the row. The returned release function MUST be deferred; it sends an explicit pg_advisory_unlock and returns the dedicated connection to the pool. It is idempotent and safe to call after the request context has been canceled (the unlock uses a background context so cancellation does not strand the lock).
Lock ID derivation: a 64-bit FNV-1a hash of "connoauth:" + kind + "/" + name. The "connoauth:" namespace prefix prevents collision with any other code that might also use pg_advisory_lock in the same database. Hash collisions across distinct (kind, name) pairs are mathematically possible but vanishingly rare for any realistic connection count; a collision would only cause unnecessary serialization between two unrelated connections, never incorrect behavior.
func (*PostgresStore) Set ¶
func (s *PostgresStore) Set(ctx context.Context, t PersistedToken) error
Set upserts the row for t.Key, encrypting access and refresh tokens before they reach the database. Empty token strings are stored as SQL NULL via encryptOptional rather than encrypted empty-strings — this matches the prior per-kind stores so the migration backfill preserves shape.
type Refresher ¶ added in v1.61.0
type Refresher struct {
// contains filtered or unexported fields
}
Refresher proactively refreshes OAuth tokens before they expire so connections survive arbitrary periods of inactivity (specialist- admin scenario: one operator connects, no one touches the connection for hours, day-to-day operators still see it working).
The loop is correct without an AdvisoryLocker (multiple replicas will harmlessly race, occasionally producing redundant refreshes); passing a real locker is a cost optimization, not a correctness requirement.
func NewRefresher ¶ added in v1.61.0
func NewRefresher(store Store, configs ConfigResolver, events *authevents.Writer, locker AdvisoryLocker, cfg RefresherConfig, ) *Refresher
NewRefresher constructs a Refresher. Pass NoopLocker{} for single- replica deployments. events may be nil (no-op writes).
type RefresherConfig ¶ added in v1.61.0
type RefresherConfig struct {
// Interval is how often the loop wakes up. Default 5 minutes.
// MUST be smaller than the smallest IdP idle window in scope.
Interval time.Duration
// AccessLeadTime is the duration before an access-token's
// disclosed expiry at which the refresher will refresh. Default
// 5 minutes.
AccessLeadTime time.Duration
// RefreshLeadTime is the duration before a refresh-token's
// disclosed or synthetic expiry at which the refresher will
// refresh. Default 1 hour.
RefreshLeadTime time.Duration
}
RefresherConfig governs the refresher's cadence and lead times. Zero values fall back to the defaults declared above.
type RevocationEvent ¶ added in v1.61.0
type RevocationEvent struct {
// OccurredAt is when the IdP rejected the refresh (or the local
// pre-check found no refresh token / expired refresh).
OccurredAt time.Time `json:"occurred_at"`
// Reason is the machine-readable revocation reason from the
// event detail (`invalid_grant`, `no_refresh_token`,
// `refresh_expired`, or the generic `revoked` fallback).
Reason string `json:"reason,omitempty"`
// IDPHost is the host portion of the IdP token URL the
// revocation came from. Lets the portal show "rejected by
// idp.example.com at 10:42" at a glance.
IDPHost string `json:"idp_host,omitempty"`
}
RevocationEvent is the trimmed-down audit-event view the OAuth status card needs. Reads cleanly through JSON encoding to the portal SPA without exposing internal authevents.Type strings or raw detail payloads.
type Source ¶
type Source struct {
// contains filtered or unexported fields
}
Source is the per-connection access-token getter. Toolkits call Token(ctx) on every outbound request; the Source reads the persisted row, returns the cached access_token when still valid, or refreshes it (and persists the result) when expired.
Source is safe for concurrent use; the underlying Store is the source of truth and Set/Get serialize through the database (or the MemoryStore mutex). The Source itself is stateless across calls — every Token() round-trips the store, which is what makes multi- replica deployments correct (replica A's refresh becomes visible to replica B on the next call without inter-replica coordination).
func NewSource ¶
NewSource builds a Source for the (key, cfg) pair. The store is the persistence backend (Postgres in production, Memory in tests). Callers reuse a single Source per connection — construction is cheap, but pooling avoids re-creating the http.Client per request.
func (*Source) Reacquire ¶
Reacquire forces a refresh-token exchange even when the cached token is still valid. Used by the admin "Reacquire" button to test the refresh path on demand. authorization_code grants cannot re-run the full browser flow without operator interaction. That path is the regular Connect button instead.
Holds the same distributed refresh lock as Token so a manual reacquire cannot race the background refresher (or another replica) into a rotation conflict.
func (*Source) Status ¶
func (s *Source) Status(ctx context.Context) OAuthStatus
Status returns a snapshot of the current persisted state, for the admin status endpoint. Reads the row from the store; transient store errors are surfaced via LastError so operators see "DB unreachable" rather than a misleading "Connect needed" prompt.
Status does NOT trigger a refresh — the admin UI calls Status every few seconds (or on demand) and a refresh-per-status-call would generate IdP-side load with no benefit. Refresh happens lazily on the next Token() call.
func (*Source) Token ¶
Token returns a non-expired access token, refreshing transparently when the cached one has expired (or is within expiryBuffer of expiry). On unrecoverable failure (no refresh token, refresh rejected by IdP, refresh deadline passed), returns ErrNeedsReauth so callers can short-circuit retries and surface the Connect prompt to operators. Transient transport failures surface as the underlying error (sanitized) so the caller's retry path can run.
func (*Source) WithActor ¶ added in v1.61.0
WithActor sets the audit-event actor for events this Source emits. Used by the background refresher to record events as SystemBackgroundRefresh; the default (SystemToolCall) is correct for callers that hand the Source out per outbound request.
func (*Source) WithEvents ¶ added in v1.61.0
func (s *Source) WithEvents(w *authevents.Writer) *Source
WithEvents wires the audit-event writer. Returns s so callers can chain construction inline. nil is accepted (events become a no-op).
type Store ¶
type Store interface {
// Get returns the persisted token for the key or ErrTokenNotFound
// when no row exists.
Get(ctx context.Context, key Key) (*PersistedToken, error)
// Set inserts or replaces the token row for the key.
Set(ctx context.Context, t PersistedToken) error
// Delete removes the token row, forcing a re-auth on the next
// call. Returns nil (not ErrTokenNotFound) for missing rows so
// idempotent cleanup callers don't need to special-case absence.
Delete(ctx context.Context, key Key) error
// List returns metadata for every persisted row. Used by the
// background refresher to decide which connections need
// proactive refresh. AccessToken and RefreshToken are NOT
// populated in the returned slice. The refresher only needs
// deadlines, kind, and name to pick targets; the per-row Get
// loads the secret material when it actually refreshes.
List(ctx context.Context) ([]PersistedToken, error)
// Lock acquires an exclusive lock for the key. The lock is held
// across processes (Postgres advisory lock for the SQL store, a
// per-key mutex for the in-memory store) so two refresh attempts
// for the same key serialize regardless of which replica or
// goroutine started them. The caller MUST defer the returned
// release function. The returned function is idempotent and
// safe to call after the request context has been canceled.
//
// Lock is the coordination primitive that prevents the rotation
// race: against any IdP that enforces one-time-use refresh-token
// rotation (RFC 6749 §6), two concurrent refreshes posting the
// same refresh_token cause the loser to receive invalid_grant
// for a token the winner already consumed. Without serialization
// across replicas, that loser's response is classified as a
// revoked credential and the persisted row is deleted, taking
// the connection out of service. The acquired lock is released
// either by the returned function or by the backing connection
// closing (Postgres advisory locks are session-scoped, so a
// crashed holder cannot deadlock the row).
Lock(ctx context.Context, key Key) (func(), error)
}
Store persists OAuth tokens for the authorization_code grant so a one-time browser-based authentication grants long-running background access. Keyed by (kind, name) so the same backing table serves multiple connection-toolkit families without collision.