OAuth2
Authorization-code flow with PKCE — OAuthClient builds the redirect, validates the callback, and the JWT carries the CSRF state stateless.
OAuthClient runs the authorization-code flow against any provider
that speaks RFC 6749. The flow uses PKCE end-to-end and keeps the
CSRF state in a signed cookie — no server-side session, no Redis
lookup, no in-memory map. The redirect leg returns a URL the browser
follows; the callback leg validates the state, exchanges the code
for an access token, and hands you the provider’s user info.
The implementation wraps the oauth2 crate,
configured against the provider via OAuthClientConfig. Profile mapping
stays in your app’s Strategy so the
client itself never sees your principal type.
Install
Section titled “Install”The OAuth 2.0 client role — this app obtaining a token from somebody else’s authorization server — is its own capability. RFC 6749 §1.1 names four roles, and the crate that answers who the caller is is a different one.
cargo add nest-rs --features oauth-clientEnabling social brings it along: every social provider composes this client
as its shared flow.
Wire it in
Section titled “Wire it in”OAuthClientModule::for_root(None) registers an OAuthClient as global
infrastructure, configured via NESTRS_OAUTH_CLIENT__* env vars. Pin the
config in code by passing an OAuthClientConfig.
#[module( imports = [ ConfigModule::for_root(), AuthnModule::for_root(None), OAuthClientModule::for_root(None), ],)]pub struct AuthModule;A controller then exposes the two HTTP legs. The exemplar in
crates/features/src/oauth/ ships the full handler:
#[controller(path = "/")]pub struct OAuthController { #[inject] issuer: Arc<TokenIssuer>,}
#[routes]impl OAuthController { // The redirect leg is a plain handler: a `Strategy` maps a request to a // principal and never issues a transport response, so the 302 and the // cookie are built here. `#[public]` because there is no credential yet. #[get("/authorize")] #[public] async fn authorize(&self) -> Result<Response> { let auth = self.client.authorize(&self.jwt, "github")?; Ok(Response::builder() .status(StatusCode::FOUND) .header(header::LOCATION, auth.url) .header( header::SET_COOKIE, format!( "nestrs_oauth_tx={}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age={}", auth.transaction, OAuthClient::TRANSACTION_TTL_SECS, ), ) .finish()) }
// NOT `#[public]`: `OAuthGuard` authenticates *from* `code`/`state`, so it // must be allowed to reject. A forged callback is then the documented // `401`; marked public, the guard would absorb the denial and the handler // would fail on the missing principal instead. #[get("/callback")] #[use_guards(OAuthGuard)] async fn callback(&self, caller: Ctx<Caller>) -> Result<Json<AccessTokenDto>> { Ok(Json(self.issuer.issue( Some(caller.user_id), caller.org_id, caller.roles.clone(), )?)) }}Only /authorize is #[public]: it runs before any credential exists, and it
binds no guard because there is nothing yet to authenticate. /callback is the
opposite — code and state are the credential, and OAuthGuard (your app’s
Strategy<Principal = Caller>) is what verifies them. Marking it #[public]
tells AuthnGuard to admit a rejected credential as anonymous, which turns a
forged-callback 401 into a failure on the missing principal — invisible to any
alert, WAF rule or rate limit keyed on 401.
Configuration
Section titled “Configuration”OAuthClientConfig lives in nest-rs-oauth-client and reads the oauth_client
namespace — the OAuth client role has its own crate, so it has its own
namespace rather than borrowing the one belonging to the crate that answers
who the caller is.
OAuthClientConfig is a validator-validated struct: every URL field
must be non-empty (length ≥ 1) or the boot fails loudly. All fields
follow the dual-path rule.
| Field | Env var | Validation |
|---|---|---|
client_id | NESTRS_OAUTH_CLIENT__CLIENT_ID | length ≥ 1 |
client_secret | NESTRS_OAUTH_CLIENT__CLIENT_SECRET | length ≥ 1 |
auth_url | NESTRS_OAUTH_CLIENT__AUTH_URL | length ≥ 1 + parses as URL |
token_url | NESTRS_OAUTH_CLIENT__TOKEN_URL | length ≥ 1 + parses as URL |
redirect_url | NESTRS_OAUTH_CLIENT__REDIRECT_URL | absolute URL |
userinfo_url | NESTRS_OAUTH_CLIENT__USERINFO_URL | length ≥ 1 |
scopes | NESTRS_OAUTH_CLIENT__SCOPES | comma-separated list |
OAuthClient::new runs validate() first, then constructs the
underlying BasicClient. The HTTP backend refuses redirects during
the token exchange — following them is an SSRF risk per the oauth2
crate’s own guidance.
The redirect leg
Section titled “The redirect leg”authorize produces the URL the browser follows and the signed
transaction cookie that ties the round-trip together:
let auth = self.client.authorize(&self.jwt, "github")?;// auth.url — the provider redirect URL// auth.transaction — short-lived JWT cookie: CSRF state + PKCE verifierThe provider argument is the registry key of the provider this flow is for.
It is load-bearing: it is minted into the transaction, and exchange
refuses a transaction issued for a different provider (AuthError::Failed("OAuth provider mismatch")) before the CSRF compare — which is what stops a
transaction being replayed across providers.
The transaction is a JSON Web Token signed by your app’s
JwtService, carrying the random CSRF state, the PKCE verifier,
and exp. The browser stores it as a cookie; the callback leg
verifies the signature before trusting any of it.
The framework generates a fresh PKCE pair per flow
(PkceCodeChallenge::new_random_sha256) and a fresh CSRF token
(CsrfToken::new_random) — never reuse across requests.
The callback leg
Section titled “The callback leg”The provider redirects to redirect_url with state and code
query parameters. exchange verifies the cookie’s signature,
compares its CSRF state to the query string, and only then trades
the code for a TokenSet:
let tokens = self.client.exchange( &self.jwt, "github", // the same provider the transaction was minted for &transaction_cookie, state_from_query, code_from_query,).await?;TokenSet carries the access_token (and a refresh_token /
id_token when the provider returns one), so an OIDC provider can read
identity from the id_token rather than a userinfo call.
The CSRF check runs before the exchange — never the other way
around. A mismatched state returns AuthError::Failed("OAuth state mismatch") and the flow stops.
User info → principal
Section titled “User info → principal”userinfo<T> fetches the provider’s profile endpoint with the access
token as a bearer and deserializes the body into your app’s
provider-specific shape:
#[derive(Debug, Deserialize)]struct GoogleUser { sub: String, email: String, name: String,}
let profile: GoogleUser = self.client.userinfo(&tokens.access_token).await?;Mapping the provider profile into your principal (looking the user up
in your DB, creating one if absent, issuing your app’s JWT) is the
Strategy’s job — OAuthClient deliberately never sees your
principal type.
AuthorizationRedirect — the redirect leg’s return value
Section titled “AuthorizationRedirect — the redirect leg’s return value”pub struct AuthorizationRedirect { pub url: String, pub transaction: String,}url— setLocation: <url>and return302.transaction— set as anHttpOnly,Secure,SameSite=Laxcookie scoped to the callback path.
The transaction inherits the JwtService’s expiry, so a flow that
takes longer than your expires_in to complete fails the verify
step. Keep the JWT TTL aligned with how long a user typically takes
to consent.
Token endpoint errors
Section titled “Token endpoint errors”A token-issuing controller that fronts the OAuth flow (or its
client_credentials grant) emits RFC 6749 wire codes via
TokenError:
| Variant | Wire string | Status |
|---|---|---|
UnsupportedGrant | unsupported_grant_type | 400 |
InvalidScope | invalid_scope | 400 |
InvalidClient | invalid_client | 401 |
Sign(inner) | server_error | 500 |
The Sign variant collapses any internal signing failure to the
opaque RFC code on the wire; the inner anyhow::Error stays attached
for tracing. A rename of these strings breaks every conforming
client — they are wire contracts, not display strings.
Going further
Section titled “Going further”- Social login — mount GitHub/Google (or your own provider) over this client through an open, discovered provider contract.
- JWT — the access token your callback issues.
- Authentication — the
Strategytrait the callback runs. - Threat model — what PKCE + CSRF cookies catch, and what they don’t.