Skip to content

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.

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.

Terminal window
cargo add nest-rs --features oauth-client

Enabling social brings it along: every social provider composes this client as its shared flow.

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.

apps/auth/src/module.rs
#[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:

crates/features/src/authn/http/controller.rs
#[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.

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.

FieldEnv varValidation
client_idNESTRS_OAUTH_CLIENT__CLIENT_IDlength ≥ 1
client_secretNESTRS_OAUTH_CLIENT__CLIENT_SECRETlength ≥ 1
auth_urlNESTRS_OAUTH_CLIENT__AUTH_URLlength ≥ 1 + parses as URL
token_urlNESTRS_OAUTH_CLIENT__TOKEN_URLlength ≥ 1 + parses as URL
redirect_urlNESTRS_OAUTH_CLIENT__REDIRECT_URLabsolute URL
userinfo_urlNESTRS_OAUTH_CLIENT__USERINFO_URLlength ≥ 1
scopesNESTRS_OAUTH_CLIENT__SCOPEScomma-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.

authorize produces the URL the browser follows and the signed transaction cookie that ties the round-trip together:

crates/features/src/authn/http/controller.rs
let auth = self.client.authorize(&self.jwt, "github")?;
// auth.url — the provider redirect URL
// auth.transaction — short-lived JWT cookie: CSRF state + PKCE verifier

The 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 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:

crates/features/src/authn/http/controller.rs
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.

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:

crates/features/src/authn/http/controller.rs
#[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”
nest_rs::oauth::client::AuthorizationRedirect
pub struct AuthorizationRedirect {
pub url: String,
pub transaction: String,
}
  • url — set Location: <url> and return 302.
  • transaction — set as an HttpOnly, Secure, SameSite=Lax cookie 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.

A token-issuing controller that fronts the OAuth flow (or its client_credentials grant) emits RFC 6749 wire codes via TokenError:

VariantWire stringStatus
UnsupportedGrantunsupported_grant_type400
InvalidScopeinvalid_scope400
InvalidClientinvalid_client401
Sign(inner)server_error500

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.

  • 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 Strategy trait the callback runs.
  • Threat model — what PKCE + CSRF cookies catch, and what they don’t.