OAuth Server

OAuth Server

On this page

An OAuth 2.0 authorization server in rainier-oauth-server, behind the framework's oauth-server feature: the token endpoint, the two halves of the authorization endpoint, personal access tokens, and the guard that accepts what it issues.

rainier-framework = { version = "2", features = ["oauth-server"] }

This is the server side — your application issuing tokens to other apps. Letting users sign in with somebody else's accounts is Single Sign-On.

AuthorizationServerthe grants, the authorization endpoint, personal access tokens, revocation, purging
OAuthGuard<U>a rainier-auth guard for bearer access tokens; scopes become abilities
OAuthStore / MemoryStorewhere clients, tokens and codes are kept
TokenSealer / EncrypterSealerhow refresh tokens and codes are sealed
AccessTokenSigner / AccessTokenVerifierRS256 access tokens
Scopeswhich scopes exist, and namespace inheritance
AuthorizationSessionthe pending authorization, across the consent screen
pkcethe client's half of PKCE — verifier() and challenge()

Quick start

The whole flow — two clients, authorization code with PKCE, refresh rotation, client credentials, a personal access token, a scoped API route, revocation — runs offline in one example:

cargo run -p rainier-framework --example oauth_server --features oauth-server

examples/oauth_server.rs is the reference for everything below.

1. Turn it on.

rainier-framework = { version = "2", features = ["oauth-server"] }

2. Keys. Access tokens are RS256, so the server needs an RSA private key; API servers that only accept tokens need the public half. Generate a pair once and keep it in your secret store:

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out oauth-private.key
openssl pkey -in oauth-private.key -pubout -out oauth-public.key
let signer = AccessTokenSigner::from_pem(&env.string("OAUTH_PRIVATE_KEY", ""))?;

AccessTokenSigner::generate(2048) makes a throwaway key for tests and local development — never for a deployment, where a new key per boot logs every client out.

Refresh tokens and codes are sealed with the application's own encrypter, so they rotate with APP_KEY:

let sealer = Arc::new(EncrypterSealer::new(encrypter.clone()));

3. A store. MemoryStore for tests; for a deployment, implement OAuthStore over your tables (four of them: clients, access tokens, refresh tokens, codes — the record types in model are the columns). Taking over an existing PHP server's tables instead is rainier-compat.

4. The server, and its routes.

let server = Arc::new(
    AuthorizationServer::new(store.clone(), sealer, signer, hasher.clone())
        .scopes(Scopes::new().with("account", "Read your account").with("vehicles", "Your vehicles")),
);

// No session or CSRF on the token endpoint: the client authenticates itself.
let token = server.clone();
router.post("/oauth/token", move |request: Req| {
    let server = token.clone();
    async move { server.respond_to_token_request(&request).await }
});

// The authorization endpoint lives in the user's browser session, behind login.
router.group(GroupAttributes::new().middleware(web_with_login()), |router| {
    router.get("/oauth/authorize", show_consent);     // validate → stash → your consent page
    router.post("/oauth/authorize", approve);         // take → complete(.., true)
    router.delete("/oauth/authorize", deny);          // take → complete(.., false)
});

The two consent handlers are written out in full below and in the example.

5. Protect the API.

let guard = OAuthGuard::new("api", server.verifier(), store, users.clone());
let auth = Arc::new(AuthManager::<User>::new("api").register(Arc::new(guard)));

router
    .get("/api/vehicles", list_vehicles)
    .middleware(Authenticate::new(auth.clone()))
    .middleware(RequireAbility::all(["vehicles"]));    // the scope check

A token without vehicles is answered 403; no token, a junk one, or a revoked one, 401. A separate API service that only accepts tokens builds its guard from AccessTokenVerifier::from_pem(public_key) and a store pointing at the same tables.


Wiring it

use rainier_framework::oauth_server::*;

let store: Arc<dyn OAuthStore> = Arc::new(MyStore::new(db.clone()));
let sealer = Arc::new(EncrypterSealer::new(encrypter.clone()));
let signer = AccessTokenSigner::from_pem(&env.string("OAUTH_PRIVATE_KEY", ""))?;

let server = Arc::new(
    AuthorizationServer::new(store.clone(), sealer, signer, hasher.clone())
        .scopes(
            Scopes::new()
                .with("account", "Manage your account")
                .with("vehicles", "See and control your vehicles"),
        )
        .lifetimes(Lifetimes {
            access_token: Duration::from_secs(24 * 3600),
            refresh_token: Duration::from_secs(365 * 24 * 3600),
            ..Lifetimes::default()
        }),
);

// The API side.
let guard = OAuthGuard::new("api", server.verifier(), store, users.clone());
let auth = AuthManager::<User>::new("web").register(Arc::new(guard));
EndpointMethod
POST /oauth/tokenserver.respond_to_token_request(&request)
GET /oauth/authorizeserver.validate_authorization_request(&request), then your consent screen
POST /oauth/authorizeAuthorizationSession::take, then server.complete_authorization_request(…, true)
DELETE /oauth/authorizethe same with false
POST /oauth/revokeserver.respond_to_revocation_request(&request) (RFC 7009)

Each is a one-line handler; the server owns the protocol and the application owns the routes, the login page and the consent screen.

The grants

GrantRefresh tokenNotes
authorization_codeyesPKCE S256 (and plain, unless turned off); required for public clients
refresh_tokenyesrotated: the old one is spent, the old access token revoked
client_credentialsnoconfidential clients only; the token's sub is the client
passwordyesonly once .password_grant(owners) is given — see below
personal accessnonot a grant at the endpoint: issue_personal_access_token(user, name, scopes, provider)

A client may use a grant only if its grant_types lists it. A client with a secret_hash is confidential and must authenticate (Basic auth or body parameters); one without is public.

The password grant is opt-in

RFC 9700 says not to use it: it teaches users to type their password into an app that is not yours. It exists here because first-party mobile apps were built on it and have to keep working while they move to the code flow. UserProviderOwners checks credentials through the application's existing rainier-auth user provider.

Spending a code is claiming it

A code and a refresh token are each good for one exchange. The server revokes the row first and issues only if that revocation is the one that took, so two requests racing with one code get one token between them. An OAuthStore over SQL does this with UPDATE … SET revoked = 1 WHERE id = ? AND revoked = 0 and the affected-row count — the store contract says so.

The authorization endpoint

async fn show(request: Req, user: AuthenticatedUser<User>) -> Result<Response> {
    let authorization = match server.validate_authorization_request(&request).await {
        Ok(authorization) => authorization,
        Err(error) => return Ok(error.into_response()),
    };
    if server.previously_approved(&authorization, &user.id()).await? {
        return Ok(server.complete_authorization_request(&authorization, &user.id(), true).await);
    }
    let auth_token = AuthorizationSession::stash(&request, &authorization)?;
    views.render("oauth.consent", context! { authorization, auth_token })
}

async fn approve(request: Req, user: AuthenticatedUser<User>) -> Result<Response> {
    let authorization = AuthorizationSession::take(&request, &request.input_or("auth_token", ""))?;
    Ok(server.complete_authorization_request(&authorization, &user.id(), true).await)
}

An error from validate_authorization_request carries a redirect only once the redirect URI is known to be the client's. An unknown client or an unregistered redirect URI is answered to the user, never redirected to — redirecting to an unverified URI would hand the code to whoever chose it.

Redirect URIs match exactly, except an http loopback URI (127.0.0.1, [::1]) matches on any port, as RFC 8252 asks for native apps.

The auth_token is single-use and compared in constant time; without it, a page on another site could post "approve" from inside the user's logged-in browser.

Scopes

Scopes::new().with(name, description) declares them. A request naming an unknown one is refused by name. Scopes are namespaces: holding vehicles grants vehicles:read, never the reverse; * grants everything, and survives only grants with no consent screen (password, client credentials, personal access) — a third party cannot talk a user into "everything".

The guard hands a token's scopes to rainier-auth as Abilities, each as itself and as scope:*, so RequireAbility is the scope middleware and agrees with the inheritance above.

Access tokens

RS256 JWTs:

Claim
audthe client id
jtithe token id — the row revocation is checked against
iat, nbf, exp
subthe user, or the client for a client-credentials token
scopesan array

AccessTokenSigner::with_kid adds a kid, issued_by an iss, with_claim a static claim. Verification fixes the algorithm to RS256 from the key, never from the header, accepts integer or fractional timestamps and a string or array aud, and also_accepting a previous key during rotation.

The guard checks more than the signature: the token's row must exist and not be revoked, and its client must exist and not be revoked. That is a read per request, and it is what makes a token revocable before it expires.

Housekeeping

server.revoke(access_token_id) revokes an access token and its refresh tokens. server.purge(retention) deletes revoked rows and rows that expired more than retention ago — schedule it daily.

What is not here

The implicit grant (RFC 9700 removed it), the device authorization grant, token introspection (RFC 7662), dynamic client registration, and OpenID Connect — an id_token is a different promise to a different party.

Taking over from a PHP OAuth server

rainier-compat reads and writes the tables and token formats a league/oauth2-server deployment already has, so the cutover logs nobody out — see Migrating from a PHP OAuth server. The compat_php example runs that side offline:

cargo run -p rainier-framework --example compat_php --features compat-php-oauth,compat-php-webauthn

This page is the framework’s docs/oauth-server.md at 7b8cb15. Found a mistake? Edit it on GitHub.

Type to search 2.0.0 docs.