OAuth2
crate.auth.protocols.oauth2 is a self-contained OAuth 2.1 authorization server. The OAuth2 class owns the collaborators and exposes the surface the router consumes; the request handling lives in OAuth2Endpoints, rebuilt per call so post-construction changes to hooks take effect.
For a task-oriented walkthrough see the authentication tutorial.
Constructing the Server
Section titled “Constructing the Server”this(UserCollection userCollection, AuthorizationCodeStore codeStore, const OAuth2Configuration configuration = OAuth2Configuration(), AuthorizationServerProvider authServerProvider = null, ClientProvider clientProvider = null, ScopeValidator scopeValidator = null);| Parameter | Required | Purpose |
|---|---|---|
userCollection | yes | Where users and their tokens live |
codeStore | yes | Authorization-code storage; OAuth2 throws when it is null |
configuration | no | Route paths and cookie/rotation opt-ins |
authServerProvider | no | Decides which hosts an authorize request may redirect to |
clientProvider | no | Validates client credentials beyond the default user-token flow |
scopeValidator | no | Rejects tokens requesting scopes the client may not have |
Grants
Section titled “Grants”grant_type | Class | Behavior |
|---|---|---|
password | PasswordGrantAccess | Username and password against the user collection |
authorization_code | AuthorizationCodeGrantAccess | Code exchange, PKCE-verified |
refresh_token | RefreshTokenGrantAccess | Exchange a refresh token for a fresh access token |
| anything else | UnknownGrantAccess | Responds with an OAuth2 error |
verifyPkce(codeVerifier, codeChallenge, method) implements RFC 7636. Only S256 is accepted — any other method, plain included, returns false. The verifier is SHA-256 hashed, base64url-encoded, and stripped of padding before comparison.
Refresh Rotation
Section titled “Refresh Rotation”RefreshTokenGrantAccess has two fields that govern rotation:
| Field | Default | Meaning |
|---|---|---|
rotate | set from OAuth2Configuration.rotateRefreshToken | Issue a new refresh token and retire the presented one |
graceWindow | 40.seconds | How long the retired token keeps working as an alias of its successor |
The grace window exists for the render-then-navigate case: a server-side render consumes the refresh token, but the rotated cookie never reaches the browser, so the browser retries with the old one.
Rotation goes through TokenRotationStore:
| Method | Guarantee |
|---|---|
pushToken(userId, token) | Atomic append; two concurrent appends both survive |
claimRotation(oldToken, newSuccessor, expire) | Compare-and-set. Returns the winning successor — newSuccessor when this call won, the concurrent winner’s name otherwise, "" when the token is gone |
pullToken(anchorToken, tokenName) | Removes an orphaned token after a lost claim |
MongoTokenRotationStore implements this with a single-document findAndModify; all token state lives in one user document, so no transaction is needed. CrateTokenRotationStore is the single-process equivalent used by UserCrateCollection when no store is passed.
An unknown refresh token yields an OAuth2 error rather than a 500 — UserCollection.byToken throws UserNotFoundException, which the grant treats as an empty token.
Login Redirect Host
Section titled “Login Redirect Host”DefaultAuthorizationServerProvider decides which domain the authorize endpoint redirects to. It keeps the user on the host the request arrived through, so a deployment serving several domains signs in on the domain it authorizes on:
auto provider = new DefaultAuthorizationServerProvider( "https://example.com", ["example.org", "staging.example.net"]);
auto oauth = new OAuth2(userCollection, new AuthorizationCodeStore(), config, provider);The configured domain and every entry in allowedHosts are honoured, along with their subdomains. X-Forwarded-Host is read for the incoming host, port stripped and the first entry taken from a comma-separated list; anything not on the allow-list falls back to the configured domain, so a spoofed header cannot turn the login redirect into an open redirect.
Configuration
Section titled “Configuration”OAuth2Configuration carries the route paths (see the tutorial’s table) plus:
| Field | Default | Meaning |
|---|---|---|
style | "" | Path of a stylesheet embedded into the served HTML; requests to it bypass bearer validation |
refreshCookie | disabled | RefreshCookieConfig, below |
rotateRefreshToken | false | Turn on refresh-token rotation |
RefreshCookieConfig
Section titled “RefreshCookieConfig”struct RefreshCookieConfig { bool enabled = false; string name = "refresh_token"; long maxAge = 4 * 7 * 24 * 3600; bool secure = true; Cookie.SameSite sameSite = Cookie.SameSite.lax; bool httpOnly = true; string path = "/"; string companionName = "";}refreshCookieSpec(config, token) and refreshCookieSpec(config, token, maxAge) build a CookieSpec without needing an HTTPServerResponse, so cookie attributes are testable in isolation. A maxAge of 0 produces a session cookie — vibe.d emits no Max-Age for it.
shouldIssueRefreshCookie(config, result) is true only when the cookie is enabled and the token response actually carries a string refresh_token.
Two optional delegates fire around token lifecycle events. Both default to null, which keeps the plain JSON-body behavior.
| Hook | Fires | Typical use |
|---|---|---|
tokenIssued | After every successful issuance, refresh grants included | Attach a session cookie |
tokenRevoked | After a revocation (logout) | Clear the cookie the issuance hook set |
Protected Resource Metadata (RFC 9728)
Section titled “Protected Resource Metadata (RFC 9728)”crate.auth.protocols.oauth2.resource is protocol-agnostic — REST, MCP, and GraphQL resource servers all use it.
import crate.auth.protocols.oauth2.resource;
router.get(protectedResourceMetadataUrl("", "/mcp"), protectedResourceMetadataHandler(OAuth2ResourceConfig(), "/mcp"));protectedResourceMetadataUrl(baseUrl, resourcePath) inserts the well-known segment between the origin and the resource path, so a client holding the resource URL can always derive where the metadata lives:
| Resource | Metadata URL |
|---|---|
https://example.org | https://example.org/.well-known/oauth-protected-resource |
https://example.org/mcp | https://example.org/.well-known/oauth-protected-resource/mcp |
The MCP policy registers this handler for its own base path automatically.
OAuth2ResourceConfig
Section titled “OAuth2ResourceConfig”| Field | Meaning |
|---|---|
authServerUrl | Base URL of the authorization server issuing tokens for this resource |
issuer | Issuer identifier to advertise; defaults to the origin the request arrived on |
Behind a Proxy
Section titled “Behind a Proxy”RFC 9728 identifies a resource by its full URL, and clients reject metadata whose resource differs from the URL they called. When a proxy strips a mount prefix, forwardedPrefix(req) reads X-Forwarded-Prefix and puts it back on the advertised resource. The auth endpoints are deliberately left off the prefix — they are served at the origin, not under the mount.
normalizeForwardedPrefix(raw) normalizes what the header carries: an empty value and a bare / both become "", and a trailing slash is dropped, so /api-v1/ and /api-v1 describe the same mount.
Rejecting a Token
Section titled “Rejecting a Token”respondOAuth2Unauthorized(res, metadataUrl, "invalid_token", ["read", "write"]);This sets a WWW-Authenticate header built by bearerChallenge, pointing the client at the metadata document so it knows where to get a token:
Bearer resource_metadata="https://example.org/.well-known/oauth-protected-resource"Dynamic Client Registration
Section titled “Dynamic Client Registration”POST to registrationPath (/auth/register by default) implements RFC 7591. The response echoes the submitted grant_types, falling back to ["authorization_code"] when none is given. GET <clientLookupPrefix><clientId> returns public client metadata.
Related
Section titled “Related”- Authentication tutorial — wiring, middleware, and cookie flows
- Authentication internals — token extraction and auth policies
- MCP policy — how an MCP endpoint advertises its metadata