Skip to content

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.

this(UserCollection userCollection,
AuthorizationCodeStore codeStore,
const OAuth2Configuration configuration = OAuth2Configuration(),
AuthorizationServerProvider authServerProvider = null,
ClientProvider clientProvider = null,
ScopeValidator scopeValidator = null);
ParameterRequiredPurpose
userCollectionyesWhere users and their tokens live
codeStoreyesAuthorization-code storage; OAuth2 throws when it is null
configurationnoRoute paths and cookie/rotation opt-ins
authServerProvidernoDecides which hosts an authorize request may redirect to
clientProvidernoValidates client credentials beyond the default user-token flow
scopeValidatornoRejects tokens requesting scopes the client may not have
grant_typeClassBehavior
passwordPasswordGrantAccessUsername and password against the user collection
authorization_codeAuthorizationCodeGrantAccessCode exchange, PKCE-verified
refresh_tokenRefreshTokenGrantAccessExchange a refresh token for a fresh access token
anything elseUnknownGrantAccessResponds 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.

RefreshTokenGrantAccess has two fields that govern rotation:

FieldDefaultMeaning
rotateset from OAuth2Configuration.rotateRefreshTokenIssue a new refresh token and retire the presented one
graceWindow40.secondsHow 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:

MethodGuarantee
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.

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.

OAuth2Configuration carries the route paths (see the tutorial’s table) plus:

FieldDefaultMeaning
style""Path of a stylesheet embedded into the served HTML; requests to it bypass bearer validation
refreshCookiedisabledRefreshCookieConfig, below
rotateRefreshTokenfalseTurn on refresh-token rotation
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.

HookFiresTypical use
tokenIssuedAfter every successful issuance, refresh grants includedAttach a session cookie
tokenRevokedAfter a revocation (logout)Clear the cookie the issuance hook set

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:

ResourceMetadata URL
https://example.orghttps://example.org/.well-known/oauth-protected-resource
https://example.org/mcphttps://example.org/.well-known/oauth-protected-resource/mcp

The MCP policy registers this handler for its own base path automatically.

FieldMeaning
authServerUrlBase URL of the authorization server issuing tokens for this resource
issuerIssuer identifier to advertise; defaults to the origin the request arrived on

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.

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"

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.