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 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 resource owner who is already signed in ¶
A form is the right answer for a browser and the wrong one for everything else. A first-party application holding a session cookie, a CLI holding a token, a service exchanging one credential for another — none of them has anything to type, and the only thing they can do with a login page is fail to parse it.
WithSubjectResolver registers a seam consulted before the form is rendered and before SubjectAuthenticator is asked, on GET and POST alike. A request carrying proof of who its owner is redirects with an authorization code; a request carrying none, or one the resolver does not recognize, gets the form exactly as before. A Server built without one is unchanged.
Two mechanisms, two seams, rather than one method that inspects the request and forks. Which of "presented a credential" and "typed a password" wins is a protocol question — the answer here is that proof already held wins — and folding both into SubjectAuthenticator leaves every deployment to answer it again, slightly differently, in application code. It also removes the reason a machine client had to POST: an empty body sent to a URL whose parameters are all in the query string was never anything but an artifact of where the seam was.
The endpoints ¶
GET /.well-known/oauth-authorization-server RFC 8414 discovery
GET /authorize the login form, or a code for an
already-authenticated owner
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.
/register is the one of the six a deployment can turn off, for one whose clients are administered somewhere else — created through a permission-gated API, seeded by a migration — and for which an anonymous endpoint writing to the same client table would be a way around those permissions. WithDynamicRegistration(false) takes it off the router and out of the discovery document in the same breath, because a document naming an endpoint that 404s is the failure the document exists to avoid.
It is also the one of the six an anonymous caller can write rows through, so a deployment that keeps it wants a bound on how fast. This package will not guess what the bound is per: who a caller *is* depends on a deployment's proxy, gateway, and address handling, and an address read from in here is a load balancer's as often as a client's. So the decision is a deployment's and the seam is WithRegistrationLimiter, which takes the gate it built — ratelimiting/http.NewMiddleware over any ratelimiting.RateLimiter is the expected one — and wraps RegisterHandler in it. Wrapping the handler rather than taking middleware at Mount is what makes it survive the router: Handler, Mount, and a deployment routing the endpoint by hand are bounded by the same construction call, and a deployment that names no gate gets exactly what it got before.
/revoke answers the same empty 200 whether it revoked a session or was handed a token nobody ever issued — RFC 7009 §2.2 requires that, so a client cannot use it to find out which tokens exist. A deployment often needs to know anyway, to emit its own "this user signed out" event, and asks with WithRevocationObserver rather than by inspecting a response that deliberately carries nothing.
The resource server's half ¶
Everything above mints tokens. Verifier is what a protected resource does with one, and it is a separate type for the same reason ResourceMetadata is: the resource server and the authorization server are frequently not the same component even when they are the same process.
guard, _ := oauth2server.NewVerifier(metadata, srv)
router.Handle(http.MethodPost, "/mcp", mcpHandler,
guard.Middleware("recipes:read"))
Three checks, and the middle one is why this exists.
The token is live: delegated to Server.Authenticate, which reads the Store. The token names this resource: its RFC 8707 audience is compared against the resource identifier in the metadata document. The token carries the scopes the route asked for.
Authenticate is the first of those three and only the first. It answers "is this a live token", because it is the authorization server's lookup and the authorization server serves every resource behind it — so a deployment running two protected resources against one authorization server has a cross-resource replay the moment either of them reads a non-nil return as an authorized request. The audience comparison is the one a resource server has to make for itself, and the only thing it needs in order to make it is its own identifier: naming that is what building a ResourceMetadata already is, so a Verifier is built from one rather than asking for the string a second time.
A token that carries no audience at all is refused, and there is no option that accepts one. It is the token RFC 8707 exists to prevent; a deployment where this refuses everything has clients that are not sending the resource parameter.
Verify is the same three checks without the HTTP, for a resource server that writes its own response envelope, and WriteChallenge writes the RFC 6750 refusal — status and WWW-Authenticate — for one that only wants the header right. A handler underneath Middleware reads what was verified with TokenFromContext instead of looking it up again.
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.
A replayed authorization code revokes its family too, which is RFC 6749 §4.1.2 and is the same threat one step earlier: whoever wins the race to /token keeps a token pair, and the loser's replay is the only signal that there were two of them. It works because the family is minted at /authorize and carried on the code — see AuthorizationCode.FamilyID — rather than at the redemption, which would leave a replay detectable and unanswerable. Unlike refresh reuse it has no switch: WithRefreshReuseDetection exists because a client that loses the response to a rotation and retries revokes a session it is using, and a replayed code cannot cost that — a client that received the pair has nothing to retry, so what is revoked is a pair nobody is holding.
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, and a dependency on any one protocol's SDK. A remote MCP server is the case that prompted asking, and it is worth saying where the line fell, because "the machinery is generic and the tools are not" is true and still does not put an mcp package in this module.
What is generic about an OAuth-protected MCP server is not MCP. It is the resource server: extract a bearer token, check it is live, check it was minted for this resource, check its scopes, and answer a refusal with the RFC 9728 pointer that starts a client's discovery. That is Verifier above, and it is the same code for a REST API behind the same tokens — so a package named mcp would have been a name a REST resource server either could not reach or had to import a protocol it does not speak to get at.
What is left after that is genuinely MCP's, and genuinely the consumer's: the server assembly, the tool registration, the schemas. An MCP server is an http.Handler, so mounting it is Middleware around it and a Handle call on the same router. A deployment preferring its SDK's own bearer middleware hands that middleware a function calling Verify and copying Scopes and ExpiresAt into the SDK's token type — three lines, against an SDK version this module then never has to have an opinion about. Taking the dependency, or defining an interface for a consumer's SDK to satisfy, would both buy those three lines at the price of a vendor API in this module's go.mod or in its exported surface.
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, and verify — the last
being a Verifier rather than an endpoint
here, sharing the label so one panel
covers the tokens minted and the
requests they are spent on.
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 wants a
WithRegistrationLimiter gate — and once
one is set, the refusals are the gate's
own counters rather than this one, which
counts what got through.
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.
oauth2server_audience_rejections live tokens presented at a Verifier they
were not minted for. An unknown token is
background — sessions end all day, and
those land on the shared error counter
under endpoint "verify" with
invalid_token — while a token that is
good somewhere else arriving here is a
client pointed at the wrong server or a
replay, and is never nothing.
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 ¶
- Constants
- Variables
- func BearerFromRequest(req *http.Request) string
- func ContextWithToken(ctx context.Context, token *AccessToken) context.Context
- func Hash(credential string) string
- func S256Challenge(verifier string) string
- func ValidateRedirectURI(raw string) error
- func VerifyPKCE(verifier, challenge string) bool
- type AccessToken
- type AuthorizationCode
- type AuthorizationServerMetadata
- type Client
- type LoginError
- type LoginRenderer
- type LoginRendererFunc
- type LoginView
- type Option
- func WithAccessTokenTTL(ttl time.Duration) Option
- func WithAuthorizationCodeTTL(ttl time.Duration) Option
- func WithClientRegistrationTTL(ttl time.Duration) Option
- func WithClock(c clock.Clock) Option
- func WithDynamicRegistration(serve bool) Option
- func WithLogger(logger logging.Logger) Option
- func WithLoginRenderer(renderer LoginRenderer) Option
- func WithMetricsProvider(metricsProvider metrics.Provider) Option
- func WithRefreshReuseDetection(detect bool) Option
- func WithRefreshTokenTTL(ttl time.Duration) Option
- func WithRegistrationLimiter(gate routing.Middleware) Option
- func WithRegistrationPolicy(policy RegistrationPolicy) Option
- func WithResources(resources ...string) Option
- func WithRevocationObserver(observer RevocationObserver) Option
- func WithScopes(scopes ...string) Option
- func WithServiceDocumentation(url string) Option
- func WithSubjectResolver(resolver SubjectResolver) Option
- func WithTracerProvider(tracerProvider tracing.Provider) Option
- type ProtectedResourceMetadata
- type RefreshToken
- type RegistrationPolicy
- type RegistrationPolicyFunc
- type RegistrationRequest
- type RegistrationResponse
- type ResourceMetadata
- func (m *ResourceMetadata) Challenge(errorCode, description string) string
- func (m *ResourceMetadata) Document() ProtectedResourceMetadata
- func (m *ResourceMetadata) Handler() http.Handler
- func (m *ResourceMetadata) Mount(r *routing.Router, middleware ...routing.Middleware)
- func (m *ResourceMetadata) ScopeChallenge(description string, scopes []string) string
- type ResourceOption
- type RevocationObserver
- type Server
- func (s *Server) Authenticate(ctx context.Context, bearer string) (*AccessToken, error)
- func (s *Server) AuthorizeHandler() http.Handler
- func (s *Server) Handler() http.Handler
- func (s *Server) Issuer() string
- func (s *Server) Metadata() AuthorizationServerMetadata
- func (s *Server) MetadataHandler() http.Handler
- func (s *Server) Mount(r *routing.Router, middleware ...routing.Middleware)
- func (s *Server) RegisterHandler() http.Handler
- func (s *Server) RevokeHandler() http.Handler
- func (s *Server) TokenHandler() http.Handler
- type Store
- type Subject
- type SubjectAuthenticator
- type SubjectAuthenticatorFunc
- type SubjectResolver
- type SubjectResolverFunc
- type TokenAuthenticator
- type TokenResponse
- type Verifier
- func (v *Verifier) Metadata() *ResourceMetadata
- func (v *Verifier) Middleware(requiredScopes ...string) routing.Middleware
- func (v *Verifier) Verify(ctx context.Context, bearer string, requiredScopes ...string) (*AccessToken, error)
- func (v *Verifier) WriteChallenge(res http.ResponseWriter, err error)
- type VerifierOption
Examples ¶
Constants ¶
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.
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.
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.
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.
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 defense against volume is a rate limit, which the policy cannot impose and WithRegistrationLimiter can; see RegistrationPolicy.
const ( ErrorCodeInvalidRequest = "invalid_request" ErrorCodeInvalidClient = "invalid_client" ErrorCodeInvalidGrant = "invalid_grant" 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 6750 §3.1 resource server errors. They are what a protected // resource sends in a WWW-Authenticate challenge rather than in a body, and // they are the two codes this package's Verifier emits. ErrorCodeInvalidToken = "invalid_token" ErrorCodeInsufficientScope = "insufficient_scope" // 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.
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.
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.
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.
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.
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.
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.
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.
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.
const TokenTypeBearer = "Bearer"
TokenTypeBearer is the token_type every token response carries.
Variables ¶
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.
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") // ErrNilResourceMetadata indicates NewVerifier was called without a // ResourceMetadata. It is where the resource identifier lives, so a // Verifier without one would have nothing to compare a token's audience // against. ErrNilResourceMetadata = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil protected resource metadata") // ErrNilTokenAuthenticator indicates NewVerifier was called without a // TokenAuthenticator. There is no implicit one: a resource server that // cannot reach the store has nothing to verify a token against. ErrNilTokenAuthenticator = platformerrors.Wrap(platformerrors.ErrNilInputParameter, "nil oauth2 token authenticator") )
Construction and input sentinels.
var ( // ErrNoBearerToken indicates a request that presented no Authorization: // Bearer credential at all. // // Distinct from a token that failed, deliberately. RFC 6750 §3 answers this // one with a challenge carrying no error code, because a client that has // not tried yet has not got anything wrong — it is being told where to go // and register, which is the entire discovery chain RFC 9728 exists for. ErrNoBearerToken = platformerrors.Wrap(platformerrors.ErrEmptyInputParameter, "request carries no bearer token") // ErrTokenAudienceMismatch indicates a live token whose RFC 8707 audience // does not name this resource. // // It is the check a resource server is most likely to skip and the one that // costs the most when skipped: an authorization server serves every // resource behind it, so a token minted for one of them is a token the // others will accept unless they compare. A token carrying no audience at // all lands here too — see audienceFor for why that is the correct end of // the trade rather than a case to make configurable. ErrTokenAudienceMismatch = platformerrors.New("access token was not issued for this resource") // ErrInsufficientScope indicates a live token, minted for this resource, // that does not carry a scope the route required. It is the one resource // server refusal that is a 403 rather than a 401: the credential is good // and re-presenting it will not help. ErrInsufficientScope = platformerrors.New("access token does not carry a required scope") )
Resource server sentinels. These are what Verify reports, and they are separate errors rather than one because a resource server answers each of them differently — see Verifier.WriteChallenge, which maps these three and the store's ErrNotFound onto a status and an RFC 6750 error code.
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") // ErrRegistrationNotServed indicates a registration request reaching a // server built with WithDynamicRegistration(false). It renders as a 404, // which is what the discovery document already said by leaving // registration_endpoint out. ErrRegistrationNotServed = platformerrors.New("this authorization server does not serve dynamic client registration") // 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.
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.
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.
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 BearerFromRequest ¶
BearerFromRequest reads the credential out of an Authorization header.
Header only, and that is the same refusal NewResourceMetadata publishes: RFC 6750 also defines a form parameter and a query parameter, and the query parameter puts a bearer token in every access log and Referer header between the client and here. A document that declines to advertise it and an extractor that reads it anyway would be advertising it after all.
An absent, malformed, or differently-schemed header yields the empty string, which Verify refuses as ErrNoBearerToken.
func ContextWithToken ¶
func ContextWithToken(ctx context.Context, token *AccessToken) context.Context
ContextWithToken carries a verified access token into a handler's context.
Middleware calls it; it is exported so a test of a handler can build the context that handler expects without standing up an authorization server.
func Hash ¶
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 ¶
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 ¶
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 ¶
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 TokenFromContext ¶
func TokenFromContext(ctx context.Context) (*AccessToken, bool)
TokenFromContext reads the token Middleware verified for this request.
The second return is false for a request that did not come through Middleware, which is what a handler mounted somewhere unguarded looks like from the inside — so a handler that needs the token must check it rather than dereferencing what it hopes is there.
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.
func (*AccessToken) HasScopes ¶
func (t *AccessToken) HasScopes(required ...string) bool
HasScopes reports whether this token carries every scope in required.
Naming none is true, which is the ordinary way for a resource server to ask "is this token good here" without also asking what it may do — the per-operation question is usually decided further in, by a handler that has this token from TokenFromContext.
Exact string equality, and no hierarchy: "recipes:write" does not imply "recipes:read", and "recipes" does not cover either. RFC 6749 §3.3 leaves scope semantics to the authorization server, so any implication invented here would be one this package granted on a deployment's behalf — and the failure direction is a token being accepted for something nobody meant to authorize.
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
// FamilyID is the token family this code will mint, minted here at
// /authorize rather than at the redemption that uses it.
//
// The timing is the whole point. RFC 6749 §4.1.2 says a code presented a
// second time should revoke what the first presentation issued, and a
// family decided at redemption is one a replay cannot name: the record
// comes back with ErrAlreadyRedeemed carrying everything about the code
// except the one field that says which tokens to revoke. Deciding it here
// costs an identifier for a code that is never redeemed and makes the
// replay actionable.
FamilyID 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 ¶
func (c *AuthorizationCode) Clone() *AuthorizationCode
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,omitempty"`
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.
RegistrationEndpoint is the one field here that can be absent rather than merely empty, and it is spelled omitempty for a reason an empty string cannot carry: a server built with WithDynamicRegistration(false) does not serve /register, and a client that resolved "" against the issuer would get this server's root rather than learning the endpoint is not there.
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.
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 ¶
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 ¶
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 ¶
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 ¶
WithClock swaps the clock every deadline is stamped against, so a test can expire a token without waiting for it.
func WithDynamicRegistration ¶
WithDynamicRegistration sets whether this server serves RFC 7591 dynamic client registration. On by default.
It is on by default because a client that discovered this server at runtime holds no pre-registered identifier, and registration is what it does about that. Turning it off is a deployment saying its clients are administered somewhere else — created through a permission-gated API, seeded by a migration — for which an anonymous endpoint writing to the same client table is a way around those permissions rather than a second way into them.
It turns the endpoint off in all three places at once, which is the whole reason it is one switch and not a router decision: Mount and Handler stop routing /register, RegisterHandler answers 404 to a deployment that mounted it by hand, and Metadata omits registration_endpoint. The document naming an endpoint that 404s is the failure the metadata is written to avoid with the sign flipped — and naming it as an empty string would be worse still, since a client resolving "" against the issuer gets this server's root.
What it does not touch is the clients already in the store. Turning registration off stops new ones being minted; it does not un-register the ones that were.
func WithLogger ¶
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 ¶
WithMetricsProvider attaches a metrics provider. An absent one records nothing.
func WithRefreshReuseDetection ¶
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.
It governs refresh tokens only. A replayed authorization code always revokes its family, because the cost this switch exists to weigh does not arise there: a client that received the token pair has nothing to retry, so a replayed code revokes a pair nobody is holding.
func WithRefreshTokenTTL ¶
WithRefreshTokenTTL sets how long a refresh token is exchangeable. A non-positive value leaves the default in place.
func WithRegistrationLimiter ¶
func WithRegistrationLimiter(gate routing.Middleware) Option
WithRegistrationLimiter puts a gate in front of /register.
/register is unauthenticated by construction — RFC 7591 requires that, or a client that discovered this server at runtime would have nothing to present — so it is the one endpoint here an anonymous caller can write rows through, and bounding how fast it may is a deployment's to decide. It is a deployment's because *who a caller is* is: an address is a proxy's unless the proxy is trusted, a header is whatever an API gateway was configured to set, and neither fact is visible from in here. So this package supplies the seam and the deployment supplies the answer.
The gate is a routing.Middleware, which is what ratelimiting/http.NewMiddleware returns:
limiter, err := ratelimiting.NewInMemoryRateLimiter(1, 5) gate, err := ratelimitinghttp.NewMiddleware(limiter, ratelimitinghttp.KeyByRemoteAddr()) server, err := oauth2server.NewServer(issuer, store, authenticator, oauth2server.WithRegistrationLimiter(gate))
It is an option rather than a Mount argument because a gate passed to Mount is a gate on all six endpoints, and mounting the handlers individually to reach one of them is a router-shaped answer that has to be rewritten for every router. Set here it wraps RegisterHandler itself, so Mount, Handler, and a deployment mounting the endpoint by hand all get it and none of them had to know.
The gate runs before anything this package does, so a refused request costs no store read, spends no registration policy, and appears in the middleware's own counters rather than in oauth2server_requests — a refusal is not an attempt this server declined, it is one it never saw.
A nil gate registers nothing, which is the behavior of every server built before this option existed.
Example ¶
/register is unauthenticated by construction, so a deployment bounds it with a gate of its own choosing rather than one this package guessed at.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"github.com/primandproper/platform-go/v13/authentication/oauth2server"
"github.com/primandproper/platform-go/v13/authentication/oauth2server/memory"
"github.com/primandproper/platform-go/v13/ratelimiting"
ratelimitinghttp "github.com/primandproper/platform-go/v13/ratelimiting/http"
)
func main() {
// One registration per second, and no burst beyond it. A real deployment
// picks these from its own traffic; what it cannot delegate is the next
// line.
limiter, err := ratelimiting.NewInMemoryRateLimiter(1, 1)
if err != nil {
panic(err)
}
defer func() { _ = limiter.Close() }()
// The address the connection came from, which is right for a server facing
// clients directly and wrong behind a proxy — there, KeyByForwardedFor with
// the number of proxies actually in front. That is the fact this package
// cannot know and the deployment cannot avoid knowing.
gate, err := ratelimitinghttp.NewMiddleware(limiter, ratelimitinghttp.KeyByRemoteAddr())
if err != nil {
panic(err)
}
authenticator := oauth2server.SubjectAuthenticatorFunc(
func(_ context.Context, _ *http.Request) (*oauth2server.Subject, error) {
return &oauth2server.Subject{ID: "user_1"}, nil
})
srv, err := oauth2server.NewServer("https://auth.example", memory.NewStore(), authenticator,
oauth2server.WithRegistrationLimiter(gate))
if err != nil {
panic(err)
}
// The gate is inside RegisterHandler, so Handler, Mount, and a deployment
// routing POST /register by hand are all behind it.
front := httptest.NewServer(srv.Handler())
defer front.Close()
register := func() int {
body := strings.NewReader(`{"redirect_uris":["https://client.example/callback"]}`)
req, reqErr := http.NewRequestWithContext(context.Background(),
http.MethodPost, front.URL+oauth2server.PathRegister, body)
if reqErr != nil {
panic(reqErr)
}
req.Header.Set("Content-Type", "application/json")
res, doErr := front.Client().Do(req)
if doErr != nil {
panic(doErr)
}
defer func() { _ = res.Body.Close() }()
return res.StatusCode
}
fmt.Println(register())
fmt.Println(register())
}
Output: 201 429
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 ¶
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 WithRevocationObserver ¶
func WithRevocationObserver(observer RevocationObserver) Option
WithRevocationObserver attaches the callback /revoke reports a real revocation to. A nil observer leaves whatever was already set in place.
It is the one piece of information that endpoint deliberately withholds from the client and that the deployment legitimately needs: RFC 7009 requires the same empty 200 whether a token was revoked or was never there, so a consumer that wants to emit its own "this user signed out" event cannot tell the two apart from the outside. See RevocationObserver for when it is called and what it must not do.
func WithScopes ¶
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 ¶
WithServiceDocumentation sets the service_documentation URL in the discovery document.
func WithSubjectResolver ¶
func WithSubjectResolver(resolver SubjectResolver) Option
WithSubjectResolver registers a seam consulted before the login form is rendered, for requests that already carry proof of who the resource owner is.
Absent — which is the default — /authorize behaves exactly as it always has: a GET renders the form, a POST asks the SubjectAuthenticator. Registered, a GET carrying a session cookie or a bearer token redirects with an authorization code and never draws a page, so a CLI or a first-party application does not have to POST an empty body to a URL whose parameters are all in the query string.
It is a separate seam rather than a second mechanism inside SubjectAuthenticator on purpose. Which of "presented a credential" and "typed a password" wins is a protocol question, and folding both into one method leaves every deployment to answer it slightly differently; here the answer is fixed, and it is that proof already held wins. SubjectAuthenticator keeps meaning what it has always meant — the human typed something.
A nil resolver registers nothing. See SubjectResolver for what its answers mean.
Example ¶
A resource owner who is already authenticated by other means never sees a form, on either verb.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"github.com/primandproper/platform-go/v13/authentication/oauth2server"
"github.com/primandproper/platform-go/v13/authentication/oauth2server/memory"
)
func main() {
// The seam for clients that hold proof rather than a keyboard: a
// first-party application with a session cookie, a CLI with a token, a
// service exchanging one credential for another. Returning (nil, nil) means
// "not one of mine", and the login form is rendered as usual.
resolver := oauth2server.SubjectResolverFunc(
func(_ context.Context, req *http.Request) (*oauth2server.Subject, error) {
// A request with no session cookie is not this resolver's, which
// is (nil, nil) rather than an error: the form is still the right
// answer for whoever sent it.
session, _ := req.Cookie("session")
if session == nil {
return nil, nil
}
return &oauth2server.Subject{ID: "user_" + session.Value}, nil
})
store := memory.NewStore()
srv, err := oauth2server.NewServer("https://auth.example", store,
oauth2server.SubjectAuthenticatorFunc(
func(context.Context, *http.Request) (*oauth2server.Subject, error) {
return nil, oauth2server.NewLoginError("Sign in to continue.", nil)
}),
oauth2server.WithSubjectResolver(resolver))
if err != nil {
panic(err)
}
ctx := context.Background()
if err = store.CreateClient(ctx, &oauth2server.Client{
ID: "client_1",
RedirectURIs: []string{"https://app.example/callback"},
}); err != nil {
panic(err)
}
query := url.Values{
"response_type": {oauth2server.ResponseTypeCode},
"client_id": {"client_1"},
"redirect_uri": {"https://app.example/callback"},
"code_challenge": {oauth2server.S256Challenge("0123456789012345678901234567890123456789abc")},
"code_challenge_method": {oauth2server.CodeChallengeMethodS256},
}
// A GET, with no body to POST and nothing to type.
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, oauth2server.PathAuthorize+"?"+query.Encode(), http.NoBody)
req.AddCookie(&http.Cookie{Name: "session", Value: "1"})
res := httptest.NewRecorder()
srv.Handler().ServeHTTP(res, req)
location, err := url.Parse(res.Header().Get("Location"))
if err != nil {
panic(err)
}
fmt.Println(res.Code)
fmt.Println(location.Query().Has("code"))
}
Output: 302 true
func WithTracerProvider ¶
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. A policy is handed the parsed metadata and not the request, so it cannot see a caller at all — and identifying one depends on how a deployment is fronted anyway: source address, a proxy header, an API gateway's own token. WithRegistrationLimiter is where that answer goes, and ratelimiting/http builds the gate it takes.
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/v13/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 ¶
func (m *ResourceMetadata) Document() ProtectedResourceMetadata
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.
func (*ResourceMetadata) ScopeChallenge ¶
func (m *ResourceMetadata) ScopeChallenge(description string, scopes []string) string
ScopeChallenge renders the WWW-Authenticate header for a request refused because its token lacks a scope, naming the scopes that would have satisfied it.
It is a second method rather than a third parameter on Challenge because a parameter added to an exported function is a change every caller has to absorb, and because the scope attribute belongs to exactly one refusal: RFC 6750 §3.1 defines it for insufficient_scope and for nothing else. The error code is therefore not a parameter either — there is only one it can be.
This is the refusal a client can act on. Every other one tells it that the credential it holds is no good; this one tells it what to ask the authorization server for instead.
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 RevocationObserver ¶
RevocationObserver is told what a /revoke request actually revoked.
It is a function rather than an interface because there is nothing for a deployment to implement here: this is a notification, and the only thing an interface would add is a type to declare somewhere.
When it is called ¶
After a revocation that removed something, with the subject the record belonged to and the family it was part of. Never for a token that was already gone, never for a token belonging to another client, and never when the store refused the write — RFC 7009 §2.2 requires the same empty 200 in all of those cases, and this is the difference the response cannot carry.
Revoking a refresh token takes its whole family, so this is called once for the family rather than once per record. Revoking an access token names the family it belonged to, which is still live: an access token revocation is not a sign-out, and a consumer that treats it as one will emit an event for a session that is still going.
What does not reach it is a revocation this server decided on: the family killed by refresh token reuse detection, or by a refresh token presented by the wrong client. Those are not sign-outs — reporting them through the same callback would have a deployment logging "user signed out" for a theft — and they are already visible as oauth2server_refresh_reuse_detected and a recorded operation.
What it must not do ¶
Block. It runs inline, on the request goroutine, before the 200 is written, so a slow observer is a slow sign-out; a deployment publishing a message should hand it to whatever it already has for that rather than waiting on a broker from in here. The context is the request's, so it is cancelled the moment the client hangs up.
A panic is recovered and recorded rather than allowed to take down the request: the records are already gone by the time this runs, and a failing analytics callback must not turn a sign-out that succeeded into a 500 the client retries.
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 seams a deployment owns: SubjectAuthenticator, the optional SubjectResolver, 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/v13/authentication/oauth2server"
"github.com/primandproper/platform-go/v13/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 ¶
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 ¶
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.
Where the method stops mattering is the optional SubjectResolver, which is asked on both before either the form or the SubjectAuthenticator. A request that already proves who its resource owner is — a session cookie, a bearer token — redirects with a code whichever verb it arrived on, so a client with nothing to type is not made to POST to say so.
func (*Server) 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 ¶
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, S256 is the only challenge method because it is the only one VerifyPKCE accepts, and registration_endpoint is absent entirely from a server built with WithDynamicRegistration(false), which is the one field here a deployment can turn off.
func (*Server) MetadataHandler ¶
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 applies to all six endpoints, which is what to reach for when the whole surface needs the same thing. It is not where /register's rate limit goes: that endpoint is the one here an anonymous caller can write rows through, so it wants a bound the other five do not, and WithRegistrationLimiter puts one inside RegisterHandler — where Handler and a deployment mounting the endpoint by hand get it too. A deployment that does not want the endpoint at all says so with WithDynamicRegistration(false), which takes it out of the discovery document as well as off the router.
func (*Server) RegisterHandler ¶
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 decide from in here is how fast an anonymous caller may ask, because that rests on who the caller is and this package cannot tell: WithRegistrationLimiter is the seam a deployment answers that with, and a server built with one returns the handler already behind it — so Handler, Mount, and a deployment routing this by hand are all bounded without having to arrange it themselves.
A server built with WithDynamicRegistration(false) answers 404 here instead of registering anything, so that a deployment which mounted this handler by hand cannot end up serving the endpoint its discovery document says it does not have. The gate, when there is one, still runs first: a caller hammering an endpoint that 404s is the case a bound is for, and spending the check to find out it was turned off would be paying at the wrong end.
func (*Server) RevokeHandler ¶
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 ¶
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. AuthorizationCode.FamilyID is that field, which is why it is on the
// code rather than only on the tokens — an implementation that drops it
// leaves the replay detectable and unanswerable.
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.
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 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 a corporate identity provider.
It is asked only when nothing else has already answered: it means the human typed something. A resource owner who is already authenticated by other means — a session cookie, a token a first-party client holds — is SubjectResolver's, which is consulted first and needs no form at all.
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 ¶
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 SubjectResolver ¶
type SubjectResolver interface {
ResolveSubject(ctx context.Context, req *http.Request) (*Subject, error)
}
SubjectResolver reports the resource owner when the request already carries proof of who they are.
It is the seam for the case a login form cannot serve: a first-party application holding a session cookie, a CLI holding a token, a service exchanging one credential for another. None of those has anything to type, and without this seam the only way to reach a subject is to POST a form — which is a strange thing to ask of a client whose parameters are all in the query string, and true only because that is where the seam used to be.
It is consulted on GET and on POST alike, before the login form is rendered and before SubjectAuthenticator is asked anything. A request carrying proof therefore never meets a form, and one carrying none behaves exactly as it did before — the seam is optional, and a Server built without one is unchanged.
What it owes back ¶
(nil, nil) means "not one of mine": no credential, an expired one, one this resolver does not recognize. The request carries on to the form, which is the honest answer to "I cannot say who this is" and the reason an expired session cookie still sends a browser to sign in again.
A Subject with a non-empty ID issues an authorization code and redirects, exactly as a successful form login would. An empty ID is refused: a token whose subject is the empty string authorizes whoever the resource server decides the empty string is.
An error ends the attempt at the client's registered redirect URI, with no form rendered. That is the difference between the two seams, and it is deliberate: a caller who presented a credential and had it rejected has nothing to type, so sending it a login page would be sending an answer it cannot use. Reserve the error for a resolver that is actually broken, or for a credential this deployment means to refuse outright — an absent or lapsed one is (nil, nil).
What it must not do ¶
Write to the ResponseWriter, for the same reason SubjectAuthenticator must not: it does not have one, and a seam that could render its own response could redirect somewhere this package never validated.
type SubjectResolverFunc ¶
SubjectResolverFunc adapts a function to SubjectResolver.
func (SubjectResolverFunc) ResolveSubject ¶
func (f SubjectResolverFunc) ResolveSubject(ctx context.Context, req *http.Request) (*Subject, error)
ResolveSubject implements SubjectResolver.
type TokenAuthenticator ¶
type TokenAuthenticator interface {
Authenticate(ctx context.Context, bearer string) (*AccessToken, error)
}
TokenAuthenticator resolves a bearer credential to the access token record behind it, and is the seam a Verifier stands on.
*Server implements it, and in the ordinary deployment — resource server and authorization server in one process, which is what opaque tokens require — that is what a Verifier is handed. It is an interface rather than a *Server so that a resource server holding only the Store, or a test wanting a controlled answer, has something to pass; it is deliberately the narrowest possible one, because a Verifier needs to look a token up and nothing else.
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.
type Verifier ¶
type Verifier struct {
// contains filtered or unexported fields
}
Verifier is the resource server's half of this package: the checks a request carrying a bearer token has to survive before a handler sees it.
The authorization server mints tokens; a Verifier decides whether the one in front of it authorizes this request, at this resource. Three checks, and the package would be incomplete without all three:
- the token is live. Delegated to TokenAuthenticator, which reads it from the Store — the lookup that opaque tokens are for, and the reason a revoked session stops working now rather than in fifteen minutes.
- the token names this resource. RFC 8707 puts the resource identifier in the token's audience so a token minted for one resource server cannot be replayed at another; a resource server that does not compare it has the field and not the property.
- the token carries the scopes the route requires.
The second is the one that gets left out, and it is the one this type exists for. Server.Authenticate hands back a live token whoever it was minted for, because it is the authorization server's lookup and the authorization server serves every resource behind it. A deployment running two resource servers against one authorization server — an HTTP API and an MCP endpoint, which is the shape that keeps arriving — has a cross-resource replay the moment one of them treats "Authenticate returned a token" as "this request is authorized".
It is not specific to any protocol built on top of it. An MCP server is an http.Handler and mounts behind Middleware; so does a REST API, and a resource server with its own response envelope calls Verify and writes its own.
func NewVerifier ¶
func NewVerifier(metadata *ResourceMetadata, tokens TokenAuthenticator, opts ...VerifierOption) (*Verifier, error)
NewVerifier builds the guard a protected resource puts in front of its handlers.
Both parameters are parameters rather than options, and neither has a default. The metadata is where the resource identifier lives — the string every audience check compares against and the document a client follows a 401 to — so a Verifier without one could publish a resource and authorize requests against a different name. The TokenAuthenticator is the lookup; an implicit one does not exist, since a resource server that cannot reach the store has nothing to verify against.
The resource identifier here and the one in the authorization server's WithResources have to be the same string, byte for byte. They are two deployment-supplied spellings of one identifier and this package compares them rather than deriving one from the other, because the authorization server and the resource server are usually not the same process.
Example ¶
The resource server's half: an MCP endpoint mounted behind this package's tokens.
Nothing here is MCP-specific, which is the point — mcpHandler stands in for whatever an SDK assembled, and a REST API takes exactly the same guard.
package main
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"github.com/primandproper/platform-go/v13/authentication/oauth2server"
"github.com/primandproper/platform-go/v13/authentication/oauth2server/memory"
)
func main() {
// The authorization server, in this process, so a revoked token stops
// working now rather than in fifteen minutes.
srv, err := oauth2server.NewServer("https://auth.example", memory.NewStore(),
oauth2server.SubjectAuthenticatorFunc(
func(context.Context, *http.Request) (*oauth2server.Subject, error) {
return &oauth2server.Subject{ID: "user_1"}, nil
}),
oauth2server.WithScopes("recipes:read", "recipes:write"),
// The same string the resource names itself by, below. Two spellings of
// one identifier, and a token's audience is compared against it.
oauth2server.WithResources("https://api.example/"))
if err != nil {
panic(err)
}
// What this resource server is, and where its tokens come from. A client
// that has never heard of either reads this document and finds out.
meta, err := oauth2server.NewResourceMetadata("https://api.example/",
[]string{srv.Issuer()},
oauth2server.WithResourceName("Recipes MCP"),
oauth2server.WithResourceScopes("recipes:read", "recipes:write"))
if err != nil {
panic(err)
}
guard, err := oauth2server.NewVerifier(meta, srv)
if err != nil {
panic(err)
}
// Whatever the MCP SDK assembled from twenty-five tool registrations. It is
// an http.Handler, so this is the whole of the mount.
mcpHandler := http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
// A tool that writes where the others read asks for itself, against the
// token the guard already looked up.
token, _ := oauth2server.TokenFromContext(req.Context())
if !token.HasScopes("recipes:write") {
res.WriteHeader(http.StatusForbidden)
return
}
res.WriteHeader(http.StatusOK)
})
// srv.Mount(router) puts the six OAuth endpoints on; meta.Mount(router)
// publishes the document; this is the resource itself.
protected := guard.Middleware("recipes:read")(mcpHandler)
// A client that was never configured with this server gets told where to
// look rather than simply refused.
res := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/mcp", http.NoBody)
protected.ServeHTTP(res, req)
fmt.Println(res.Code)
fmt.Println(res.Header().Get("WWW-Authenticate"))
}
Output: 401 Bearer resource_metadata="https://api.example/.well-known/oauth-protected-resource"
func (*Verifier) Metadata ¶
func (v *Verifier) Metadata() *ResourceMetadata
Metadata returns the document this Verifier guards a resource for, so a deployment that built the Verifier has the mountable document without carrying both values around.
func (*Verifier) Middleware ¶
func (v *Verifier) Middleware(requiredScopes ...string) routing.Middleware
Middleware admits only requests carrying a live token minted for this resource and holding every scope in requiredScopes.
It is a routing.Middleware, which is net/http's own middleware shape, so it wraps anything: a routing.Router registration, an http.ServeMux, or a handler some other library built. That last one is the case worth naming, because an MCP server is an http.Handler and this is the whole of what mounting one behind this package's tokens takes.
A handler underneath reads the token with TokenFromContext, which is how a per-operation scope check — one MCP tool that writes where the others read — gets at what this already looked up, rather than verifying a second time.
func (*Verifier) Verify ¶
func (v *Verifier) Verify(ctx context.Context, bearer string, requiredScopes ...string) (*AccessToken, error)
Verify checks a bearer credential against this resource and returns the token behind it.
requiredScopes is the scope set the caller must hold; naming none checks that the token is live and minted for this resource and stops there, which is what a resource server whose per-operation permissions are decided further in wants. That is the opposite convention from authorization/http's Require, which denies on an empty permission list — there an empty list is a configuration that lost its contents, here it is the ordinary way to say "any token for this resource".
The failures are separate sentinels rather than one error, because they are four different answers on the wire: ErrNoBearerToken is a request that never presented one, ErrNotFound (which ErrExpired wraps) is a token that is not usable, ErrTokenAudienceMismatch is a token for somewhere else, and ErrInsufficientScope is a good token that is not allowed to do this. Only the last is a 403 — the other three are answered by re-presenting a better credential, and that one is not.
A token carrying no audience at all fails, and there is no option to accept one. See audienceFor: a token minted with no resource indicator is exactly the token RFC 8707 exists to prevent, and a resource server that accepts it has opted every one of its siblings into being replayed against. A deployment seeing this refuse everything has clients that are not sending the resource parameter, and that is the thing to fix.
func (*Verifier) WriteChallenge ¶
func (v *Verifier) WriteChallenge(res http.ResponseWriter, err error)
WriteChallenge writes the refusal a Verify error calls for: the status, and the WWW-Authenticate header that tells a client which document to go read.
It is exported because Verify is: a resource server with its own response envelope verifies for itself and still wants the challenge header written the same way, and the mapping from sentinel to status and RFC 6750 error code is exactly the part worth not writing twice.
There is no body. RFC 6750 §3 puts a resource server's refusal in the header, and a JSON body beside it would be a second, unspecified copy of the same thing for clients to disagree about which to read.
Nothing is logged here. The one failure worth a line is a store that broke, and Verify already recorded that on the operation that saw it; every other refusal is a client presenting a credential this resource will not take, which is the counter's business and not the log's.
type VerifierOption ¶
type VerifierOption func(*verifierOptions)
VerifierOption configures a Verifier at construction.
A distinct type from Option, with distinct names, because this package already spends WithLogger and its siblings on Server. The alternative — one Option type covering both — would make NewVerifier accept WithLoginRenderer and silently do nothing with it. See ResourceOption, which is prefixed for the same reason.
func WithVerifierClock ¶
func WithVerifierClock(c clock.Clock) VerifierOption
WithVerifierClock replaces the clock a Verifier times its operations against.
func WithVerifierLogger ¶
func WithVerifierLogger(logger logging.Logger) VerifierOption
WithVerifierLogger attaches a logger. Absent means noop.
func WithVerifierMetricsProvider ¶
func WithVerifierMetricsProvider(metricsProvider metrics.Provider) VerifierOption
WithVerifierMetricsProvider attaches a metrics provider. Absent means noop.
func WithVerifierTracerProvider ¶
func WithVerifierTracerProvider(tracerProvider tracing.Provider) VerifierOption
WithVerifierTracerProvider attaches a tracer provider. Absent means noop.
Source Files
¶
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. |
|
internal/queries
Package queries is the authorization server's schema described as data: the four canonical table names, each table's columns in the order every read projects them, and the columns a write may leave NULL.
|
Package queries is the authorization server's schema described as data: the four canonical table names, each table's columns in the order every read projects them, and the columns a write may leave NULL. |
|
internal/queriesgen
command
Command queriesgen writes the canonical sqlc input for the authorization server's schema, one file per dialect, from authentication/oauth2server/database/internal/queries.
|
Command queriesgen writes the canonical sqlc input for the authorization server's schema, one file per dialect, from authentication/oauth2server/database/internal/queries. |
|
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. |