Documentation
¶
Overview ¶
Package oauth implements a hand-rolled OAuth 2.1 authorization server for the analytics API + MCP surfaces. PKCE-only authorization-code flow, opaque tokens, dynamic client registration (RFC 7591), authorization-server metadata discovery (RFC 8414). No refresh tokens; clients re-authorize on expiry.
Tokens are persisted hash-only via auth.HashToken (sha256 hex), reusing the existing token-hashing primitive. Authorization codes are one-shot enforced in a transaction so a parallel /oauth/token call can never double-spend.
Lifecycle:
register → authorize (consent + project pick) → token → bearer-auth client_id ← code ←────────────────────────── access_token (1h)
The package itself is request-agnostic — HTTP wiring lives in internal/web/oauth_handlers.go and oauth_middleware.go.
Index ¶
- Constants
- Variables
- func ContextWith(ctx context.Context, ac *AccessContext) context.Context
- func VerifyS256(verifier, challenge string) bool
- type AccessContext
- type AccessToken
- type Client
- type ConsumeCodeParams
- type ConsumedCode
- type IssueAccessTokenParams
- type IssueCodeParams
- type RegisterParams
- type Service
- func (s *Service) ConsumeCode(ctx context.Context, p ConsumeCodeParams) (ConsumedCode, error)
- func (s *Service) IssueAccessToken(ctx context.Context, p IssueAccessTokenParams) (plaintext string, expiresAt time.Time, err error)
- func (s *Service) IssueCode(ctx context.Context, p IssueCodeParams) (plaintext string, err error)
- func (s *Service) LookupActiveAccessToken(ctx context.Context, plaintext string) (*AccessToken, error)
- func (s *Service) LookupClient(ctx context.Context, id string) (Client, error)
- func (s *Service) MarkAccessTokenUsed(ctx context.Context, id string) error
- func (s *Service) Now() time.Time
- func (s *Service) RegisterClient(ctx context.Context, p RegisterParams) (Client, error)
- func (s *Service) SetNow(fn func() time.Time)
Constants ¶
const CodeChallengeMethodS256 = "S256"
CodeChallengeMethodS256 is the only PKCE challenge method we accept. RFC 7636 §4.2; spec-mandated for OAuth 2.1.
const DefaultAccessTokenTTL = 1 * time.Hour
DefaultAccessTokenTTL is the access-token lifetime when none is configured. Production callers set Service.AccessTokenTTL from env; tests use the default unless they need an expiry-edge scenario.
const DefaultAuthorizationCodeTTL = 10 * time.Minute
DefaultAuthorizationCodeTTL is the authorization-code lifetime. 10 minutes matches MCP-spec guidance — long enough to survive a slow user-agent roundtrip, short enough to bound exposure.
const ScopeAPI = "api"
ScopeAPI is the only scope on day one — covers /v1/* + /mcp. Clients pass it explicitly in /oauth/authorize; the server validates it on the way in.
Variables ¶
var ( ErrInvalidRequest = errors.New("invalid_request") ErrInvalidGrant = errors.New("invalid_grant") ErrUnsupportedGrantType = errors.New("unsupported_grant_type") ErrAccessDenied = errors.New("access_denied") ErrInvalidScope = errors.New("invalid_scope") ErrServerError = errors.New("server_error") )
Typed errors mapped 1:1 to RFC 6749 §5.2 error codes by the HTTP layer. Keep this list aligned with the strings in oauth_handlers.go.
Functions ¶
func ContextWith ¶
func ContextWith(ctx context.Context, ac *AccessContext) context.Context
ContextWith returns ctx augmented with ac. RequireBearer calls it after a successful token lookup.
func VerifyS256 ¶
VerifyS256 reports whether base64url(sha256(verifier)) == challenge using a constant-time comparison on the digest. RFC 7636 §4.6.
verifier and challenge are both base64url-encoded RFC 7636 strings (no padding). An empty verifier or challenge is rejected — there's no legitimate case where either is empty when this function is reached.
Types ¶
type AccessContext ¶
AccessContext is the bag of identity claims attached to authenticated requests via RequireBearer. Handlers downstream of the middleware can pull it back out of ctx via FromContext.
func FromContext ¶
func FromContext(ctx context.Context) *AccessContext
FromContext returns the access context attached by RequireBearer, or nil if the request never carried a valid bearer token.
type AccessToken ¶
type AccessToken struct {
ID string
ClientID string
UserID string
ProjectID string
Scope string
ExpiresAt time.Time
}
AccessToken is the application-layer view of an oauth_access_tokens row. ID is the row's UUID — RequireBearer hands it to MarkAccessTokenUsed for the throttled last_used_at stamp.
type Client ¶
Client mirrors db.OauthClient at the application boundary so handlers don't import the sqlc package for this single struct.
func (Client) RedirectURIAllowed ¶
RedirectURIAllowed reports whether uri exactly matches one of the client's registered redirect URIs.
type ConsumeCodeParams ¶
type ConsumeCodeParams struct {
Code string
ClientID string
RedirectURI string
CodeVerifier string
}
ConsumeCodeParams is what /oauth/token submits.
type ConsumedCode ¶
ConsumedCode is what ConsumeCode returns to the token handler so it can issue the access token bound to the same (user, project, scope).
type IssueAccessTokenParams ¶
IssueAccessTokenParams is the bag the token handler hands here after a successful code consume.
type IssueCodeParams ¶
type IssueCodeParams struct {
ClientID string
UserID string
ProjectID string
RedirectURI string
Scope string
CodeChallenge string
CodeChallengeMethod string
}
IssueCodeParams is the bag the consent handler hands to IssueCode after the user approves on /oauth/authorize.
type RegisterParams ¶
RegisterParams is the validated payload for dynamic client registration (RFC 7591). Name is optional (empty string → "unnamed client"); redirect URIs are required.
type Service ¶
type Service struct {
AccessTokenTTL time.Duration
AuthorizationCodeTTL time.Duration
// contains filtered or unexported fields
}
Service is the package's stateful entrypoint. It wraps the sqlc queries handle and exposes Issue/Consume/Lookup operations the HTTP handlers compose. now() is overridable for deterministic expiry tests.
func NewService ¶
NewService builds a Service bound to pool with default TTLs. Callers tweak AccessTokenTTL / AuthorizationCodeTTL after construction to honor env-driven overrides; tests reach for SetNow.
func (*Service) ConsumeCode ¶
func (s *Service) ConsumeCode(ctx context.Context, p ConsumeCodeParams) (ConsumedCode, error)
ConsumeCode validates the bound (client_id, redirect_uri, PKCE) tuple and only then marks the code used. The two-step lookup-then-update lets a failed PKCE / client / redirect check leave the code intact so a legitimate retry (typo'd verifier, retried CLI request) doesn't force the user back through /oauth/authorize.
One-shot is enforced by the UPDATE's `WHERE used_at IS NULL` predicate: a parallel /oauth/token call racing on the same code sees RowsAffected == 0 on the loser, which maps to invalid_grant. No transaction is needed — the UPDATE itself is the atomic boundary.
func (*Service) IssueAccessToken ¶
func (s *Service) IssueAccessToken(ctx context.Context, p IssueAccessTokenParams) (plaintext string, expiresAt time.Time, err error)
IssueAccessToken mints a fresh access token, persists its hash, and returns the plaintext + expiry. The plaintext is shown to the requester once via the token-endpoint response and never persisted.
func (*Service) IssueCode ¶
IssueCode mints a fresh authorization code bound to (client, user, project, redirect_uri, scope, PKCE challenge). Returns the plaintext code (the caller embeds it in the redirect URI). The hash is stored; the plaintext is not.
func (*Service) LookupActiveAccessToken ¶
func (s *Service) LookupActiveAccessToken(ctx context.Context, plaintext string) (*AccessToken, error)
LookupActiveAccessToken resolves a plaintext bearer to its bound identity. Returns nil + nil error when the token is unknown / expired / revoked so the middleware can return a uniform 401 without leaking which.
Pre-DB rejection: anything that starts with PublicTokenPrefix is rejected without a query. Public ingest tokens live in the JS snippet — they must never grant /v1/* + /mcp access, and the partial-unique-hash index would otherwise let a collision attempt churn DB time.
func (*Service) LookupClient ¶
LookupClient returns the client by ID. pgx.ErrNoRows becomes ErrUnauthorizedClient so the handler can map to the right OAuth error code without distinguishing "doesn't exist" from "you can't have it".
func (*Service) MarkAccessTokenUsed ¶
MarkAccessTokenUsed stamps last_used_at on the row identified by id. The underlying UPDATE has a 60s predicate so a hot token only writes the column at most once per minute — this keeps WAL + lock contention bounded once /v1/query and /mcp share the bearer middleware. Called fire-and-forget from RequireBearer on every successful lookup; an error is logged by the caller, never propagated to the request.
func (*Service) Now ¶
Now returns the Service's current time. Production callers get time.Now; tests that swap the clock via SetNow observe their override.
func (*Service) RegisterClient ¶
RegisterClient validates the params and inserts a new public client. The returned Client.ID is the public client_id the caller hands to the requester.
Redirect-URI rules (RFC 8252 §7.3 + MCP guidance):
- HTTPS hosts allowed
- http://localhost / http://127.0.0.1 allowed (loopback redirect)
- everything else rejected (no fragments, no bare HTTP)