oauth2server

package
v11.2.0 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: AGPL-3.0 Imports: 26 Imported by: 0

Documentation

Overview

Package oauth2server is an OAuth 2.1 authorization server: the endpoints, the grant logic, and a Store seam underneath them.

store, _ := oauth2database.NewStore(&oauth2database.Config{}, db)

srv, _ := oauth2server.NewServer("https://auth.example", store, authenticator)
srv.Mount(router)

It exists because a resource server whose clients discover it at runtime and hold no pre-registered client identifier has to be an authorization server — protected resource metadata (RFC 9728), authorization server metadata (RFC 8414), authorization code with PKCE, and dynamic client registration (RFC 7591) — and that is a lot of protocol to assemble out of the reference examples. The examples are map-backed, so every assembly from them is map-backed, and a map-backed authorization server works exactly until there are two replicas.

None of this is specific to any one protocol built on top of it. The same endpoints serve any RFC 7591 client.

What you have to supply

Two things, and they are the two this package must not decide.

A SubjectAuthenticator says who the human at /authorize is. One deployment checks a username, an argon2 password, and a TOTP code against its own identity repository; another already has a session cookie; another delegates to a corporate identity provider. There is no default, because a default would be a server that issues authorization codes to whoever asks.

A Subject says what a token means. ID is the "sub"; Claims is whatever the resource server needs beside it — an account identifier, a tenant, a role — and this package neither reads it nor constrains it beyond requiring strings.

Everything else has a default, the login form included: see DefaultLoginRenderer for a plain page that works with no stylesheet and no assets, and WithLoginRenderer for replacing it.

The endpoints

GET  /.well-known/oauth-authorization-server   RFC 8414 discovery
GET  /authorize                                the login form
POST /authorize                                authenticate, issue a code
POST /token                                    authorization_code, refresh_token
POST /register                                 RFC 7591 dynamic registration
POST /revoke                                   RFC 7009 revocation

Mount registers all six on a routing.Router; Handler returns them as one http.Handler for a caller not using routing. The seventh document — RFC 9728 protected resource metadata — is emitted by the resource server, which is not necessarily this process, so it is a separate mountable thing: ResourceMetadata.

The decision with the most reach: what an access token is

An access token here is opaque, and every resource-server request that carries one costs a lookup in the Store. The alternative — a signed authentication/tokens JWT or PASETO, verified locally — is a real option that this package deliberately does not take, and the reasoning is worth having in the open rather than inherited from whichever was written first.

A signed token makes verification free and revocation impossible. What carries revocability then is the refresh token, which means a sign-out ends the session at the end of the access token's lifetime rather than now — so the access token lifetime becomes a direct trade against how long a revoked session keeps working, and the pressure is always to make it shorter, which puts the load back on the token endpoint.

An opaque token makes revocation immediate and verification a lookup. The Store is required either way, for authorization codes and client registrations, so the opaque choice adds no dependency; it adds a query on a primary key, on a table whose live rows are bounded by (sessions × access token lifetime).

Immediate revocation is the property this package is unwilling to give up. It ships a /revoke endpoint, and an endpoint that answers 200 to "end this session" and then leaves the session working for another quarter of an hour is worse than not shipping one. Server.Authenticate is how an in-process resource server does the lookup.

The cost is stated plainly rather than hidden: a resource server in a different process cannot call Authenticate, and this package ships no RFC 7662 introspection endpoint for it to call instead. Share the Store, or hold the resource server in the same process.

The access token lifetime

Fifteen minutes, and it is the number this package most deliberately did not inherit. The examples this generalizes use twenty-four hours, and they use it because their store is a map: a restart would otherwise sign everybody out, so the token had to outlive the process. With a durable store and a rotating refresh token behind it, a long-lived bearer token buys nothing — the session already survives — and costs the entire window in which a leaked one works.

What is checked, and why each one is not a follow-up

Three of these are the difference between an authorization server and something that looks like one, and all three are cheap while the interface is being drawn and awkward afterwards.

Redirect URIs are matched exactly, byte for byte, against the ones the client registered — at /authorize, and again at /token against the URI the code was issued for. Not a prefix, not "same host", not "ignoring the query string". Where the registered URIs are stored and never read again, any redirect_uri a request supplies receives an authorization code; PKCE keeps whoever receives it from redeeming it, so that is a leak rather than a takeover, but registration was supposed to be what stopped it.

Client secrets are verified at /token, against the registration rather than against whatever the authorization code recorded. A metadata document that advertises client_secret_post from an endpoint reading no secret makes registration decorative.

Tokens carry an audience, from RFC 8707 resource indicators, so that a token minted for one resource server cannot be replayed at another. A resource server that finds its own identifier absent must refuse the token; this package cannot make that check on its behalf.

Every credential is stored as a hex SHA-256 digest and never as itself, so a database dump contains nothing redeemable — invisible in a map that dies with the process, and the entire difference once the store is a table.

PKCE is mandatory and S256-only. There is no option to disable it and no support for the "plain" method, which puts the verifier in the request PKCE exists to protect.

Refresh tokens rotate and reuse is detected. Rotation alone is bookkeeping: the replayed token is refused and the copy the attacker is using keeps working, so the theft leaves one failed request and no other trace. What makes it worth doing is that a replay revokes the whole family — see RefreshToken.FamilyID.

What it does not implement

The implicit grant and the resource-owner-password grant, both removed by OAuth 2.1. There is no option that brings them back, because an option is a thing a deployment can be misconfigured into.

Token introspection (RFC 7662) and consent screens beyond the login form. The first is discussed above; the second is application-shaped in the same way the login form is, and WithLoginRenderer is the seam for it.

An adapter for any one protocol built on top of this. A remote MCP server is the case that prompted asking, because it has to be an authorization server and this is that; what is left over is smaller than it looks. An MCP server is an http.Handler, so putting it beside these endpoints is a Handle call on the same router. Its SDK wants a token verifier, which is a function that calls Authenticate and copies an AccessToken's Subject, Scopes, and ExpiresAt into whatever the SDK's own token type is. The 401 challenge the SDK's bearer middleware sends is the one ResourceMetadata.Challenge already builds, and the verified subject a tool handler needs is what the SDK then carries for it. A package holding a mount call and a fifteen-line copy would pin this module to one SDK's API in exchange for the fifteen lines.

Rate limiting /register. It is unauthenticated by construction — RFC 7591 requires that for discovery to work — so bounding it matters, but who a caller *is* depends on a deployment's proxy, gateway, and address handling in ways not visible from in here. Mount takes middleware for exactly this; ratelimiting has the middleware.

Choosing a store

oauth2server/database keeps four SQL tables, and is what a deployment wants. oauth2server/memory keeps four maps, and is for tests and single-process development. They are held to the same conformance suite, oauth2server/oauth2servertest, including the two cases that separate them: a code redeemed twice concurrently, and a record that expires between a read and the write that follows it.

oauth2server/config assembles either from environment configuration, with a do.Provide registration.

Watching it

oauth2server_requests                by endpoint: metadata, authorize, token,
                                     register, revoke.
oauth2server_errors                  by endpoint and OAuth error code. A rising
                                     invalid_grant on the token endpoint is
                                     usually a client with a broken PKCE
                                     implementation; a rising invalid_client is
                                     usually a registration that lapsed.
oauth2server_latency_ms              by endpoint.
oauth2server_codes_issued            authorization codes. Should track logins.
oauth2server_tokens_issued           token pairs, from both grants.
oauth2server_clients_registered      dynamic registrations. This is the one an
                                     anonymous caller drives, so a spike here
                                     is the signal that /register needs a rate
                                     limiter in front of it.
oauth2server_refresh_reuse_detected  replayed refresh tokens and codes. Not
                                     always an attack — a client that lost the
                                     response to a refresh and retried lands
                                     here too — but never nothing.
oauth2server_revocations             records revoked, by /revoke and by reuse
                                     detection together.

No credential appears on a span or in a log line, hashed or otherwise: a hash of a bearer credential is the store's lookup key for it. The client identifier is recorded, and is public by construction.

Index

Examples

Constants

View Source
const (
	MinCodeVerifierLength = 43
	MaxCodeVerifierLength = 128
)

PKCE verifier length bounds, from RFC 7636 §4.1. They are checked at /authorize rather than only at /token so that a client whose verifier is too short finds out before a human has typed a password.

View Source
const (
	FieldUsername = "username"
	FieldPassword = "password"
	FieldTOTPCode = "totp_code"
)

The form field names the shipped login page posts, and that the shipped SubjectAuthenticator adapters read.

They are exported so an application replacing the renderer but not the authenticator — or the other way round — has one spelling to agree on rather than two string literals in two files.

View Source
const (
	PathAuthorizationServerMetadata = "/.well-known/oauth-authorization-server"
	PathProtectedResourceMetadata   = "/.well-known/oauth-protected-resource"
	PathAuthorize                   = "/authorize"
	PathToken                       = "/token"
	PathRegister                    = "/register"
	PathRevoke                      = "/revoke"
)

The paths this package mounts. They are the ones RFC 8414 and RFC 9728 fix (the two .well-known documents) and the conventional spellings for the rest.

They are constants rather than options because a discovery document is what tells a client where the endpoints are, so nothing outside this package needs to know them — and a deployment that wants them elsewhere mounts the handlers itself. What must not vary is that the metadata and the mount agree, which is what sharing these constants buys.

View Source
const (
	// DefaultAuthorizationCodeTTL is how long an authorization code is
	// redeemable.
	//
	// One minute. A code is redeemed by the client the instant the browser
	// hands it the redirect, so the window is bounded by one HTTP round trip
	// rather than by anything a human does; RFC 6749 §4.1.2 puts the ceiling at
	// ten minutes, and every second under that is a second the code is not
	// sitting in a browser history, a proxy log, and a Referer header.
	DefaultAuthorizationCodeTTL = time.Minute

	// DefaultAccessTokenTTL is how long an access token is usable.
	//
	// Fifteen minutes, which is the number this package most deliberately did
	// not inherit. The examples this is a generalization of use twenty-four
	// hours, and they use it *because* their store is a map: a restart would
	// otherwise be visible to every signed-in user, so the token had to outlive
	// the process. With a durable store and a rotating refresh token behind it,
	// a long-lived bearer token buys nothing and costs the entire window in
	// which a leaked one works.
	DefaultAccessTokenTTL = 15 * time.Minute

	// DefaultRefreshTokenTTL is how long a refresh token is exchangeable.
	//
	// Seven days. It is the credential that carries revocability here — the
	// thing a sign-out actually ends — and it is one-time-use with reuse
	// detection, so a stolen one is good for one exchange before the theft
	// revokes the whole family.
	DefaultRefreshTokenTTL = 7 * 24 * time.Hour

	// DefaultClientRegistrationTTL is how long a dynamically registered client
	// lasts before it has to register again.
	//
	// Ninety days. Registration is open by construction, so this table has an
	// anonymous writer; an expiry is what bounds it without anybody having to
	// decide which rows are garbage. A client still in use re-registers on its
	// next discovery, which under RFC 7591 it already knows how to do.
	//
	// Set it to zero for registrations that never lapse, and then answer the
	// question of what removes them.
	DefaultClientRegistrationTTL = 90 * 24 * time.Hour
)

The four lifetimes, and why they are these numbers.

View Source
const (
	// MaxRedirectURIs is how many callbacks one registration may declare.
	MaxRedirectURIs = 16

	// MaxRedirectURILength bounds one callback URI.
	MaxRedirectURILength = 2048

	// MaxClientNameLength bounds the display name, which is rendered on the
	// consent form.
	MaxClientNameLength = 256
)

Bounds the default registration policy enforces.

They are not a security boundary on their own — a caller who can register one client can register a thousand — but they bound what a single registration costs, which is what keeps a row in the client table a row rather than a place to store a megabyte. The actual defense against volume is rate limiting middleware, which the policy cannot do; see RegistrationPolicy.

View Source
const (
	ErrorCodeInvalidRequest          = "invalid_request"
	ErrorCodeInvalidClient           = "invalid_client"
	ErrorCodeInvalidGrant            = "invalid_grant"
	ErrorCodeUnauthorizedClient      = "unauthorized_client"
	ErrorCodeUnsupportedGrantType    = "unsupported_grant_type"
	ErrorCodeUnsupportedResponseType = "unsupported_response_type"
	ErrorCodeInvalidScope            = "invalid_scope"
	ErrorCodeAccessDenied            = "access_denied"
	ErrorCodeServerError             = "server_error"

	// ErrorCodeInvalidTarget is RFC 8707 §2: a resource indicator this server
	// does not mint tokens for.
	ErrorCodeInvalidTarget = "invalid_target"

	// The RFC 7591 §3.2.2 registration errors.
	ErrorCodeInvalidRedirectURI    = "invalid_redirect_uri"
	ErrorCodeInvalidClientMetadata = "invalid_client_metadata"
)

The RFC 6749 §5.2 / §4.1.2.1 error codes, plus the two later ones this server can emit. They are the strings a client branches on, so they are constants rather than literals scattered across four handlers.

View Source
const (
	GrantTypeAuthorizationCode = "authorization_code"
	GrantTypeRefreshToken      = "refresh_token"
)

Grant types this server implements. OAuth 2.1 removes the implicit and resource-owner-password grants, and this package does not bring them back — there is no option that re-enables them, because an option is a thing a deployment can be misconfigured into.

View Source
const (
	AuthMethodNone         = "none"
	AuthMethodClientSecret = "client_secret_post"
	AuthMethodClientBasic  = "client_secret_basic"
)

Token endpoint authentication methods.

AuthMethodNone is a public client — a CLI, a single-page app, anything that cannot hold a secret. It is not a weaker mode: PKCE is mandatory for every client here, so a public client's authorization code is bound to a verifier only the requester holds, and the secret would be adding a credential that ships in the binary.

View Source
const CodeChallengeMethodS256 = "S256"

CodeChallengeMethodS256 is the only PKCE method this server accepts. The "plain" method puts the verifier in the authorization request, which is the request PKCE exists to protect, so supporting it would be supporting the attack.

View Source
const CredentialByteLength = 32

CredentialByteLength is how many bytes of entropy every credential this package mints carries: authorization codes, access tokens, refresh tokens, client identifiers, and client secrets.

256 bits, base64url-encoded to 43 characters. The same number for all five because there is no credential here whose disclosure is survivable, and a per-credential length would be five numbers to justify instead of one.

View Source
const DefaultLoginFailureMessage = "Sign-in failed. Check your details and try again."

DefaultLoginFailureMessage is what the form says when a SubjectAuthenticator refuses without naming a message of its own.

It is deliberately uninformative about which half was wrong. "No such user" and "wrong password" as separate answers make the form an account enumeration oracle, and a rate limiter does not fix that — it slows it down.

View Source
const (
	// DefaultSweepInterval is how often a store with a sweeper started removes
	// dead records when nothing says otherwise.
	//
	// Ten minutes, which is roughly two authorization-code lifetimes: often
	// enough that the code table stays near its steady-state size, rarely
	// enough that it is not a recurring full-table delete.
	DefaultSweepInterval = 10 * time.Minute
)

SweepInterval bounds are shared by the store implementations that run their own sweep goroutine, so that "how often" means the same thing in both.

View Source
const MaxRegistrationBodyBytes = 64 << 10

MaxRegistrationBodyBytes bounds the registration request body.

/register is the one endpoint here an anonymous caller can write rows through, so the body it sends is read with a ceiling rather than to EOF. 64 KiB is far more than any legitimate registration — sixteen redirect URIs at two kilobytes each does not reach it — and small enough that a caller cannot make this server hold a megabyte per open connection.

View Source
const ResponseTypeCode = "code"

ResponseTypeCode is the only response_type this server answers. It is the authorization code flow; everything else OAuth 2.0 defined returns a token through the front channel, which OAuth 2.1 removes.

View Source
const TokenTypeBearer = "Bearer"

TokenTypeBearer is the token_type every token response carries.

Variables

View Source
var (
	// ErrNotFound indicates no record is stored under the given identifier.
	ErrNotFound = platformerrors.New("oauth2 record not found")

	// ErrExpired indicates a record was found but is past its deadline. It
	// wraps ErrNotFound.
	//
	// A store reports it rather than pretending the record is absent so that
	// the expiry is decided against one clock — the store's — instead of being
	// re-derived by every caller that reads the record. See Store for why the
	// check has to happen inside the same statement that consumes it.
	ErrExpired = platformerrors.Wrap(ErrNotFound, "oauth2 record expired")

	// ErrAlreadyRedeemed indicates a one-time credential was presented twice.
	// It wraps ErrNotFound.
	//
	// Consuming methods return the record alongside this error, deliberately.
	// The record names the token family, and revoking that family is the whole
	// point of detecting the replay: without it, rotation rejects the copy the
	// attacker holds and leaves the copy the victim holds working, so nobody
	// finds out.
	ErrAlreadyRedeemed = platformerrors.Wrap(ErrNotFound, "oauth2 credential already redeemed")

	// ErrRecordExists indicates a create was given an identifier already in
	// use.
	//
	// Every identifier this package mints carries 256 bits of entropy, so this
	// means a store was handed one it did not mint rather than that two
	// credentials collided. It is an error rather than an overwrite because the
	// overwrite would be silent, and what it would silently discard is a live
	// credential somebody is holding.
	ErrRecordExists = platformerrors.New("oauth2 record identifier already in use")

	// ErrClientExists indicates a client was registered under an identifier
	// already in use. It wraps ErrRecordExists.
	//
	// It has its own sentinel because registration is the one create an
	// anonymous caller drives, so it is the one a caller is likely to branch
	// on: a silent overwrite there would let one anonymous caller take over
	// another's client by guessing an identifier.
	ErrClientExists = platformerrors.Wrap(ErrRecordExists, "oauth2 client identifier already in use")
)

Store sentinels. Every Store implementation reports these and nothing else for the four outcomes a caller has to branch on, which is what lets the server's grant logic be written once against the interface rather than once per backend.

ErrExpired and ErrAlreadyRedeemed wrap ErrNotFound, so a caller that only needs "this credential is not usable" checks that one. The distinction matters in exactly one place and it is the important one: a code or refresh token that comes back ErrAlreadyRedeemed is a replay, and a replay is the signal that revokes a token family. An ErrNotFound is a typo.

View Source
var (
	// ErrNilStore indicates NewServer was called without a Store.
	ErrNilStore = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil oauth2 store")

	// ErrNilAuthenticator indicates NewServer was called without a
	// SubjectAuthenticator. There is no default: an authorization server that
	// cannot tell who the human is would issue codes to anybody who asked.
	ErrNilAuthenticator = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil oauth2 subject authenticator")

	// ErrEmptyIssuer indicates a Server was built without an issuer URL. Every
	// metadata document and every audience check is derived from it.
	ErrEmptyIssuer = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "empty oauth2 issuer")

	// ErrInvalidIssuer indicates an issuer that is not an https URL with no
	// query or fragment, as RFC 8414 §2 requires.
	ErrInvalidIssuer = platformerrors.New("oauth2 issuer must be an https URL with no query or fragment")

	// ErrEmptyIdentifier indicates an empty client identifier or credential
	// hash reached a Store.
	ErrEmptyIdentifier = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "empty oauth2 identifier")

	// ErrNilRecord indicates a nil record reached a Store's create method.
	ErrNilRecord = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil oauth2 record")
)

Construction and input sentinels.

View Source
var (
	// ErrInvalidRedirectURI indicates a redirect_uri that is not among the
	// ones the client registered.
	//
	// It is never sent to the redirect_uri, because the whole point is that
	// this one is not the client's. It renders as a 400 in the browser, which
	// is the only safe place left to put it.
	ErrInvalidRedirectURI = platformerrors.New("redirect_uri is not registered for this client")

	// ErrUnknownClient indicates a client_id no registration matches.
	ErrUnknownClient = platformerrors.New("unknown oauth2 client")

	// ErrClientAuthenticationFailed indicates a client that presented no
	// credential, or the wrong one, at an endpoint that requires one.
	ErrClientAuthenticationFailed = platformerrors.New("oauth2 client authentication failed")

	// ErrPKCERequired indicates an authorization request with no S256 code
	// challenge. OAuth 2.1 requires PKCE on every authorization code request,
	// and this server has no switch to turn it off.
	ErrPKCERequired = platformerrors.New("authorization request requires an S256 code_challenge")

	// ErrPKCEVerificationFailed indicates a code_verifier whose S256 digest is
	// not the challenge the code was issued against.
	ErrPKCEVerificationFailed = platformerrors.New("code_verifier does not match the code_challenge")

	// ErrUnsupportedGrantType indicates a grant_type this server does not
	// implement. It implements authorization_code and refresh_token; OAuth 2.1
	// removes implicit and resource-owner-password, and this package does not
	// bring them back.
	ErrUnsupportedGrantType = platformerrors.New("unsupported oauth2 grant type")

	// ErrUnsupportedResponseType indicates a response_type other than "code".
	ErrUnsupportedResponseType = platformerrors.New("unsupported oauth2 response type")

	// ErrInvalidScope indicates a requested scope the client is not registered
	// for.
	ErrInvalidScope = platformerrors.New("requested scope is not registered for this client")

	// ErrInvalidResource indicates a "resource" indicator (RFC 8707) that is
	// not one this server mints tokens for.
	ErrInvalidResource = platformerrors.New("requested resource is not served by this authorization server")

	// ErrCodeClientMismatch indicates an authorization code redeemed by a
	// client other than the one it was issued to.
	ErrCodeClientMismatch = platformerrors.New("authorization code was issued to a different client")

	// ErrRedirectURIMismatch indicates a token request whose redirect_uri is
	// not the one the code was issued against.
	ErrRedirectURIMismatch = platformerrors.New("redirect_uri does not match the one the code was issued against")

	// ErrRegistrationRejected indicates a client registration a
	// RegistrationPolicy refused. It is what a policy returns when it wants a
	// 400 with an invalid_client_metadata code; wrap it to say why.
	ErrRegistrationRejected = platformerrors.New("client registration rejected")

	// ErrLoginFailed indicates a SubjectAuthenticator that could not identify
	// the human. The server re-renders the login form rather than failing the
	// request; wrap it to choose the message the form shows.
	ErrLoginFailed = platformerrors.New("could not authenticate the resource owner")
)

Protocol sentinels. These are the failures the server renders as an OAuth error response rather than as a platform error envelope, and they are exported so an application's own SubjectAuthenticator or RegistrationPolicy can return the same ones.

View Source
var (
	// ErrNoRedirectURI indicates a registration declaring no callback. There is
	// nowhere to send an authorization code, so the registration would be
	// unusable — and a client with no registered URI is the client for which
	// "any redirect_uri is accepted" was invented.
	ErrNoRedirectURI = platformerrors.Wrap(ErrRegistrationRejected, "registration declares no redirect_uris")

	// ErrTooManyRedirectURIs indicates a registration over MaxRedirectURIs.
	ErrTooManyRedirectURIs = platformerrors.Wrap(ErrRegistrationRejected, "registration declares too many redirect_uris")

	// ErrRedirectURINotAbsolute indicates a callback that is not an absolute
	// URI with a host. A relative one cannot be matched exactly against
	// anything, which is the check the whole registration exists to enable.
	ErrRedirectURINotAbsolute = platformerrors.Wrap(ErrRegistrationRejected, "redirect_uri is not an absolute URI")

	// ErrRedirectURIInsecure indicates an http callback to something that is
	// not a loopback address. An authorization code sent over plaintext to the
	// network is a code anybody on the path can read, and PKCE does not help:
	// the verifier travels over the same network to the same endpoint.
	ErrRedirectURIInsecure = platformerrors.Wrap(ErrRegistrationRejected, "redirect_uri must be https, a loopback http address, or a private-use scheme")

	// ErrRedirectURIHasFragment indicates a callback with a fragment. RFC 6749
	// §3.1.2 forbids it, and it cannot survive the redirect anyway — the
	// server appends its own query, and a browser drops the original fragment
	// when the response adds one.
	ErrRedirectURIHasFragment = platformerrors.Wrap(ErrRegistrationRejected, "redirect_uri must not contain a fragment")

	// ErrRedirectURITooLong indicates a callback over MaxRedirectURILength.
	ErrRedirectURITooLong = platformerrors.Wrap(ErrRegistrationRejected, "redirect_uri is too long")

	// ErrClientNameTooLong indicates a client_name over MaxClientNameLength.
	ErrClientNameTooLong = platformerrors.Wrap(ErrRegistrationRejected, "client_name is too long")

	// ErrUnsupportedAuthMethod indicates a requested token_endpoint_auth_method
	// this server does not implement.
	ErrUnsupportedAuthMethod = platformerrors.Wrap(ErrRegistrationRejected, "unsupported token_endpoint_auth_method")
)

Registration rejection reasons. Each wraps ErrRegistrationRejected, so a caller writing its own policy can return these, and one reading an error can check the general case.

View Source
var ErrEmptyResource = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "empty protected resource identifier")

ErrEmptyResource indicates a ResourceMetadata built without a resource identifier. It is what a client matches its token's audience against, so an empty one would publish a document that authorizes nothing to be checked.

View Source
var ErrNoAuthorizationServer = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "no authorization servers named")

ErrNoAuthorizationServer indicates a ResourceMetadata naming no authorization server. The document's entire purpose is to say where to go and get a token.

Functions

func Hash

func Hash(credential string) string

Hash returns the hex-encoded SHA-256 digest of a credential. It is what every Store method keys on, and what a Client's SecretHash holds.

SHA-256 with no salt and no work factor, deliberately. Every value passed here is 256 bits from crypto/rand, so there is no dictionary to attack, no two users choosing the same value, and nothing a slow hash would buy — while what it would cost is a KDF on the path of every resource-server request. Passwords are the opposite case in every one of those respects, and go through authentication/argon2.

It is exported because a resource server holding an opaque token has to reach the same digest to look it up, and re-deriving "hex of sha256" at that call site is exactly the kind of second copy that can drift.

func S256Challenge

func S256Challenge(verifier string) string

S256Challenge renders the RFC 7636 S256 challenge for a verifier: base64url-encoded, unpadded, as the spec requires — a padded challenge is a different string and will not match one a compliant client computed.

func ValidateRedirectURI

func ValidateRedirectURI(raw string) error

ValidateRedirectURI reports whether a callback is one this server will send an authorization code to.

Three shapes are allowed, and they are the three OAuth 2.1 §8.4 recognizes for a client that cannot be confidential:

  • https, for anything reachable over a network;
  • http on a loopback host, for a native client that spins up a listener on an ephemeral port;
  • a private-use scheme with a dot in it (com.example.app:/callback), for a mobile client the operating system routes by scheme.

It is exported because a policy that wants to add its own rule — an allowlist of hosts, say — should be adding to this rather than replacing it, and re-deriving the loopback and private-use cases at that call site is exactly the copy that drifts.

func VerifyPKCE

func VerifyPKCE(verifier, challenge string) bool

VerifyPKCE reports whether verifier is the S256 pre-image of challenge.

S256 only. RFC 7636 also defines "plain", which puts the verifier in the authorization request — the request PKCE exists to protect — so supporting it would mean supporting the attack. There is no option to enable it.

The comparison is constant-time for the same reason equalHash is, and an empty challenge or verifier is a failure rather than a match: a code that somehow reached the store with no challenge must not be redeemable by sending no verifier.

Types

type AccessToken

type AccessToken struct {
	IssuedAt  time.Time
	ExpiresAt time.Time

	// RevokedAt is when the token was revoked, or the zero time. Revocation is
	// recorded rather than deleting the row so that a sweep and a revocation
	// are different events, and so that the row survives long enough for a
	// resource server's next request to be answered "no" rather than "never
	// heard of it".
	RevokedAt time.Time

	Hash     string
	ClientID string

	// FamilyID ties this token to the refresh-token family it was minted
	// under, so revoking the family revokes it.
	FamilyID string

	Subject Subject

	Scopes []string

	// Audience is the RFC 8707 resource this token is for. A resource server
	// that finds its own identifier absent must refuse the token: that check
	// is the reason resource indicators exist, and this package cannot make it
	// on the resource server's behalf.
	Audience []string
}

AccessToken is one issued access token.

Opaque and stored, not signed and self-describing — see the package doc for why that is a decision rather than an accident, and what it costs. As with AuthorizationCode, what is stored is the digest.

func (*AccessToken) Active

func (t *AccessToken) Active(now time.Time) bool

Active reports whether the token is usable at now: issued, not revoked, not expired.

func (*AccessToken) Clone

func (t *AccessToken) Clone() *AccessToken

Clone returns a deep copy. See Subject.Clone.

type AuthorizationCode

type AuthorizationCode struct {
	IssuedAt  time.Time
	ExpiresAt time.Time

	// RedeemedAt is when the code was consumed, or the zero time. It is what
	// makes a second redemption detectable rather than merely unsuccessful.
	RedeemedAt time.Time

	// Hash is the hex-encoded SHA-256 digest of the code. It is the store's
	// primary key.
	Hash string

	ClientID string

	// RedirectURI is the one the authorization request nominated, re-checked at
	// the token endpoint. A code issued for one URI cannot be redeemed by
	// naming another.
	RedirectURI string

	// CodeChallenge is the S256 challenge the code is bound to. Never empty —
	// PKCE is mandatory.
	CodeChallenge string

	// Nonce is echoed into the token record for an OpenID-shaped caller that
	// wants it. This package does not issue ID tokens and does not interpret
	// it.
	Nonce string

	Subject Subject

	Scopes []string

	// Resources are the RFC 8707 resource indicators the authorization request
	// asked for. They become the access token's audience, which is what stops
	// a token minted for one resource server being replayed at another.
	Resources []string
}

AuthorizationCode is one issued authorization code.

The code itself is not in here. What the store holds is Hash — the SHA-256 digest of the value the client received — so that a dump of this table contains nothing that can be redeemed. That is a property a map-backed store gets for free by dying with the process and a table does not get at all.

func (*AuthorizationCode) Clone

Clone returns a deep copy. See Subject.Clone.

type AuthorizationServerMetadata

type AuthorizationServerMetadata struct {
	Issuer                            string   `json:"issuer"`
	AuthorizationEndpoint             string   `json:"authorization_endpoint"`
	TokenEndpoint                     string   `json:"token_endpoint"`
	RegistrationEndpoint              string   `json:"registration_endpoint"`
	RevocationEndpoint                string   `json:"revocation_endpoint"`
	ServiceDocumentation              string   `json:"service_documentation,omitempty"`
	ScopesSupported                   []string `json:"scopes_supported,omitempty"`
	ResponseTypesSupported            []string `json:"response_types_supported"`
	GrantTypesSupported               []string `json:"grant_types_supported"`
	TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`

	// CodeChallengeMethodsSupported lists S256 and nothing else, which is also
	// how a client discovers that PKCE is not optional here.
	CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`

	RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported"`

	// AuthorizationResponseIssParameterSupported reports that every
	// authorization response carries the "iss" parameter (RFC 9207), which is
	// what lets a client with more than one authorization server tell which one
	// answered. A client holding two servers and no iss cannot detect a mix-up
	// attack.
	AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported"`
}

AuthorizationServerMetadata is the RFC 8414 discovery document.

The field set is what this server actually implements, not the whole registry. A document advertising something the endpoints do not do is worse than a shorter one: a client believes it, and the failure surfaces at the token endpoint as an error the client's author has no reason to expect. The map-backed examples advertise client_secret_post from an endpoint that reads no secret, which is exactly this failure with the sign flipped.

type Client

type Client struct {
	// CreatedAt is when the registration was accepted.
	CreatedAt time.Time

	// ExpiresAt is when the registration lapses, or the zero time for a
	// registration that does not.
	//
	// Dynamic registration is open by construction — RFC 7591 requires it for
	// the discovery flow to work at all — so the table it writes to has an
	// anonymous writer. An expiry is what keeps that table bounded without
	// requiring anybody to decide which rows are garbage: a client that is
	// still in use re-registers, and one that is not ages out.
	ExpiresAt time.Time

	// ID is the client_id. Minted by the server from crypto/rand.
	ID string

	// SecretHash is the SHA-256 digest of the client secret, hex-encoded, or
	// empty for a public client.
	//
	// SHA-256 rather than argon2, and that is not a shortcut. A client secret
	// is 256 bits minted by this server, so there is no dictionary to attack
	// and nothing a work factor would buy; what it would cost is an argon2
	// verification on every single token request. Passwords are the opposite
	// case and go through authentication/argon2.
	SecretHash string

	// Name is the client_name from the registration request, shown on the
	// consent form. Cosmetic, and attacker-supplied — render it, never trust
	// it.
	Name string

	// TokenEndpointAuthMethod is how this client authenticates at /token: one
	// of AuthMethodNone, AuthMethodClientSecret, AuthMethodClientBasic.
	TokenEndpointAuthMethod string

	// RedirectURIs are the exact URIs this client may receive an authorization
	// code at. Matched exactly, byte for byte, as OAuth 2.1 requires: no
	// prefix matching, no wildcard, no ignored query string.
	RedirectURIs []string

	// GrantTypes and ResponseTypes are what the registration asked for,
	// narrowed to what this server implements.
	GrantTypes    []string
	ResponseTypes []string

	// Scopes are the scopes this client may request. An authorization request
	// for anything outside it is rejected rather than silently narrowed —
	// silently narrowing hands back a token that looks like the one that was
	// asked for and is not.
	Scopes []string
}

Client is a registered OAuth client.

Every field except Name and SecretHash is load-bearing at request time. RedirectURIs in particular: it is the field the map-backed examples store and never read again, and reading it is the check that decides whether an authorization code may be sent somewhere the client did not nominate.

func (*Client) Clone

func (c *Client) Clone() *Client

Clone returns a deep copy. See Subject.Clone for why stores return copies.

func (*Client) Public

func (c *Client) Public() bool

Public reports whether this client holds no secret.

type LoginError

type LoginError struct {

	// Message is shown to the human. Write it for them.
	Message string
	// contains filtered or unexported fields
}

LoginError is how a SubjectAuthenticator names the message the login form shows.

It exists because the alternative — rendering the error's own text — makes every authenticator's internal error message a string in a browser, and the author of that message had no reason to think it would be. A *LoginError's Message is the only string from an authenticator that reaches a page.

It wraps ErrLoginFailed, so an authenticator returning one gets the re-render behavior without also having to say so:

return nil, oauth2server.NewLoginError("That code has expired.", err)

func NewLoginError

func NewLoginError(message string, cause error) *LoginError

NewLoginError builds a LoginError. An empty message renders DefaultLoginFailureMessage; a nil cause is fine, and means there was nothing underneath worth recording.

func (*LoginError) Error

func (e *LoginError) Error() string

Error implements error, rendering what is recorded rather than what is shown.

func (*LoginError) Unwrap

func (e *LoginError) Unwrap() []error

Unwrap reports both ErrLoginFailed and whatever the authenticator was reacting to, so a caller can match either.

type LoginRenderer

type LoginRenderer interface {
	RenderLogin(ctx context.Context, res http.ResponseWriter, view LoginView)
}

LoginRenderer draws the form the resource owner authenticates at.

A default is shipped — DefaultLoginRenderer — because a package that made every consumer write one would be shipping seven-eighths of an authorization server. It is deliberately plain: an application that cares how its login page looks replaces this, and one that does not gets a page that works without a stylesheet.

A renderer owns the whole response, status included, which is the one place this package hands that over. It has to: a renderer that wanted to answer 429 on a rate-limited login could not say so otherwise.

var DefaultLoginRenderer LoginRenderer = LoginRendererFunc(renderDefaultLogin)

DefaultLoginRenderer is the login page this package ships.

It exists because the alternative is a package that implements the whole of OAuth 2.1 and then asks every consumer to write an HTML form before any of it runs. It is deliberately unstyled and deliberately small: an application that cares what its login page looks like passes WithLoginRenderer, and one that does not gets a page that works with no stylesheet, no JavaScript, and no assets to serve.

It renders through html/template, so the client name — which is attacker-supplied, since registration is open — is escaped rather than trusted.

type LoginRendererFunc

type LoginRendererFunc func(ctx context.Context, res http.ResponseWriter, view LoginView)

LoginRendererFunc adapts a function to LoginRenderer.

func (LoginRendererFunc) RenderLogin

func (f LoginRendererFunc) RenderLogin(ctx context.Context, res http.ResponseWriter, view LoginView)

RenderLogin implements LoginRenderer.

type LoginView

type LoginView struct {
	// Action is where the form posts: the /authorize URL with the original
	// query string intact. The authorization parameters travel in the query
	// rather than in hidden form fields so that the POST is validated against
	// exactly the same request the GET was.
	Action string

	// Error is the message to show, or empty on the first render.
	Error string

	// ClientName is the registered client_name, or the client_id if the
	// registration named none.
	ClientName string

	// Scopes are what the client asked for, so the human can see what they are
	// approving.
	Scopes []string
}

LoginView is what the login form is rendered from.

ClientName is attacker-supplied — it is whatever the registration request said, and registration is open — so a renderer must escape it. The shipped renderer uses html/template, which does that by construction; a renderer that builds HTML by concatenation is choosing to be an XSS.

type Option

type Option func(*serverOptions)

Option configures a Server at construction.

func WithAccessTokenTTL

func WithAccessTokenTTL(ttl time.Duration) Option

WithAccessTokenTTL sets how long an access token is usable. A non-positive value leaves the default in place.

Raising it is the single change here with the most reach. An access token is checked against the store on every resource-server request, so revocation is immediate — but only for tokens that are still being checked. Lengthening this does not lengthen how long a session lasts; the refresh token already decides that. It lengthens how long a leaked token works.

func WithAuthorizationCodeTTL

func WithAuthorizationCodeTTL(ttl time.Duration) Option

WithAuthorizationCodeTTL sets how long an authorization code is redeemable. A non-positive value leaves the default in place; see DefaultAuthorizationCodeTTL for what to weigh.

func WithClientRegistrationTTL

func WithClientRegistrationTTL(ttl time.Duration) Option

WithClientRegistrationTTL sets how long a dynamically registered client lasts.

Zero means registrations never lapse, which is a real choice and not a mistake — but it is one that leaves an unauthenticated endpoint writing rows nothing ever removes, so make it deliberately. A negative value is the same as zero.

func WithClock

func WithClock(c clock.Clock) Option

WithClock swaps the clock every deadline is stamped against, so a test can expire a token without waiting for it.

func WithLogger

func WithLogger(logger logging.Logger) Option

WithLogger attaches a logger. An absent logger logs nowhere.

func WithLoginRenderer

func WithLoginRenderer(renderer LoginRenderer) Option

WithLoginRenderer replaces the login form. A nil renderer leaves the shipped one in place; see DefaultLoginRenderer.

func WithMetricsProvider

func WithMetricsProvider(metricsProvider metrics.Provider) Option

WithMetricsProvider attaches a metrics provider. An absent one records nothing.

func WithRefreshReuseDetection

func WithRefreshReuseDetection(detect bool) Option

WithRefreshReuseDetection sets whether presenting an already-redeemed refresh token revokes its whole family.

On by default, and turning it off is turning rotation into bookkeeping: without it the replay is refused and the copy the attacker is using keeps working, so a theft produces one failed request and no other trace. The switch exists because the failure mode has a cost — a client that loses the response to a refresh and retries revokes its own session — and a deployment with a client it cannot fix may need to weigh that.

func WithRefreshTokenTTL

func WithRefreshTokenTTL(ttl time.Duration) Option

WithRefreshTokenTTL sets how long a refresh token is exchangeable. A non-positive value leaves the default in place.

func WithRegistrationPolicy

func WithRegistrationPolicy(policy RegistrationPolicy) Option

WithRegistrationPolicy replaces what /register accepts. A nil policy leaves the shipped one in place; see DefaultRegistrationPolicy for what that enforces and why replacing it should mean adding to it.

func WithResources

func WithResources(resources ...string) Option

WithResources declares the RFC 8707 resource indicators this server mints tokens for. They become an access token's audience.

Declaring none accepts any resource the client asks for and records it as the audience — which still binds the token, but binds it to something the client chose. Declaring the set is what makes the audience a statement by this server rather than an echo.

func WithScopes

func WithScopes(scopes ...string) Option

WithScopes declares the scopes this server issues.

An authorization request for a scope outside this set is refused rather than narrowed. Narrowing silently hands back a token that looks like the one that was asked for and is not, and the client finds out at the resource server, in a different process, as a 403.

Declaring none accepts any scope the client registered for, which is the right answer for a deployment whose resource server does its own scope mapping and the wrong one for a deployment that thought this was a filter.

func WithServiceDocumentation

func WithServiceDocumentation(url string) Option

WithServiceDocumentation sets the service_documentation URL in the discovery document.

func WithTracerProvider

func WithTracerProvider(tracerProvider tracing.Provider) Option

WithTracerProvider attaches a tracer provider. An absent one traces nowhere.

type ProtectedResourceMetadata

type ProtectedResourceMetadata struct {
	Resource               string   `json:"resource"`
	ResourceName           string   `json:"resource_name,omitempty"`
	ResourceDocumentation  string   `json:"resource_documentation,omitempty"`
	AuthorizationServers   []string `json:"authorization_servers"`
	ScopesSupported        []string `json:"scopes_supported,omitempty"`
	BearerMethodsSupported []string `json:"bearer_methods_supported"`
}

ProtectedResourceMetadata is the RFC 9728 document a resource server publishes so that a client which discovered the resource at runtime can find the authorization server behind it.

It is emitted by the resource server rather than by this one, and the two are not necessarily the same process — which is why it is a separate mountable thing rather than a sixth route on the Server.

type RefreshToken

type RefreshToken struct {
	IssuedAt  time.Time
	ExpiresAt time.Time

	// RedeemedAt is when this token was exchanged, or the zero time. One-time
	// use: a second exchange is a replay.
	RedeemedAt time.Time

	// RevokedAt is when this token was revoked, whether individually through
	// /revoke or as part of its family.
	RevokedAt time.Time

	Hash     string
	ClientID string
	FamilyID string

	Subject Subject

	Scopes    []string
	Audience  []string
	Resources []string
}

RefreshToken is one issued refresh token.

FamilyID is what makes rotation worth doing. Every refresh minted from a given authorization code carries the same family identifier, so when a redeemed token is presented a second time the server knows exactly which tokens to revoke: all of them. Rotation without that detects nothing — the replay is refused and the copy the attacker is actually using keeps working.

func (*RefreshToken) Clone

func (t *RefreshToken) Clone() *RefreshToken

Clone returns a deep copy. See Subject.Clone.

type RegistrationPolicy

type RegistrationPolicy interface {
	// AllowRegistration vets a request. An error wrapping
	// ErrRegistrationRejected renders as a 400 with invalid_client_metadata
	// and the error's message; anything else is a 500.
	AllowRegistration(ctx context.Context, req *RegistrationRequest) error
}

RegistrationPolicy decides whether a registration is accepted.

The default — DefaultRegistrationPolicy — enforces what the protocol cannot be safe without: at least one redirect URI, every one of them absolute, https or loopback, and free of a fragment. Replace it to add whatever else a deployment needs: an allowlist of hosts, a cap tied to a tenant, a check against an out-of-band approval.

What it is not is a rate limiter. Rate limiting a registration endpoint depends on how a deployment identifies a caller — source address, a proxy header, an API gateway's own token — and none of those are visible from here with any confidence. Mount /register behind ratelimiting middleware; Server's Mount takes middleware for exactly this.

var DefaultRegistrationPolicy RegistrationPolicy = RegistrationPolicyFunc(defaultRegistrationPolicy)

DefaultRegistrationPolicy is what /register enforces when nothing else is configured: the rules a dynamically registered client has to satisfy for the rest of this package's checks to mean anything.

Chief among them is that there is at least one redirect URI and that each is exactly matchable. Everything the authorization endpoint does about redirect URIs rests on the registered set being a set of exact, absolute strings; a registration that declared none, or declared a relative one, would leave that check with nothing to compare against.

type RegistrationPolicyFunc

type RegistrationPolicyFunc func(ctx context.Context, req *RegistrationRequest) error

RegistrationPolicyFunc adapts a function to RegistrationPolicy.

func (RegistrationPolicyFunc) AllowRegistration

func (f RegistrationPolicyFunc) AllowRegistration(ctx context.Context, req *RegistrationRequest) error

AllowRegistration implements RegistrationPolicy.

type RegistrationRequest

type RegistrationRequest struct {
	ClientName              string   `json:"client_name,omitempty"`
	TokenEndpointAuthMethod string   `json:"token_endpoint_auth_method,omitempty"`
	Scope                   string   `json:"scope,omitempty"`
	RedirectURIs            []string `json:"redirect_uris"`
	GrantTypes              []string `json:"grant_types,omitempty"`
	ResponseTypes           []string `json:"response_types,omitempty"`
}

RegistrationRequest is an RFC 7591 dynamic client registration request, as received.

Every field is attacker-supplied. /register is unauthenticated because RFC 7591 requires it to be for the discovery flow to work at all, which makes vetting this the authorization server's problem rather than an optional extra.

type RegistrationResponse

type RegistrationResponse struct {
	ClientID     string `json:"client_id"`
	ClientSecret string `json:"client_secret,omitempty"`
	ClientName   string `json:"client_name,omitempty"`

	Scope                   string `json:"scope,omitempty"`
	TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`

	RedirectURIs  []string `json:"redirect_uris"`
	GrantTypes    []string `json:"grant_types"`
	ResponseTypes []string `json:"response_types"`

	// ClientIDIssuedAt and ClientSecretExpiresAt are seconds since the epoch,
	// as RFC 7591 specifies. Zero for ClientSecretExpiresAt means the secret
	// does not expire.
	ClientIDIssuedAt      int64 `json:"client_id_issued_at"`
	ClientSecretExpiresAt int64 `json:"client_secret_expires_at"`
}

RegistrationResponse is the RFC 7591 §3.2.1 client information response.

ClientSecret appears exactly once in the lifetime of a registration: here. The store holds a digest, so this response is the only time the value exists outside the client — which is why the endpoint answers 201 with it rather than answering 201 and offering a way to read it back.

type ResourceMetadata

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

ResourceMetadata publishes the RFC 9728 protected resource metadata document.

It is a separate type from Server, and separately mountable, because the resource server and the authorization server are not necessarily the same process — usually they are not. A resource server publishes this to say "the tokens I accept come from over there"; the authorization server publishes its own document at PathAuthorizationServerMetadata to say what it can do.

A client that discovered the resource at runtime reads this first, follows authorization_servers to the other document, registers, and only then has a client_id. That chain is the reason any of this exists.

func NewResourceMetadata

func NewResourceMetadata(resource string, authorizationServers []string, opts ...ResourceOption) (*ResourceMetadata, error)

NewResourceMetadata builds the document a protected resource publishes.

resource is the identifier a client sends as the RFC 8707 "resource" parameter and that this server's tokens carry as their audience — so it has to be the same string in both places, which is why it is a parameter here and not derived from a request's Host header. A document whose resource identifier came from the request would say something different depending on which proxy answered.

Example

A resource server publishes its own document, so a client that discovered it at runtime can find the authorization server behind it and register.

package main

import (
	"fmt"

	"github.com/primandproper/platform-go/v11/authentication/oauth2server"
)

func main() {
	meta, err := oauth2server.NewResourceMetadata(
		"https://api.example/",
		[]string{"https://auth.example"},
		oauth2server.WithResourceName("Recipes API"),
		oauth2server.WithResourceScopes("recipes:read"))
	if err != nil {
		panic(err)
	}

	// Sent with every 401, so a client that was never configured with this
	// server is told where to look rather than simply refused.
	fmt.Println(meta.Challenge("invalid_token", "the token expired"))

}
Output:
Bearer resource_metadata="https://api.example/.well-known/oauth-protected-resource", error="invalid_token", error_description="the token expired"

func (*ResourceMetadata) Challenge

func (m *ResourceMetadata) Challenge(errorCode, description string) string

Challenge renders the WWW-Authenticate header a protected resource sends with a 401.

This is the other half of discovery and the half that is easy to leave out. A client with no token has no reason to fetch the metadata document until something tells it to, and RFC 9728 §5.1 makes this header that something: the resource_metadata parameter points at the document, and the client follows it, registers, and comes back. Without the header, a client that was never configured with this server simply gets a 401 and stops.

errorCode is an RFC 6750 §3.1 code — "invalid_token" for a token that is expired, revoked, or unknown; empty for a request that carried no token at all, which is not an error so much as an absence.

func (*ResourceMetadata) Document

Document returns the metadata this publishes.

func (*ResourceMetadata) Handler

func (m *ResourceMetadata) Handler() http.Handler

Handler serves the document.

func (*ResourceMetadata) Mount

func (m *ResourceMetadata) Mount(r *routing.Router, middleware ...routing.Middleware)

Mount registers the document on a routing.Router at the path RFC 9728 fixes.

type ResourceOption

type ResourceOption func(*ProtectedResourceMetadata)

ResourceOption configures a ResourceMetadata.

func WithResourceDocumentation

func WithResourceDocumentation(url string) ResourceOption

WithResourceDocumentation sets the documentation URL in the document.

func WithResourceName

func WithResourceName(name string) ResourceOption

WithResourceName sets the human-readable name in the document.

func WithResourceScopes

func WithResourceScopes(scopes ...string) ResourceOption

WithResourceScopes declares the scopes this resource understands, so a client knows what to ask the authorization server for.

type Server

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

Server is an OAuth 2.1 authorization server: the five endpoints, the grant logic behind them, and nothing about who the resource owner is.

It is a concrete type rather than an interface because there is one implementation of the protocol and there is not going to be a second. What swaps is underneath it — the Store — and beside it, in the two seams a deployment owns: SubjectAuthenticator and LoginRenderer.

func NewServer

func NewServer(issuer string, store Store, authenticator SubjectAuthenticator, opts ...Option) (*Server, error)

NewServer builds an authorization server.

issuer, store, and authenticator are parameters rather than options because none of them has a defensible default. An issuer is what every metadata document and every audience check is derived from; a store is where the state lives, and an implicit in-memory one would work in every test and fail every login behind a load balancer; a SubjectAuthenticator is the only thing that knows who the human is, and a default would be a server that issues codes to whoever asks.

Everything else is an option, observability included.

Example

The two seams a deployment supplies: who the human is, and what a token means. Everything else in this package is protocol.

package main

import (
	"context"
	"fmt"
	"net/http"

	"github.com/primandproper/platform-go/v11/authentication/oauth2server"
	"github.com/primandproper/platform-go/v11/authentication/oauth2server/memory"
)

func main() {
	// Whatever this application actually does to identify a human — a password
	// and a TOTP code against its own identity repository, an existing session
	// cookie, a corporate identity provider. There is no default, because a
	// default would be a server that issues authorization codes to whoever
	// asks.
	authenticator := oauth2server.SubjectAuthenticatorFunc(
		func(_ context.Context, req *http.Request) (*oauth2server.Subject, error) {
			username := req.PostFormValue(oauth2server.FieldUsername)
			if username == "" {
				return nil, oauth2server.NewLoginError("Enter your username.", nil)
			}

			return &oauth2server.Subject{
				ID: "user_" + username,
				// The application-shaped half of the identity. This package
				// carries it into every token and never reads it.
				Claims: map[string]string{"account_id": "acct_9"},
			}, nil
		})

	// memory for this example; a deployment wants oauth2server/database, or two
	// replicas cannot complete each other's logins.
	srv, err := oauth2server.NewServer("https://auth.example", memory.NewStore(), authenticator,
		oauth2server.WithScopes("recipes:read", "recipes:write"),
		oauth2server.WithResources("https://api.example/"))
	if err != nil {
		panic(err)
	}

	// srv.Mount(router) registers all six endpoints; srv.Handler() is the same
	// set as one http.Handler.
	doc := srv.Metadata()

	fmt.Println(doc.TokenEndpoint)
	fmt.Println(doc.CodeChallengeMethodsSupported)
	fmt.Println(doc.GrantTypesSupported)

}
Output:
https://auth.example/token
[S256]
[authorization_code refresh_token]

func (*Server) Authenticate

func (s *Server) Authenticate(ctx context.Context, bearer string) (*AccessToken, error)

Authenticate resolves a bearer token to the record behind it, for a resource server running in this process.

It is the other half of the decision to make access tokens opaque: with a signed token a resource server verifies a signature locally, and with this one it asks. What it buys is that a revoked token stops working on the next request rather than at the end of its lifetime — see the package doc, which argues the trade rather than assuming it.

A resource server in a *different* process cannot call this, and this package deliberately ships no introspection endpoint for it to call instead: RFC 7662 introspection is an authenticated endpoint with its own client credentials and its own caching questions, and adding one on the way past would be shipping a second protocol nobody asked for. Either share the Store, or hold the resource server in the same process.

func (*Server) AuthorizeHandler

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

AuthorizeHandler serves GET and POST /authorize.

Both methods run exactly the same validation, and that is the point of putting the authorization parameters in the query string on both. The GET renders the login form; the form posts back to the same URL with the same query, so the POST re-derives the client, the redirect URI, the scopes and the PKCE challenge from the same bytes the GET was checked against. Carrying them across in hidden form fields instead would mean the request that issues the code is not the request that was validated.

func (*Server) Handler

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

Handler returns every endpoint on one http.Handler, for a caller not using routing.

The metadata document is served from the .well-known path RFC 8414 fixes, which is at the root of the host rather than under whatever prefix the rest is mounted at. A deployment serving the authorization server under a path therefore has to route that one document separately — hence MetadataHandler, which exists to be mounted on its own.

func (*Server) Issuer

func (s *Server) Issuer() string

Issuer returns the normalized issuer URL. It is what a resource server puts in its own metadata's authorization_servers, so it is exported rather than left for a caller to re-derive from the string it passed in — the normalization is the point.

func (*Server) Metadata

func (s *Server) Metadata() AuthorizationServerMetadata

Metadata returns the RFC 8414 discovery document this server publishes.

It is derived rather than configured, so what it advertises and what the endpoints do cannot disagree: the auth methods listed are the ones /token verifies, the grant types are the ones it implements, and S256 is the only challenge method because it is the only one VerifyPKCE accepts.

func (*Server) MetadataHandler

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

MetadataHandler serves the RFC 8414 discovery document.

func (*Server) Mount

func (s *Server) Mount(r *routing.Router, middleware ...routing.Middleware)

Mount registers every endpoint on a routing.Router, with middleware applied to all of them.

The routes are raw handlers rather than typed ones, and record no OpenAPI operation. That is not a shortcut: these endpoints are specified elsewhere — form-encoded requests, 302 responses carrying credentials in a query string, an HTML login page — and a generated schema describing them would be a second, worse copy of RFC 6749 that a client author would be wrong to read.

The middleware slot is how a deployment rate limits /register, which is the one endpoint here that an anonymous caller can write rows through. To limit only that one, mount the handlers individually rather than calling this.

func (*Server) RegisterHandler

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

RegisterHandler serves POST /register: RFC 7591 dynamic client registration.

It is unauthenticated, and has to be — a client that discovered this server at runtime holds no credential to authenticate with, and the discovery flow exists precisely for clients that were never pre-registered. That makes everything an authenticated endpoint would get for free into this server's problem: the body is bounded, the metadata is vetted by a RegistrationPolicy, and every registration carries an expiry so the table it writes to does not grow without limit.

What it cannot do from in here is rate limiting; see RegistrationPolicy for why, and Server.Mount for where to put it.

func (*Server) RevokeHandler

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

RevokeHandler serves POST /revoke: RFC 7009 token revocation.

The endpoint answers 200 for a token it revoked, a token it has never seen, and a token that was already dead. That is RFC 7009 §2.2, and the reason is worth stating because the alternative looks more helpful: an endpoint that answered 404 for an unknown token would let anybody enumerate which tokens exist by sending guesses at it.

The client is authenticated first, and a token belonging to another client is treated as unknown. Without that, any registered client could revoke any other client's tokens by presenting them — an endpoint whose entire purpose is destructive and whose success is unverifiable from outside.

func (*Server) TokenHandler

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

TokenHandler serves POST /token: the authorization_code and refresh_token grants.

type Store

type Store interface {
	// CreateClient records a registration. An identifier already in use is
	// ErrClientExists rather than an overwrite: registrations are created by
	// anonymous callers, and a silent overwrite would let one of them take over
	// another's client by guessing an identifier.
	CreateClient(ctx context.Context, client *Client) error

	// GetClient reads a registration. A registration past its ExpiresAt is
	// ErrExpired, not a value the caller has to check.
	GetClient(ctx context.Context, clientID string) (*Client, error)

	// DeleteClient removes a registration. A registration that was already
	// gone is not an error — the caller wanted it gone.
	DeleteClient(ctx context.Context, clientID string) error

	// CreateAuthorizationCode records an issued code.
	CreateAuthorizationCode(ctx context.Context, code *AuthorizationCode) error

	// ConsumeAuthorizationCode marks the code redeemed and returns it, in one
	// atomic operation.
	//
	// A code that was already redeemed returns the record *and*
	// ErrAlreadyRedeemed. The record is not a courtesy: RFC 6749 §4.1.2 says a
	// replayed code should revoke what it previously issued, and the caller
	// cannot find those tokens without knowing which family the code belongs
	// to.
	ConsumeAuthorizationCode(ctx context.Context, hash string) (*AuthorizationCode, error)

	// CreateAccessToken records an issued access token.
	CreateAccessToken(ctx context.Context, token *AccessToken) error

	// GetAccessToken reads an access token. Expired or revoked is ErrExpired —
	// a resource server asking about a token it holds wants a straight answer,
	// and "expired" and "revoked" are the same answer to it.
	GetAccessToken(ctx context.Context, hash string) (*AccessToken, error)

	// RevokeAccessToken marks one access token revoked. A token that is absent
	// or already revoked is not an error: RFC 7009 §2.2 requires the
	// revocation endpoint to answer 200 either way, and a store that
	// distinguished them would be inviting the endpoint to leak which tokens
	// exist.
	RevokeAccessToken(ctx context.Context, hash string) error

	// CreateRefreshToken records an issued refresh token.
	CreateRefreshToken(ctx context.Context, token *RefreshToken) error

	// ConsumeRefreshToken marks the token redeemed and returns it, in one
	// atomic operation. As with ConsumeAuthorizationCode, a replay returns the
	// record alongside ErrAlreadyRedeemed so that the family can be revoked.
	ConsumeRefreshToken(ctx context.Context, hash string) (*RefreshToken, error)

	// GetRefreshToken reads a refresh token without consuming it.
	//
	// It exists for /revoke, which needs the record to learn whose token this
	// is and which family to end — and must not spend the token to find out.
	// An already-redeemed token is still returned: a sign-out arriving after a
	// rotation is the ordinary case, and the family it names is exactly what
	// needs revoking. Expired or revoked is ErrExpired.
	GetRefreshToken(ctx context.Context, hash string) (*RefreshToken, error)

	// RevokeRefreshToken marks one refresh token revoked, without touching the
	// rest of its family. This is what /revoke does; RevokeFamily is what a
	// detected replay does.
	RevokeRefreshToken(ctx context.Context, hash string) error

	// RevokeFamily revokes every access and refresh token in a family and
	// reports how many records it touched.
	//
	// The count is for the caller's metric, not its control flow — a family
	// whose tokens have all expired legitimately revokes nothing.
	RevokeFamily(ctx context.Context, familyID string) (int64, error)

	// Sweep removes records whose deadlines have passed as of now, reporting
	// how many it removed.
	//
	// It is a garbage collector, not a security control: every read above
	// already refuses an expired record, so a row this has not reached yet is
	// unusable. What it stops is the table growing with every code ever
	// issued, which under dynamic registration is a table an anonymous caller
	// can add to.
	Sweep(ctx context.Context, now time.Time) (int64, error)

	// Close releases whatever the implementation holds.
	Close() error
}

Store is where an authorization server's four kinds of state live: registered clients, authorization codes, access tokens, and refresh tokens.

It is one interface rather than four because three of the four operations that matter span two of them. Redeeming a code mints a token pair; detecting a replayed refresh token revokes a family that includes access tokens; revoking a registration has to reach whatever it issued. Four interfaces would leave a caller holding four handles that have to be to the same database for any of that to be atomic, and nothing would say so.

Why the consuming methods are shaped the way they are

ConsumeAuthorizationCode and ConsumeRefreshToken are not "read, then mark". A caller cannot write that pair correctly against a shared table: two requests carrying the same code both read it unredeemed, both mint a token pair, and the credential that was supposed to be single-use was used twice. The check and the mark are therefore one method, and an implementation owes its callers that they happen atomically — one statement, one transaction, one lock, whatever the backend makes available.

Expiry is inside the same operation for the same reason, and it is the case a map-backed store gets for free and a table does not. A store that checks `expires_at > now` in Go, between the read and the write, has a window in which a code expires and is redeemed anyway. The guard belongs in the predicate.

What is stored

Digests, never credentials. Every method here takes and returns a hash — see Hash — and no implementation ever sees the value the client holds. That is invisible in a map that dies with the process and is the difference between a leaked database backup and a leaked database backup that authorizes people.

Conformance

authentication/oauth2server/oauth2servertest holds the behavior every implementation owes, written once. Run it against any Store, including one a consumer writes.

type Subject

type Subject struct {
	// Claims is the application-shaped part of a token's identity. Nil is
	// fine; an empty map and a nil map mean the same thing.
	Claims map[string]string

	// ID is the "sub" claim. Required — a token with no subject authorizes
	// nobody, and the store rejects it.
	ID string
}

Subject is who a token is for, and is the seam where this package stops deciding.

ID is the "sub": whatever the application calls a user, stable for as long as the tokens minted for it. Claims is everything else the resource server needs to act on the token — dinnerdonebetter's account identifier, a tenant, a role — and this package neither reads it nor constrains it beyond requiring it to be strings.

Strings rather than `any`, deliberately. What goes in here comes back out of a token introspection or a JWT claim set, both of which are string-keyed JSON, and a map[string]any would let an application store something that round-trips through the database store as a different Go type than it went in as. Anything richer belongs behind the identifier in ID.

func (Subject) Clone

func (s Subject) Clone() Subject

Clone returns a deep copy, so that a record handed back by a store cannot be mutated through the caller's reference into the store's own state. The memory store depends on this; the database store gets it for free and calls it anyway, so the two cannot drift.

type SubjectAuthenticator

type SubjectAuthenticator interface {
	AuthenticateSubject(ctx context.Context, req *http.Request) (*Subject, error)
}

SubjectAuthenticator identifies the human behind an authorization request.

This is one of the two places this package deliberately stops. Everything else here is protocol — the same protocol for every deployment — and this is the application: dinnerdonebetter's is a username, an argon2 password, and a TOTP code checked against its own identity repository; another consumer's is an existing session cookie, or a corporate identity provider.

What it is handed and what it owes back

It receives the parsed /authorize request, form values included, so it can read whatever fields its own login form posts. It returns either the Subject the tokens will be minted for, or an error.

An error wrapping ErrLoginFailed re-renders the login form with a message, which is the answer to a wrong password: the human is still here and can try again. Any other error fails the request, which is the answer to a broken identity store — retrying a form against a database that is down produces a user who tries four times and then files a support ticket.

Returning (nil, nil) is treated as ErrLoginFailed. A Subject with an empty ID is rejected outright: a token whose subject is the empty string authorizes whoever the resource server decides the empty string is.

What it must not do

Write to the ResponseWriter — it does not have one, deliberately. An authenticator that could render its own response could redirect somewhere this package never validated, which is the redirect it exists to prevent.

type SubjectAuthenticatorFunc

type SubjectAuthenticatorFunc func(ctx context.Context, req *http.Request) (*Subject, error)

SubjectAuthenticatorFunc adapts a function to SubjectAuthenticator.

func (SubjectAuthenticatorFunc) AuthenticateSubject

func (f SubjectAuthenticatorFunc) AuthenticateSubject(ctx context.Context, req *http.Request) (*Subject, error)

AuthenticateSubject implements SubjectAuthenticator.

type TokenResponse

type TokenResponse struct {
	AccessToken  string `json:"access_token"`
	TokenType    string `json:"token_type"`
	RefreshToken string `json:"refresh_token,omitempty"`
	Scope        string `json:"scope,omitempty"`
	ExpiresIn    int64  `json:"expires_in"`
}

TokenResponse is the RFC 6749 §5.1 successful token response.

Directories

Path Synopsis
Package oauth2servercfg assembles an OAuth 2.1 authorization server, and the Store behind it, from environment configuration.
Package oauth2servercfg assembles an OAuth 2.1 authorization server, and the Store behind it, from environment configuration.
Package database keeps an authorization server's state in SQL tables.
Package database keeps an authorization server's state in SQL tables.
migrations
Package migrations supplies the authorization server's DDL, rendered for a dialect and table prefix.
Package migrations supplies the authorization server's DDL, rendered for a dialect and table prefix.
Package memory keeps an authorization server's state in maps.
Package memory keeps an authorization server's state in maps.
Package oauth2servertest holds the behavior every oauth2server.Store owes its callers, written once and run against each implementation.
Package oauth2servertest holds the behavior every oauth2server.Store owes its callers, written once and run against each implementation.

Jump to

Keyboard shortcuts

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