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 ¶
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 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 Config ¶
type Config struct {
// 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 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) Set ¶
func (s *MemoryStore) Set(_ context.Context, t PersistedToken) error
Set stores a token in process memory, stamping UpdatedAt.
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"`
}
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 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) 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 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.
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.
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
}
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.