Documentation
¶
Overview ¶
Package oauth wires the e2a authorization server using ory/fosite as the protocol layer.
We delegate to fosite for all of the RFC-correctness corners: PKCE-S256 enforcement, redirect_uri matching (including RFC 8252 §7.3 loopback ports), authorization-code single-use semantics with RFC 6749 §10.5 reuse defense, refresh-token rotation with §10.4 chain revocation, RFC 9207 issuer identifier in authorization responses, RFC 6749 §5.2 error-shape (ASCII descriptions, JSON envelope, Cache-Control: no-store on /token), and so on. We keep the surface that is genuinely deployment-specific:
- Dynamic Client Registration (RFC 7591) — fosite ships a stub via compose.OAuth2ClientCredentialsGrantFactory but not the registration endpoint itself; we implement it.
- The consent UI handler — fosite hands us an fosite.AuthorizeRequester, we decide based on the user's session + the consent form, and either issue a code (with fosite.WriteAuthorizeResponse) or reject.
- Auto-create-agent inside the consent flow, with the agent row creation and the authorization code insert sharing one pgx transaction so a partial failure doesn't leak phantom agents.
- Discovery (RFC 8414) — we hand-roll the document because the values are all deployment-static and fosite's helper inverts the dependency we want.
The intended call graph at request time looks like:
incoming request → agent handler → oauth.Provider methods
→ fosite → Storage (this pkg)
→ pgxpool → Postgres
Storage implements the fosite-defined interfaces (OAuth2Storage, PKCERequestStorage, ClientManager, TokenRevocationStorage). The e2a-specific bits (agent_email binding on a session, slug auto- create) live in the per-endpoint handlers under internal/agent/ and reach into the same Storage when they need to.
Index ¶
- Constants
- func NewProvider(storage *Storage, issuerURL string, hmacSecret []byte) (fosite.OAuth2Provider, error)
- func TxFromContext(ctx context.Context) (pgx.Tx, bool)
- func WithTx(ctx context.Context, tx pgx.Tx) context.Context
- type Client
- func (c *Client) GetAudience() fosite.Arguments
- func (c *Client) GetGrantTypes() fosite.Arguments
- func (c *Client) GetHashedSecret() []byte
- func (c *Client) GetID() string
- func (c *Client) GetRedirectURIs() []string
- func (c *Client) GetResponseTypes() fosite.Arguments
- func (c *Client) GetScopes() fosite.Arguments
- func (c *Client) GetTokenEndpointAuthMethod() string
- func (c *Client) IsPublic() bool
- type ConnectionEntry
- type RetentionResult
- type Session
- type Storage
- func (s *Storage) BeginTX(ctx context.Context) (context.Context, error)
- func (s *Storage) CleanupExpired(ctx context.Context, now time.Time) (RetentionResult, error)
- func (s *Storage) ClientAssertionJWTValid(ctx context.Context, jti string) error
- func (s *Storage) Commit(ctx context.Context) error
- func (s *Storage) CountUserOAuthRows(ctx context.Context, userID string) (UserRowCounts, error)
- func (s *Storage) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) error
- func (s *Storage) CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) error
- func (s *Storage) CreatePKCERequestSession(ctx context.Context, signature string, request fosite.Requester) error
- func (s *Storage) CreateRefreshTokenSession(ctx context.Context, signature, accessSignature string, ...) error
- func (s *Storage) DeleteAccessTokenSession(ctx context.Context, signature string) error
- func (s *Storage) DeletePKCERequestSession(ctx context.Context, signature string) error
- func (s *Storage) DeleteRefreshTokenSession(ctx context.Context, signature string) error
- func (s *Storage) ExportConnectionsForUser(ctx context.Context, userID string) ([]ConnectionEntry, error)
- func (s *Storage) GetAccessTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error)
- func (s *Storage) GetAuthorizeCodeSession(ctx context.Context, code string, session fosite.Session) (fosite.Requester, error)
- func (s *Storage) GetClient(ctx context.Context, id string) (fosite.Client, error)
- func (s *Storage) GetPKCERequestSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error)
- func (s *Storage) GetRefreshTokenSession(ctx context.Context, signature string, session fosite.Session) (fosite.Requester, error)
- func (s *Storage) InvalidateAuthorizeCodeSession(ctx context.Context, code string) error
- func (s *Storage) Pool() *pgxpool.Pool
- func (s *Storage) RevokeAccessToken(ctx context.Context, requestID string) error
- func (s *Storage) RevokeRefreshToken(ctx context.Context, requestID string) error
- func (s *Storage) Rollback(ctx context.Context) error
- func (s *Storage) RotateRefreshToken(ctx context.Context, requestID, refreshTokenSignature string) error
- func (s *Storage) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error
- type UserRowCounts
Constants ¶
const ( ClientIDPrefix = "mcp_" AuthCodePrefix = "oace_" AccessTokenPrefix = "ate2a_" RefreshTokenPrefix = "rte2a_" )
Token-prefix constants. fosite by default doesn't prefix the strings it issues; we wrap its strategy with one that prepends these so the bearer-dispatch in authenticateUser can route by prefix (ate2a_/rte2a_ → fosite, e2a_ → API key path).
const ( AccessTokenLifespan = time.Hour RefreshTokenLifespan = 30 * 24 * time.Hour AuthorizeCodeLifespan = time.Minute )
Token / code lifetimes. Exported as constants so handler code that needs to write them onto the session before persistence can stay in lockstep with fosite's strategy expectations.
Variables ¶
This section is empty.
Functions ¶
func NewProvider ¶
func NewProvider(storage *Storage, issuerURL string, hmacSecret []byte) (fosite.OAuth2Provider, error)
NewProvider constructs the fosite.OAuth2Provider that backs every OAuth endpoint in this server. Wires:
- the e2a-prefixed HMAC strategy (ate2a_ / rte2a_ / oace_ tokens)
- the four grant handlers we use (auth code, refresh, PKCE, revoke)
- lifespans matching the legacy hand-rolled backend
- PKCE-S256 mandatory (no plain), public clients only at /token
hmacSecret is the *master* signing key (typically cfg.Signing.HMACSecret) — same one X-E2A-Auth-* email headers and HITL magic links sign with. We never hand fosite the master directly; deriveOAuthSigningKey produces a per-purpose 32-byte subkey via HKDF-SHA256, so a future rotation of the OAuth signing material (bump the label suffix to v2) doesn't churn the other signing domains and vice versa. Returns an error rather than panicking if the master is shorter than 32 bytes — operators get a loud failure at startup instead of a confusing fosite panic on the first /token request.
issuerURL is the canonical issuer used in:
- the discovery doc
- the RFC 9207 `iss` parameter on authorize responses
- future JWT `iss` claims if we ever switch from opaque to JWT
Must match what clients fetch via /.well-known. Operators MUST set http.public_url; we read it once at startup, no per-request override.
func TxFromContext ¶
TxFromContext returns the pgx.Tx stashed by WithTx, if any. The second return is false when no tx is present (the pool will be used instead). Callers that need to share a transaction across package boundaries can use this to detect and join a parent tx.
func WithTx ¶
WithTx returns a derived context that routes oauth Storage method reads/writes through the given pgx.Tx instead of the pool. The consent handler is the motivating caller: it opens a transaction on Storage.Pool(), inserts an agent row via the agent package (which threads the same tx), then hands the tx-carrying context to fosite (which calls back into Storage methods). Storage.db(ctx) reads the same key, so all writes land in the one transaction.
Storage.BeginTX is the inverse path: when fosite itself opens the tx, it uses BeginTX which delegates to WithTx — one canonical key regardless of who started the transaction.
Types ¶
type Client ¶
type Client struct {
ID string
Name string
RedirectURIs []string
GrantTypeStrings []string
ResponseTypeStrings []string
ScopeStrings []string
AudienceStrings []string
Public bool
SecretHash []byte
TokenEndpointAuthMethodS string
CreatedByUserID string
}
Client is our concrete fosite.Client implementation. It carries the metadata persisted in oauth_clients plus an e2a-specific CreatedByUserID field used by the DCR-attribution flow.
fosite.Client is an interface; the per-method getters below are what fosite reads when validating an authorization request, a token exchange, or a revocation. We deliberately don't expose any setters outside this package — once a client is loaded from the DB, fosite treats it as immutable for the duration of the request.
func (*Client) GetAudience ¶
func (*Client) GetGrantTypes ¶
func (*Client) GetHashedSecret ¶
func (*Client) GetRedirectURIs ¶
func (*Client) GetResponseTypes ¶
func (*Client) GetTokenEndpointAuthMethod ¶
GetTokenEndpointAuthMethod returns the registered auth method. fosite reads this opportunistically via a runtime type-assert on the AuthMethodClient interface; we keep it because the value is meaningful (we only accept "none" for public clients in DCR).
type ConnectionEntry ¶
type ConnectionEntry struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
AgentEmail string `json:"agent_email"`
Scope string `json:"scope"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
ConnectionEntry is one row of the user-export "OAuth connections" section. Represents a single MCP/native client the user has linked to one of their agents. The signature columns themselves stay internal — they are credential-equivalent (a leaked signature lets an attacker reconstruct the bearer through fosite's strategy).
type RetentionResult ¶
type RetentionResult struct {
AuthCodesDeleted int64
AccessTokensDeleted int64
RefreshTokensDeleted int64
PKCERequestsDeleted int64
ClientsDeleted int64
}
RetentionResult is the per-table row count from a single retention pass. Surfaced for the operator log line so volume changes are visible without parsing the SQL.
func (RetentionResult) Total ¶
func (r RetentionResult) Total() int64
Total reports the sum across tables. Useful for the "nothing to do" branch in the cleanup loop where the operator log is suppressed.
type Session ¶
type Session struct {
UserID string `json:"user_id"`
AgentEmail string `json:"agent_email,omitempty"`
Subject string `json:"subject"`
Username string `json:"username,omitempty"`
ExpiresAtMap map[fosite.TokenType]time.Time `json:"expires_at,omitempty"`
}
Session is the e2a-specific data we attach to a fosite Requester for the lifetime of an authorization. fosite serializes this along with the rest of the Requester onto each oauth_* row; on lookup we hydrate it back via the caller-provided pointer.
The two fields that matter beyond fosite's defaults:
- UserID: the e2a user_id this grant belongs to. fosite would work with just the Subject string, but we want a typed handle for the per-user revocation paths.
- AgentEmail: the inbox the OAuth client is connected to. Tool calls can override per-call; this is the default. fosite doesn't know about agents at all — this rides as session extension data.
func (*Session) Clone ¶
Clone returns a deep-enough copy that fosite handlers can mutate the returned session without affecting the original. Required by the fosite.Session interface contract — fosite calls Clone() before handing a session to a handler that may extend it.
func (*Session) GetSubject ¶
func (*Session) GetUsername ¶
type Storage ¶
type Storage struct {
// contains filtered or unexported fields
}
Storage adapts our Postgres pool to the fosite-defined storage interfaces. Methods land in subsequent slices; this skeleton is here so other packages can take a *Storage handle without circular dependency churn later.
func NewStorage ¶
NewStorage returns a Storage bound to the given pool. Caller is responsible for the pool's lifecycle; this struct doesn't own it.
func (*Storage) BeginTX ¶
BeginTX starts a new pgx transaction and returns a context carrying it. fosite's handlers pass this context to every subsequent storage call until Commit or Rollback. Uses WithTx so the key is the same one cross-package callers (e.g. the consent handler) use to thread their own transactions through.
func (*Storage) CleanupExpired ¶
CleanupExpired removes rows that no longer affect any live grant:
- oauth_auth_codes whose expires_at is in the past — codes are single-use 60s lifetime; an expired row will never be redeemed.
- oauth_pkce_requests past expires_at — paired with the auth code by request_id; on its own lifecycle but the same idea.
- oauth_access_tokens that are revoked OR expired AND old enough that no operator would still be looking at them (24h grace — access tokens are cheap to lose and the audit trail lives on the refresh chain anyway).
- oauth_refresh_tokens that have been dead for more than the refresh grace (30d). "Dead" = expires_at in the past OR revoked_at set. The same 30d cutoff applies to both branches — an expired-but-never-revoked token lingers exactly as long as a revoked one. Operators looking at "why did this connection stop working last week" have 30d to inspect.
- oauth_clients created via DCR (anonymous), older than 90d, with no remaining access/refresh tokens. RFC 7591 §4 explicitly allows expiring open-registration clients; without this an attacker could fill the table by registering through a /64 IPv6 prefix over time. Operator-curated clients (created_via != 'dcr') are never touched.
Non-revoked, non-expired (or never-expires NULL expires_at) refresh tokens are left alone regardless of age — operators who set RefreshTokenLifespan=-1 depend on this.
Idempotent and safe to run concurrently with normal traffic: every DELETE is filtered by absolute timestamps, so two passes from two workers just race to delete the same already-stale rows.
func (*Storage) ClientAssertionJWTValid ¶
ClientAssertionJWTValid is for the JWT-Bearer client authentication method (RFC 7523). We don't support that auth method — public clients only, no JWT assertions. Returning a non-nil error here is defense in depth: if a future compose misconfiguration ever enables the JWT-Bearer factory, this storage call will refuse rather than silently accept every JTI as valid. The /token endpoint will still reject the auth method earlier; this is the second line.
func (*Storage) CountUserOAuthRows ¶
func (*Storage) CreateAccessTokenSession ¶
func (*Storage) CreateAuthorizeCodeSession ¶
func (*Storage) CreatePKCERequestSession ¶
func (*Storage) CreateRefreshTokenSession ¶
func (*Storage) DeleteAccessTokenSession ¶
func (*Storage) DeletePKCERequestSession ¶
func (*Storage) DeleteRefreshTokenSession ¶
func (*Storage) ExportConnectionsForUser ¶
func (s *Storage) ExportConnectionsForUser(ctx context.Context, userID string) ([]ConnectionEntry, error)
ExportConnectionsForUser returns one row per CONSENT — the unit of "I authorized this client to use this agent" from the user's perspective. Refresh tokens rotate (potentially daily) and within the 30-day grace each rotation leaves a separate row; a row-per- refresh export would dump dozens of duplicate entries for one consent act, which is misleading for a GDPR Art. 15 deliverable (EDPB Guidelines 01/2022 §82-86: data must be in an "intelligible form"). We aggregate by (client_id, agent_email) — the natural grouping the user reasoned about at the consent screen.
Per group we report:
- IssuedAt: the earliest rotation's created_at — when the user first authorized this client+agent pair
- ExpiresAt: the latest rotation's expires_at — when access goes away absent another refresh
- RevokedAt: latest revoked_at if EVERY row in the group is revoked, else NULL — a half-revoked group is still effectively active because at least one refresh row can still hand out access tokens
The session JSONB carries the agent_email we pinned at consent time, so we extract it via a JSONB path expression rather than rejoining the agent table. The JSON key is the lowercase struct tag (`agent_email`) — Session is encoded via json.Marshal at storage.go::marshalRequest, so the field name on disk follows the `json:"agent_email"` tag, not the Go field name. Reading the uppercase form (the original mistake) returned an empty string for every real row.
func (*Storage) GetAccessTokenSession ¶
func (*Storage) GetAuthorizeCodeSession ¶
func (*Storage) GetClient ¶
GetClient loads the client by ID. Returns fosite.ErrInvalidClient when the row doesn't exist so fosite's error mapping can produce the right RFC 6749 §5.2 error code at the endpoint.
func (*Storage) GetPKCERequestSession ¶
func (*Storage) GetRefreshTokenSession ¶
func (*Storage) InvalidateAuthorizeCodeSession ¶
InvalidateAuthorizeCodeSession marks the code consumed via a CAS `WHERE signature = $1 AND active = TRUE`. The CAS is load-bearing: without it, two concurrent token exchanges at READ COMMITTED can each pass GetAuthorizeCodeSession (both see active=TRUE before either UPDATE lands) and both InvalidateAuthorizeCodeSession calls succeed against the same row — letting fosite issue two token pairs for one consent (RFC 6749 §10.5 violation). Adding active=TRUE to the WHERE makes the second UPDATE re-evaluate the predicate after the lock releases and return zero rows. Returning ErrInvalidatedAuthorizeCode triggers fosite's deferred rollback in flow_authorize_code_token.go.
func (*Storage) Pool ¶
Pool exposes the underlying pgxpool. Reserved for callers that need the raw pool — for example, opening a transaction that this package then participates in via WithTx. Prefer WithTx over direct pool use: it's the supported atomicity pattern across the oauth/agent boundary.
func (*Storage) RevokeAccessToken ¶
func (*Storage) RevokeRefreshToken ¶
func (*Storage) RotateRefreshToken ¶
func (s *Storage) RotateRefreshToken(ctx context.Context, requestID, refreshTokenSignature string) error
RotateRefreshToken marks the refresh row inactive (via CAS) and ALSO revokes every access token for the same request_id. Without the cascade, an attacker who captured the pre-refresh access token could continue using it for up to its remaining lifetime (~1h) after the legitimate client rotated. The convention is set by fosite's reference in-memory store; we match it.
The CAS on the refresh row (`AND active = TRUE`) defends against concurrent refresh exchanges: same shape as InvalidateAuthorizeCodeSession. Returning ErrInactiveToken when the predicate doesn't match drives fosite into the handleRefreshTokenReuse path (flow_refresh.go:178), which rolls the in-progress exchange back.
Runs through db(ctx) so when fosite wraps this call in MaybeBeginTx the two UPDATEs land in the same transaction.
type UserRowCounts ¶
CountUserOAuthRows returns the per-table row counts for a user. Used by DeleteUserData so operators can attest to what CASCADE removed when a user is wiped. Cheaper than a post-delete diff because we can run it inside the same SERIALIZABLE transaction.