oauth

package
v0.5.2 Latest Latest
Warning

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

Go to latest
Published: May 2, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package oauth implements the minimal OAuth 2.1 authorization server every spec-compliant MCP client (notably Claude.ai web) requires.

Surface — all served from the same wick HTTP origin:

GET  /.well-known/oauth-protected-resource (RFC 9728)
GET  /.well-known/oauth-authorization-server (RFC 8414)
POST /oauth/register                       Dynamic Client Registration (RFC 7591)
GET  /oauth/authorize                      PKCE authorization (RFC 7636)
POST /oauth/token                          code + refresh exchange (RFC 6749 §4.1, §6)

Design choices, deliberately narrow:

  • Tokens are opaque random hex strings, hashed at rest. We do NOT issue JWTs — same authentication strength, no key-management burden, and the validator is a single DB lookup. JWTs can layer in later if/when we need stateless validation across replicas.
  • Public clients only (PKCE mandatory). MCP authorization spec explicitly requires PKCE for all clients, so the simplification costs us nothing.
  • The user-agent dance reuses wick's existing cookie session: at /authorize we check for a logged-in user, redirect to /auth/login if missing, and treat the post-login redirect as implicit consent (an admin-only consent UI is a follow-up; out of scope for the first cut).
  • Refresh tokens rotate on every redemption per OAuth BCP. Reuse of a rotated refresh triggers chain-wide revocation.

Index

Constants

View Source
const (
	AccessTokenTTL  = 1 * time.Hour
	RefreshTokenTTL = 30 * 24 * time.Hour
	AuthCodeTTL     = 5 * time.Minute

	// Token wire prefixes. The bearer middleware inspects these to
	// route to the right validator (PAT vs OAuth).
	AccessTokenPrefix  = "wick_oat_"
	RefreshTokenPrefix = "wick_ort_"
)

Token TTLs. Conservative defaults — short access lifetime keeps blast radius small if a token leaks; long refresh keeps re-auth rare for active clients.

Variables

View Source
var ErrInvalid = errors.New("invalid")

ErrInvalid is the uniform "something is wrong with this request" signal for /token. Mapped to "invalid_grant" or "invalid_token" at the HTTP layer.

Functions

func ValidateRedirectURI

func ValidateRedirectURI(client *entity.OAuthClient, redirectURI string) bool

ValidateRedirectURI reports whether redirectURI is one of the strings the client registered. Strict equality — the OAuth spec allows no fuzzy matching.

Types

type AdminGrant

type AdminGrant struct {
	UserID     string
	ClientID   string
	ClientName string
	GrantedAt  time.Time
	LastUsedAt *time.Time
	TokenCount int
}

AdminGrant is the all-users variant of Grant. UserID is the row's owner; the admin UI joins it against the users table to render owner name/email.

type Grant

type Grant struct {
	ClientID   string
	ClientName string
	GrantedAt  time.Time  // earliest CreatedAt across the active tokens
	LastUsedAt *time.Time // most recent LastUsedAt, nil if never
	TokenCount int        // active access + refresh tokens
}

Grant is the aggregated view of one app the user has currently authorized — collapsing the underlying token rows down to the app metadata + lifetime markers the dashboard cares about.

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler exposes the OAuth surface. All endpoints live at the same HTTP origin as the rest of wick — issuer == app_url. /authorize reads login.GetUser from the request context, so the root-level cookie-session middleware must run before this handler.

func NewHandler

func NewHandler(svc *Service, cfg appConfig) *Handler

func (*Handler) Register

func (h *Handler) Register(mux *http.ServeMux, midd *login.Middleware)

Register wires routes. /authorize and the /profile/connections pages need the cookie session populated (they call login.GetUser); the root mux already wraps everything with Session middleware.

The /profile/connections POST routes pass auth via the supplied middleware so an unauthenticated request gets bounced to /auth/login instead of silently failing.

type IssueAuthCodeParams

type IssueAuthCodeParams struct {
	ClientID            string
	UserID              string
	RedirectURI         string
	Scope               string
	CodeChallenge       string
	CodeChallengeMethod string // "S256" — wick rejects "plain" per OAuth 2.1
}

IssueAuthCodeParams bundles the values /authorize stamps onto a new code row.

type RegisterClientParams

type RegisterClientParams struct {
	ClientName   string   `json:"client_name"`
	RedirectURIs []string `json:"redirect_uris"`
}

RegisterClientParams are the inbound DCR fields wick honors. We ignore the optional metadata (logo_uri, tos_uri, ...) — keeping the happy path tiny.

type Repo

type Repo struct {
	// contains filtered or unexported fields
}

Repo wraps gorm with the queries the OAuth handler + token validator need. Kept narrow on purpose — this package is auth-critical, easier to reason about when the DB surface is small.

func NewRepo

func NewRepo(db *gorm.DB) *Repo

func (*Repo) ConsumeAuthCode

func (r *Repo) ConsumeAuthCode(ctx context.Context, code string) (*entity.OAuthAuthorizationCode, error)

ConsumeAuthCode looks up an unused, unexpired code and marks it used in a single transaction so a replay can't race. Returns the row as it was right before flipping Used.

func (*Repo) CreateAuthCode

func (r *Repo) CreateAuthCode(ctx context.Context, c *entity.OAuthAuthorizationCode) error

func (*Repo) CreateClient

func (r *Repo) CreateClient(ctx context.Context, c *entity.OAuthClient) error

func (*Repo) CreateToken

func (r *Repo) CreateToken(ctx context.Context, t *entity.OAuthToken) error

func (*Repo) FindActiveTokenByHash

func (r *Repo) FindActiveTokenByHash(ctx context.Context, hash string) (*entity.OAuthToken, error)

func (*Repo) FindAnyTokenByHash

func (r *Repo) FindAnyTokenByHash(ctx context.Context, hash string) (*entity.OAuthToken, error)

FindAnyTokenByHash returns the row regardless of revocation/expiry. Used by /token refresh to detect replay of a rotated refresh token (and revoke the chain).

func (*Repo) GetClient

func (r *Repo) GetClient(ctx context.Context, clientID string) (*entity.OAuthClient, error)

func (*Repo) ListAllGrants

func (r *Repo) ListAllGrants(ctx context.Context) ([]AdminGrant, error)

ListAllGrants returns one row per active (user, client) pair across every user — the admin equivalent of ListGrantsByUser. Same active definition (≥1 non-revoked, non-expired token) and the same SQLite/Postgres timestamp parsing dance.

func (*Repo) ListGrantsByUser

func (r *Repo) ListGrantsByUser(ctx context.Context, userID string) ([]Grant, error)

ListGrantsByUser returns one Grant per app the user has currently authorized. "Currently" = at least one non-revoked, non-expired token row of either kind (refresh keeps the grant alive even after the access expires).

Ordered newest-grant first so the dashboard reads chronologically.

Implementation notes: SQLite returns MIN/MAX of TIMESTAMP columns as raw strings (the driver only auto-parses real columns, not aggregate expressions), so we scan those into strings and parse them in Go. parseAggregateTime accepts both SQLite's "YYYY-MM-DD HH:MM:SS[.fff]" and Postgres's RFC3339 — the same code works on either backend without runtime feature detection.

func (*Repo) MarkUsed

func (r *Repo) MarkUsed(ctx context.Context, id string) error

MarkUsed stamps LastUsedAt on a token. Best-effort observability hook the bearer middleware fires after a successful auth.

func (*Repo) Revoke

func (r *Repo) Revoke(ctx context.Context, id string) error

Revoke stamps RevokedAt on a single token row. Used when rotating refresh tokens: the old refresh is marked revoked the moment its successor is minted.

func (*Repo) RevokeAllForUserClient

func (r *Repo) RevokeAllForUserClient(ctx context.Context, userID, clientID string) error

RevokeAllForUserClient revokes every active token (access + refresh) the user holds for the given client. Used by the profile "Disconnect" button — once the user clicks it, the client must re-run the OAuth dance to regain access. Idempotent.

func (*Repo) RevokeChain

func (r *Repo) RevokeChain(ctx context.Context, rootID string) error

RevokeChain stamps RevokedAt on every token whose ParentTokenID transitively reaches the given root. Called when refresh-token reuse is detected so a leaked refresh + its descendants are all killed at once. Best-effort — small chains, no recursion limit.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service holds the OAuth runtime — repo handle plus the issuer URL (used in metadata documents and verified against token aud claim when we eventually add JWTs).

func NewService

func NewService(r *Repo, issuer string) *Service

func NewServiceFromDB

func NewServiceFromDB(db *gorm.DB, issuer string) *Service

func (*Service) Authenticate

func (s *Service) Authenticate(ctx context.Context, plain string) (string, error)

Authenticate validates an access token presented in the Authorization: Bearer header and returns the owning user_id. Mirrors accesstoken.Service.Authenticate so the MCP middleware can hand off identical-shaped calls.

func (*Service) ExchangeAuthCode

func (s *Service) ExchangeAuthCode(ctx context.Context, code, clientID, redirectURI, codeVerifier string) (*TokenPair, error)

ExchangeAuthCode redeems an authorization code per RFC 6749 §4.1.3 + RFC 7636 §4.6 (PKCE verification). Marks the code used so a replay fails atomically.

func (*Service) ExchangeRefreshToken

func (s *Service) ExchangeRefreshToken(ctx context.Context, refresh, clientID string) (*TokenPair, error)

ExchangeRefreshToken swaps a refresh token for a new pair (rotation per OAuth BCP). A reuse of an already-rotated refresh is treated as token theft: the entire chain is revoked.

func (*Service) IssueAuthCode

func (s *Service) IssueAuthCode(ctx context.Context, p IssueAuthCodeParams) (string, error)

IssueAuthCode mints a new authorization code, stores the PKCE challenge, and returns the opaque code string.

func (*Service) Issuer

func (s *Service) Issuer() string

Issuer returns the canonical issuer URL. Exposed so the .well-known handlers can render the metadata documents.

func (*Service) ListAllGrants

func (s *Service) ListAllGrants(ctx context.Context) ([]AdminGrant, error)

ListAllGrants returns active (user, client) grants across every user. Admin-only — drives /admin/connections.

func (*Service) ListGrants

func (s *Service) ListGrants(ctx context.Context, userID string) ([]Grant, error)

ListGrants returns every app the user has currently authorized. One row per client_id — the underlying access + refresh tokens are collapsed into a single Grant struct (defined in repo.go).

func (*Service) LookupClient

func (s *Service) LookupClient(ctx context.Context, clientID string) (*entity.OAuthClient, error)

LookupClient returns the client by ID, or an error when missing.

func (*Service) RegisterClient

func (s *Service) RegisterClient(ctx context.Context, p RegisterClientParams) (*entity.OAuthClient, error)

RegisterClient stores a new client and returns the freshly minted client_id. ClientName defaults to "MCP client" when empty so the admin UI never shows a blank row.

func (*Service) RevokeGrant

func (s *Service) RevokeGrant(ctx context.Context, userID, clientID string) error

RevokeGrant disconnects a client from the user's account by revoking every active token they hold for that client. The next /mcp call from that client lands on a 401 with the standard WWW-Authenticate challenge so it can re-run the OAuth dance.

Returns no error when the user has no active tokens for the client — disconnect is idempotent.

func (*Service) SetIssuer

func (s *Service) SetIssuer(issuer string)

SetIssuer overrides the issuer URL — useful when the app_url config changes after the service is constructed (admin saves a new URL via /admin/configs, no restart).

type TokenPair

type TokenPair struct {
	AccessToken    string
	RefreshToken   string
	AccessExpires  time.Duration
	RefreshExpires time.Duration
}

TokenPair is the response shape /token returns and the bearer middleware works against. AccessToken / RefreshToken are plaintext — only crossing the wire on this single response. After that, only hashes survive in the DB.

Directories

Path Synopsis
templ: version: v0.3.1001
templ: version: v0.3.1001

Jump to

Keyboard shortcuts

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