oauth

package
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Jun 5, 2026 License: AGPL-3.0 Imports: 16 Imported by: 0

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

View Source
const CodeChallengeMethodS256 = "S256"

CodeChallengeMethodS256 is the only PKCE challenge method we accept. RFC 7636 §4.2; spec-mandated for OAuth 2.1.

View Source
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.

View Source
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.

View Source
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

View Source
var (
	ErrInvalidRequest       = errors.New("invalid_request")
	ErrInvalidGrant         = errors.New("invalid_grant")
	ErrUnauthorizedClient   = errors.New("unauthorized_client")
	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

func VerifyS256(verifier, challenge string) bool

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

type AccessContext struct {
	UserID    string
	ProjectID string
	ClientID  string
	Scope     string
}

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

type Client struct {
	ID           string
	Name         string
	RedirectURIs []string
}

Client mirrors db.OauthClient at the application boundary so handlers don't import the sqlc package for this single struct.

func (Client) RedirectURIAllowed

func (c Client) RedirectURIAllowed(uri string) bool

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

type ConsumedCode struct {
	ClientID  string
	UserID    string
	ProjectID string
	Scope     string
}

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

type IssueAccessTokenParams struct {
	ClientID  string
	UserID    string
	ProjectID string
	Scope     string
}

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

type RegisterParams struct {
	Name         string
	RedirectURIs []string
}

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

func NewService(pool *pgxpool.Pool) *Service

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

func (s *Service) IssueCode(ctx context.Context, p IssueCodeParams) (plaintext string, err error)

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

func (s *Service) LookupClient(ctx context.Context, id string) (Client, error)

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

func (s *Service) MarkAccessTokenUsed(ctx context.Context, id string) error

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

func (s *Service) Now() time.Time

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

func (s *Service) RegisterClient(ctx context.Context, p RegisterParams) (Client, error)

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

func (*Service) SetNow

func (s *Service) SetNow(fn func() time.Time)

SetNow swaps the Service's clock. Test-only.

Jump to

Keyboard shortcuts

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