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
- Variables
- func ValidateRedirectURI(client *entity.OAuthClient, redirectURI string) bool
- type AdminGrant
- type Grant
- type Handler
- type IssueAuthCodeParams
- type RegisterClientParams
- type Repo
- func (r *Repo) ConsumeAuthCode(ctx context.Context, code string) (*entity.OAuthAuthorizationCode, error)
- func (r *Repo) CreateAuthCode(ctx context.Context, c *entity.OAuthAuthorizationCode) error
- func (r *Repo) CreateClient(ctx context.Context, c *entity.OAuthClient) error
- func (r *Repo) CreateToken(ctx context.Context, t *entity.OAuthToken) error
- func (r *Repo) FindActiveTokenByHash(ctx context.Context, hash string) (*entity.OAuthToken, error)
- func (r *Repo) FindAnyTokenByHash(ctx context.Context, hash string) (*entity.OAuthToken, error)
- func (r *Repo) GetClient(ctx context.Context, clientID string) (*entity.OAuthClient, error)
- func (r *Repo) ListAllGrants(ctx context.Context) ([]AdminGrant, error)
- func (r *Repo) ListGrantsByUser(ctx context.Context, userID string) ([]Grant, error)
- func (r *Repo) MarkUsed(ctx context.Context, id string) error
- func (r *Repo) Revoke(ctx context.Context, id string) error
- func (r *Repo) RevokeAllForUserClient(ctx context.Context, userID, clientID string) error
- func (r *Repo) RevokeChain(ctx context.Context, rootID string) error
- type Service
- func (s *Service) Authenticate(ctx context.Context, plain string) (string, error)
- func (s *Service) ExchangeAuthCode(ctx context.Context, code, clientID, redirectURI, codeVerifier string) (*TokenPair, error)
- func (s *Service) ExchangeRefreshToken(ctx context.Context, refresh, clientID string) (*TokenPair, error)
- func (s *Service) IssueAuthCode(ctx context.Context, p IssueAuthCodeParams) (string, error)
- func (s *Service) Issuer() string
- func (s *Service) ListAllGrants(ctx context.Context) ([]AdminGrant, error)
- func (s *Service) ListGrants(ctx context.Context, userID string) ([]Grant, error)
- func (s *Service) LookupClient(ctx context.Context, clientID string) (*entity.OAuthClient, error)
- func (s *Service) RegisterClient(ctx context.Context, p RegisterClientParams) (*entity.OAuthClient, error)
- func (s *Service) RevokeGrant(ctx context.Context, userID, clientID string) error
- func (s *Service) SetIssuer(issuer string)
- type TokenPair
Constants ¶
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 ¶
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 (*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 (*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 (*Repo) CreateClient ¶
func (*Repo) CreateToken ¶
func (*Repo) FindActiveTokenByHash ¶
func (*Repo) FindAnyTokenByHash ¶
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) 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 ¶
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 ¶
MarkUsed stamps LastUsedAt on a token. Best-effort observability hook the bearer middleware fires after a successful auth.
func (*Repo) Revoke ¶
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 ¶
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 ¶
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 (*Service) Authenticate ¶
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 ¶
IssueAuthCode mints a new authorization code, stores the PKCE challenge, and returns the opaque code string.
func (*Service) Issuer ¶
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 ¶
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 ¶
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 ¶
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.
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.