Skip to content

OAuth2

OAuth2Client 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 OAuth2Config. Profile mapping stays in your app’s Strategy so the client itself never sees your principal type.

OAuth2Module::for_root(None) registers an OAuth2Client as global infrastructure, configured via NESTRS_AUTHN__* env vars. Pin the config in code by passing an OAuth2Config.

apps/auth/src/module.rs
#[module(
imports = [
ConfigModule::for_root(),
AuthnModule::for_root(None),
OAuth2Module::for_root(None),
OAuthHttpModule,
],
)]
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 {
#[get("/authorize")]
#[public]
#[use_guards(OAuthGuard)]
async fn authorize(&self) {}
#[get("/callback")]
#[public]
#[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(),
)?))
}
}

Both routes are #[public] — the provider redirect lands without a bearer token, so AuthGuard would refuse. The OAuthGuard is your app’s Strategy<Principal = Caller> running the flow.

OAuth2Config 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_AUTHN__CLIENT_IDlength ≥ 1
client_secretNESTRS_AUTHN__CLIENT_SECRETlength ≥ 1
auth_urlNESTRS_AUTHN__AUTH_URLlength ≥ 1 + parses as URL
token_urlNESTRS_AUTHN__TOKEN_URLlength ≥ 1 + parses as URL
redirect_urlNESTRS_AUTHN__REDIRECT_URLabsolute URL
userinfo_urlNESTRS_AUTHN__USERINFO_URLlength ≥ 1
scopesNESTRS_AUTHN__SCOPEScomma-separated list

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

let auth = self.client.authorize(&self.jwt)?;
// auth.url — the provider redirect URL
// auth.transaction — short-lived JWT cookie: CSRF state + PKCE verifier

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 an access token:

let access_token = self.client.exchange(
&self.jwt,
&transaction_cookie,
state_from_query,
code_from_query,
).await?;

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:

#[derive(Debug, Deserialize)]
struct GoogleUser {
sub: String,
email: String,
name: String,
}
let profile: GoogleUser = self.client.userinfo(&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 — OAuth2Client deliberately never sees your principal type.

Authorization — the redirect leg’s return value

Section titled “Authorization — the redirect leg’s return value”
pub struct Authorization {
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
InvalidCredentialsinvalid_credentials401
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.

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