oauth

package
v1.33.0 Latest Latest
Warning

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

Go to latest
Published: Nov 12, 2026 License: MIT Imports: 34 Imported by: 0

Documentation

Overview

Package oauth lets hosted MCP clients such as claude.ai and ChatGPT connect to a compose MCP server. Those clients cannot be configured with an API key: they discover an OAuth 2.1 authorization server, send the user to log in, and then call the MCP endpoint with the access token they received.

The package hosts that authorization server and protects the MCP endpoint as the matching resource server. The application keeps its users and its REST credentials; the package keeps the protocol:

  • discovery: RFC 8414 authorization server metadata and RFC 9728 protected resource metadata, both under /.well-known;
  • client registration: Client ID Metadata Documents (an https URL as client_id, unless DisableClientMetadataDocuments is set), RFC 7591 dynamic registration, and clients listed in Config;
  • the authorization code flow with PKCE (S256 only), the RFC 8707 resource parameter, exact redirect URI matching (any port for http loopback URIs, RFC 8252), and single-use codes;
  • opaque access and refresh tokens kept in the Store, refresh rotation;
  • the bearer check in front of the MCP endpoint, answering 401 with the WWW-Authenticate challenge that starts discovery.

Every endpoint lives under Issuer:

/.well-known/oauth-authorization-server   metadata
/.well-known/oauth-protected-resource/mcp metadata of the MCP endpoint (path mirrors Resource)
/oauth/register                            dynamic client registration
/oauth/authorize                           start of the code flow
/oauth/login                               built-in login page (unless LoginURL is set)
/oauth/token                               code exchange and refresh

Mounting next to the MCP server on an Echo app:

auth, err := oauth.New(oauth.Config{
	Issuer:      "https://api.acme.com",
	Resource:    "https://api.acme.com/mcp",
	TokenPrefix: "acme",
	Store:       oauth.NewBunStore(db.DB, "oauth_records"),
	Login: oauth.Login{
		Title:           "Acme",
		IdentifierLabel: "Email or phone",
		Challenge:       app.SendLoginCode,
		// Verify returns oauth.Grant{Subject: account.ID}.
		Verify: app.VerifyLoginCode,
	},
	// Accepts the application's own API keys at the MCP endpoint too.
	Fallback: func(ctx context.Context, bearer string) (bool, error) {
		return app.IsAPIKey(ctx, bearer)
	},
})
if err != nil {
	return ez.Wrap(err)
}
server, err := mcp.New(mcp.Config{Spec: spec, Handler: e, Auth: auth.AuthFunc()})
if err != nil {
	return ez.Wrap(err)
}
auth.Mount(e, server.Handler())

Verify returns the Grant for the user: a Subject such as the account id. The MCP server forwards each call to the REST API with the header "Authorization: Bearer <access token>" unchanged, and the REST handler resolves the token with Server.Verify, so no REST credential is ever stored. Applications with their own login page set LoginURL instead of Login, show the client name and redirect host that Pending returns, and call Approve once the user has authenticated and confirmed, or Deny when the user refuses.

Access tokens look like "<TokenPrefix>at_<43 base62>" and refresh tokens "<TokenPrefix>rt_<43 base62>" (see components/auth/token). The Store keeps only the hex SHA-256 of tokens and authorization codes as record ids, so a leaked table holds no usable credential. Tokens issued before this scheme were stored in plain text and cannot be found any more: connected MCP clients must reconnect once after the upgrade. There is no migration.

Refresh tokens rotate. Each code exchange starts a token family, whose row in the Store is the single source of truth: an access or refresh token points at its family and the generation it was issued in, and is live only while the family row is. The family names its current refresh token, and a refresh row it does not name never rotates; an access row of a later generation than the family's is refused too. A rotation writes the new tokens first, where they are inert, and commits with one conditional Swap of the family row that moves it to the next generation and names the new refresh token. The rows of a rotation that fails before its Swap stay behind: the refresh row never rotates, and the access row, never sent to a client, is accepted once a retry commits its generation. A rotated refresh token presented again, after its rotation or at the same time as it, is reuse: the family row is deleted, and every token of the family is refused on its next check. When both a thief and the client use a refresh token, the second use ends the connection for both and the user logs in again.

Known limits: cached Client ID Metadata Documents are kept for MetadataCacheTTL regardless of their Cache-Control; registration is open to any client and limited only per client address (RegistrationLimit, counted against ClientIP), so clients spread over many addresses can still fill the Store, each registration nobody logs in with until UnusedClientTTL after its last authorize, so one that keeps calling authorize stays; the MCP endpoint sends no CORS headers; a tool call checks its token three times, in Protect, in AuthFunc and in the application's Verify, two Store reads each, which is accepted as defense in depth; access tokens issued before a rotation stay valid until they expire.

A refresh token presented twice revokes its family even when both requests come from the client, so a client that retries a refresh with the same token after the first attempt committed must authorize again. A client that loses the response of a refresh that committed, by disconnecting, is in the same place: its retry is reuse and the user logs in again. That is preferred to a reuse grace period, because a rotated token stays dead. A refresh that fails before the Swap leaves the presented token usable, so its retry rotates it, and a Swap that committed but ran out of time before returning is recognised by the family naming the new token, and answered as a success.

Index

Constants

View Source
const (
	KindClient  = "client"
	KindRequest = "request"
	KindCode    = "code"
	KindAccess  = "access"
	KindRefresh = "refresh"
	// KindFamily is a token family under its random id: the grant, the
	// client, the expiry and the current generation every access and
	// refresh token of the family is checked against.
	KindFamily = "family"
)

Record kinds the server stores. Codes, access tokens and refresh tokens are stored under the hex SHA-256 of the secret, never the secret itself.

View Source
const Never = time.Duration(-1)

Never disables an expiry. A zero duration in Config selects the documented default; Never is accepted only by the fields that document it, and New rejects it, like any other negative duration, everywhere else.

Variables

This section is empty.

Functions

func CreateTable

func CreateTable(ctx context.Context, db *bun.DB, table string) error

CreateTable creates the table of NewBunStore and its index on expires_at, named after the table with an "_expires_at" suffix in the schema of the table, when they do not exist.

func Sweep

func Sweep(ctx context.Context, db *bun.DB, table string) (int64, error)

Sweep deletes the expired rows of the NewBunStore table and returns how many it deleted. Run it periodically, for example from a goroutine started with the application:

go func() {
	ticker := time.NewTicker(time.Hour)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			_, err := oauth.Sweep(ctx, db, "oauth_records")
			if err != nil {
				logger.Error("sweeping oauth records failed", "error", err)
			}
		}
	}
}()

Types

type Client

type Client struct {
	// ID is the client_id. For Client ID Metadata Documents it is the https
	// URL of the document.
	ID string `json:"id"`
	// Secret is the client_secret of a confidential client; empty for public
	// clients, which authenticate with PKCE alone.
	Secret string `json:"secret,omitempty"`
	// Name is shown on the login page.
	Name string `json:"name"`
	// RedirectURIs are the exact redirect URIs the client may use. An http
	// URI on a loopback host also accepts any port on the same hostname,
	// path and query (RFC 8252 7.3), so http://localhost/callback admits
	// http://localhost:3118/callback.
	RedirectURIs []string `json:"redirect_uris"`
	// contains filtered or unexported fields
}

Client is a registered OAuth client.

type Config

type Config struct {
	// Issuer is the https base URL of this server, without a trailing slash,
	// for example "https://api.acme.com". All endpoints are served under it
	// and it is the issuer reported in the metadata.
	Issuer string
	// Resource is the canonical URL of the MCP endpoint, for example
	// "https://api.acme.com/mcp". Tokens are issued for it alone, and the
	// resource parameter of the code flow must match it.
	Resource string
	// TokenPrefix names the tokens of this server, for example "acme": access
	// tokens are "acmeat_...", refresh tokens "acmert_...". It must match
	// ^[a-z0-9]{2,10}$ so both prefixes fit the 12 characters
	// components/auth/token allows. Pick one that no other token of the
	// application starts with, so AuthFunc, Protect and Verify tell OAuth
	// tokens apart by their shape alone.
	TokenPrefix string
	// Store keeps clients, pending logins, codes and tokens.
	Store Store
	// Login configures the built-in login page.
	Login Login
	// LoginURL, when set, replaces the built-in page: the browser is sent
	// there with the pending request id in the "request" query parameter.
	// The page must show the ClientName and RedirectHost that Pending
	// returns and get an explicit click from the user before the application
	// calls Approve, so a user is never signed in to a client they did not
	// see. A refusal calls Deny and sends the browser to the URL it
	// returns, which carries access_denied.
	LoginURL string
	// Clients are pre-registered clients, for installations that hand out a
	// client id instead of accepting registration from any client.
	Clients []Client
	// AllowedRedirectHosts, when set, restricts dynamic registration and
	// Client ID Metadata Documents to clients whose every redirect URI host
	// (exact match, without port) is listed, for example claude.ai and
	// chatgpt.com, and refuses authorization requests from previously
	// stored clients whose redirect URI is not; empty accepts any host.
	// Config.Clients are not restricted.
	AllowedRedirectHosts []string
	// Fallback, when set, decides whether a bearer token that is not shaped
	// like an access token of this server may reach the MCP endpoint, for
	// example an API key of the application, so clients configured by hand
	// keep working next to OAuth clients. It returns true to accept the
	// token and false to refuse it; an error means the token could not be
	// checked, such as a Store outage, and is reported to OnError: Protect
	// answers 500 and AuthFunc returns the error. AuthFunc passes an
	// accepted token to the REST API unchanged, which authenticates it
	// again: Fallback only answers at the MCP edge. It must refuse tokens
	// that are not its own: any token it accepted passes Protect, and a
	// client holding a bad token would never get the 401 that starts
	// discovery. Tokens shaped like access tokens never reach it.
	Fallback func(ctx context.Context, token string) (bool, error)
	// AccessTokenTTL bounds access tokens. Defaults to 1h.
	AccessTokenTTL time.Duration
	// RefreshTokenTTL bounds refresh tokens and so the whole connection,
	// from the code exchange on: a rotation keeps the expiry of its family.
	// Defaults to 30 days. Never puts no fixed end on the connection: it
	// lives until the client goes IdleTTL without refreshing or reuses a
	// rotated token, each rotation moving the family's expiry to IdleTTL
	// from then.
	RefreshTokenTTL time.Duration
	// IdleTTL is how long a connection under RefreshTokenTTL Never lives
	// after its last refresh. It is kept with the family when issued, so a
	// later change applies to new connections only. Ignored under a finite
	// RefreshTokenTTL, which bounds the connection by itself. Defaults to
	// 30 days. Never is rejected.
	IdleTTL time.Duration
	// RequestTTL bounds a pending login, from authorize to approval.
	// Defaults to 10 minutes.
	RequestTTL time.Duration
	// CodeTTL bounds an authorization code until it is exchanged. Defaults
	// to 1 minute.
	CodeTTL time.Duration
	// RegisteredClientTTL keeps a dynamically registered client once a user
	// has approved it. Each authorize, and each code or refresh exchange
	// before its tokens are written, stores the approved client again with a
	// fresh expiry, so only clients unused for this long expire, even under a
	// longer RefreshTokenTTL. Defaults to 365 days.
	RegisteredClientTTL time.Duration
	// UnusedClientTTL keeps a dynamically registered client that no user has
	// approved yet: registration stores it for this long, and each authorize
	// extends it to this long from then and no further, so a registration
	// nobody completes a login with expires UnusedClientTTL after its last
	// authorize (or its registration, without one). The first code
	// exchange approves the client and gives it RegisteredClientTTL. It must
	// cover a whole login: New rejects a value below RequestTTL plus CodeTTL.
	// Defaults to 7 days.
	UnusedClientTTL time.Duration
	// MetadataCacheTTL is how long a fetched Client ID Metadata Document is
	// cached, regardless of its Cache-Control. Defaults to 1h.
	MetadataCacheTTL time.Duration
	// RefreshTimeout bounds the Store calls of a refresh, which run detached
	// from the request so a client that disconnects cannot stop them halfway:
	// reading the token and its family, renewing the client, writing the new
	// tokens, the Swap or the revocation. Defaults to 10s.
	RefreshTimeout time.Duration
	// MaxLoginAttempts is how many failed Challenge or Verify calls void a
	// pending request on the built-in login page. Defaults to 5.
	MaxLoginAttempts int
	// RegistrationLimit is how many dynamic registration requests one client
	// address may make per minute; the ones after it are answered 429 with
	// Retry-After and store nothing. Every registration POST counts, refused
	// and malformed ones too; the CORS preflight does not. Defaults to 20: a
	// client registers once when a user connects it, and hosted clients such
	// as claude.ai and ChatGPT reach the server from a few shared addresses,
	// so the limit must cover many users connecting at once, while a script
	// filling the Store is held to 20 records a minute per address. An IPv6
	// address counts as its /64, since one host usually has a whole /64 to
	// rotate through. A negative value turns the limit off.
	RegistrationLimit int
	// ClientIP returns the address a registration is counted against. It has
	// the shape of Echo's IPExtractor, so an application passes the extractor
	// it already has. When nil, Mount uses the IPExtractor of the Echo
	// instance if one is set, read at each request so it may be set after
	// Mount, and otherwise, as Handler always does, the host of the request's
	// RemoteAddr (all of it when it cannot be split). Behind a proxy or load
	// balancer without an extractor, every client has the proxy's address
	// and so shares one count, which caps registrations for everyone at
	// RegistrationLimit and lets one client use it up.
	ClientIP func(*http.Request) string
	// OnError, when set, receives the failures users see only as a generic
	// page or a server_error response: Store failures, Challenge and Verify
	// errors that are not shown, and the like. Optional. The context carries
	// the request's values but is never cancelled and has no deadline, and
	// OnError runs synchronously inside the handler, so it must bound its own
	// work.
	OnError func(ctx context.Context, err error)
	// HTTPClient fetches Client ID Metadata Documents. Defaults to a client
	// with a 10s timeout and no proxy that checks every resolved address and
	// refuses loopback, private, link-local, unspecified, multicast,
	// unique-local, carrier-grade NAT, "this network", IETF protocol
	// assignment, benchmarking, class E, NAT64, Teredo and 6to4 addresses; a
	// client set here is trusted to reach any host. Redirects are never
	// followed.
	HTTPClient *http.Client
	// DisableClientMetadataDocuments turns Client ID Metadata Documents off:
	// the metadata advertises client_id_metadata_document_supported false,
	// and a client_id that is an https URL is an unknown client, unless it is
	// listed in Clients, so the server never fetches a URL. Clients register
	// dynamically instead. A client connected through a document before the
	// switch can no longer refresh (invalid_client); its access tokens stay
	// valid until they expire. The zero value keeps documents on.
	DisableClientMetadataDocuments bool
}

Config configures a server. Issuer, Resource, TokenPrefix, Store and one of Login or LoginURL are required.

type Grant

type Grant struct {
	// Subject identifies the user, for example an account id. Required: an
	// empty Subject fails the login with an internal error.
	Subject string `json:"subject"`
	// Scope is the granted scope string, space separated, exactly as the
	// application returned it from Verify or passed it to Approve, empty when
	// it granted none. The scope the client requested is never copied in; it
	// only echoes in the token response.
	Scope string `json:"scope,omitempty"`
}

Grant is what a token stands for. It holds no credential: the REST API resolves the access token it receives back to the Grant with Verify. Subject is required: a grant without it, returned by Login.Verify or passed to Approve, fails the login with an internal error (ez.EINTERNAL).

type Login

type Login struct {
	// Title names the application on the page.
	Title string
	// IdentifierLabel labels the first field. Defaults to "Email".
	IdentifierLabel string
	// CredentialLabel labels the second field. Defaults to "Password", or
	// "Code" when Challenge is set.
	CredentialLabel string
	// Challenge runs after the identifier is submitted and before the
	// credential is asked, typically to send a one-time code. Errors with
	// code ez.EINVALID, ez.ENOTFOUND or ez.ERESOURCEEXHAUSTED show their
	// message; any other error shows a generic text and goes to OnError.
	Challenge func(ctx context.Context, identifier string) error
	// Verify checks the identifier and the credential and returns the grant
	// for the user, with Subject set and Scope as granted, empty for none.
	// Errors with code ez.ENOTAUTHENTICATED, ez.ENOTFOUND or ez.EINVALID
	// show their message; any other error shows a generic text and goes to
	// OnError.
	Verify func(ctx context.Context, identifier, credential string) (Grant, error)
}

Login configures the built-in login page. The page asks for an identifier, runs Challenge when it is set, then asks for the credential and calls Verify. Every error from Challenge or Verify counts as a failed attempt and Config.MaxLoginAttempts of them void the pending request. The limit is per pending request: throttling per identifier and per IP belongs to the application, inside Challenge and Verify.

type Pending

type Pending struct {
	ClientID     string
	ClientName   string
	RedirectHost string
	// Scope is the raw space-separated scope the client requested, so the
	// login page can grant, narrow or refuse it in the Grant.Scope it passes
	// to Approve. It is never granted on its own.
	Scope string
}

Pending describes a pending authorization request for a custom login page: the client asking for access, the host the browser returns to and the scope it asked for.

type Server

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

Server is the authorization server and the guard of the MCP endpoint.

func New

func New(cfg Config) (*Server, error)

New validates cfg, applies defaults and returns the server.

func (*Server) Approve

func (s *Server) Approve(ctx context.Context, requestID string, grant Grant) (string, error)

Approve redeems the pending request and returns the URL the browser must be sent to: the client redirect URI carrying the authorization code and state. grant.Scope is stored exactly as given, empty included; the scope the client requested only echoes in the token response. An unknown or expired request yields ez.ENOTFOUND, and a grant without Subject ez.EINTERNAL.

func (*Server) AuthFunc

func (s *Server) AuthFunc() func(ctx context.Context, token string) (http.Header, error)

AuthFunc is the mcp.Config.Auth of an MCP server behind this one. It returns the header "Authorization: Bearer <token>" for a live access token, so the REST API receives the token unchanged and resolves it with Verify. An unknown, expired or revoked access token fails with ez.ENOTAUTHENTICATED, so the client refreshes. Any other token goes to Fallback and, when accepted, is passed through the same way; when refused, or without Fallback, it fails with ez.ENOTAUTHENTICATED. A Fallback failure goes to OnError and is returned as it is. An empty token yields no headers.

func (*Server) Deny added in v1.20.0

func (s *Server) Deny(ctx context.Context, requestID string) (string, error)

Deny ends a pending authorization request with access_denied and returns the URL to send the browser to, carrying the client's state. Single use: the request is taken from the Store. An unknown or expired request is ez.ENOTFOUND.

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler serves the authorization server endpoints (metadata, register, authorize, login, token) for installations that do not use Echo. Unlike Mount, ServeMux decodes each path segment, so it also accepts other percent-encodings of the published paths.

Registrations are limited to Config.RegistrationLimit a minute per client address: Config.ClientIP, or else the host of the request's RemoteAddr. Behind a proxy or load balancer, set Config.ClientIP: without it every client has the proxy's address and shares one limit, which one client can use up.

func (*Server) Mount

func (s *Server) Mount(e *echo.Echo, mcpHandler http.Handler)

Mount registers every endpoint on e and serves mcpHandler at the path of Resource behind Protect. Echo matches the raw request path, so clients must request the published URLs byte for byte: a re-encoded form such as %c3%a9 for %C3%A9 is not found.

Registrations are limited to Config.RegistrationLimit a minute per client address. Without Config.ClientIP, the address is what e.IPExtractor returns, read at each request so it may be set after Mount, or the host of the request's RemoteAddr when e has no extractor.

func (*Server) Pending

func (s *Server) Pending(ctx context.Context, requestID string) (Pending, error)

Pending returns the client, redirect host and requested scope of a pending request, for a login page set with LoginURL to show before the user approves. An unknown or expired request yields ez.ENOTFOUND.

func (*Server) Protect

func (s *Server) Protect(next http.Handler) http.Handler

Protect wraps the MCP handler: requests without a valid bearer token get 401 with the WWW-Authenticate challenge pointing at the protected resource metadata.

func (*Server) Verify added in v1.18.0

func (s *Server) Verify(ctx context.Context, accessToken string) (Grant, error)

Verify resolves a live access token to its grant. Unknown, malformed, expired or revoked tokens return ez.ENOTAUTHENTICATED. Store failures return ez.EINTERNAL.

type Store

type Store interface {
	// Put saves value under kind and id until expiresAt, replacing any
	// previous value.
	Put(ctx context.Context, kind, id string, value []byte, expiresAt time.Time) error
	// Get returns the value, or an error with code ez.ENOTFOUND when it is
	// absent or expired.
	Get(ctx context.Context, kind, id string) ([]byte, error)
	// Take returns the value and deletes it in one step, so a code is
	// redeemed at most once. Absent or expired values yield an error with
	// code ez.ENOTFOUND.
	Take(ctx context.Context, kind, id string) ([]byte, error)
	// Swap replaces the live value under kind and id with value, until
	// expiresAt, only when the stored value equals old, byte for byte: old
	// is always a value Get returned, never re-encoded, so a store that
	// normalises its values must compare the same way. It returns an error
	// with code ez.ENOTFOUND when the record is absent, expired or holds a
	// different value, so the caller re-reads and decides again.
	Swap(ctx context.Context, kind, id string, old, value []byte, expiresAt time.Time) error
	// Delete removes the value. A missing value is not an error.
	Delete(ctx context.Context, kind, id string) error
}

Store persists the server records: clients, pending requests, codes, tokens and token families. Implementations must be safe for concurrent use. Values are opaque JSON; ids are random strings, client ids, or the hex SHA-256 of a token or code.

The refresh protocol needs read-your-writes across every instance of the server: each Get must observe every Put, Swap or Delete that returned before it started. A single primary database gives that; eventually consistent reads and read replicas are not supported, since a stale read of a family row could rotate a token of a revoked family or miss a reuse.

func NewBunStore

func NewBunStore(db *bun.DB, table string) Store

NewBunStore returns a Store backed by one table with the columns kind, id, value and expires_at, primary key (kind, id). Call CreateTable with the same table at startup to create it. Take uses a single DELETE ... RETURNING statement so a record is redeemed at most once, and Swap a single conditional UPDATE so a family rotates at most once per generation. Point db at the primary: the Store contract needs read-your-writes, which read replicas do not give. Expired rows stay in the table until Sweep deletes them; nothing calls Sweep automatically, so run it periodically, for example from a ticker goroutine as the Sweep doc shows.

func NewMemoryStore

func NewMemoryStore() Store

NewMemoryStore returns a Store kept in process memory, for tests and single-process deployments.

Jump to

Keyboard shortcuts

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