Skip to main content
nestrs-oauth2 covers both sides of the OAuth2 conversation. As a client it drives the authorization-code grant (with PKCE S256), the client-credentials grant, and the refresh-token grant, plus RFC 7009 token revocation. As a resource server it verifies incoming bearer JWTs against your IdP’s JWKS endpoint with algorithm pinning and automatic key rotation. Thin social provider wrappers pin the endpoints for Google, GitHub, Microsoft, and Apple, and an OAuth2Guard turns a verified token into route protection.

Enable the crate

There are two ways to opt in. The main-crate feature is the shortest path when you want the client, the resource server, and the guard together:
The oauth2 feature on nestrs pulls in nestrs-oauth2 with the client, resource-server, and guard features enabled, re-exports the whole crate as nestrs::oauth2, root-exports OAuth2Principal / OAuth2Identity / OAuth2PrincipalMissing / install_oauth2_middleware, and adds the NestApplication::use_oauth2 builder. It does not enable the social wrappers — for those, depend on nestrs-oauth2 directly. Feature flags on nestrs-oauth2 (all off by default):
Two helper types are named through the crates nestrs-oauth2 builds on. ValidationConfig::new(...) takes a jsonwebtoken::Algorithm, and generate_pkce() returns the oauth2 v5 crate’s challenge/verifier pair — add jsonwebtoken = "9" and oauth2 = "5" as direct dependencies when your code names those types (for example, when persisting a PkceCodeVerifier between requests).
The compound feature authn-oauth2 (on nestrs) enables oauth2 together with authn, so a route can use the Principal extractor to read either a first-party JWT or an OAuth2 token identity.

The OAuth2 client

Confidential and public clients

OAuth2Options::new builds a confidential client from the four values every IdP asks for. OAuth2Options::public_client omits the secret entirely for PKCE-only flows (RFC 8252 §7.2 — browser and mobile clients).
OAuth2Client::new validates eagerly — an empty client_id or malformed endpoint URLs return OAuth2Error::InvalidConfig at construction, not on first use. The client is cheap to clone (all Arcs and URLs). Builders: with_timeout (default 30 s per token/revoke/userinfo round-trip), with_revoke_url, with_userinfo_url, and with_cache (see sharing a token cache).

Authorization code + PKCE

1

Generate the challenge and verifier

Both halves come from one call (PkceCodeChallenge::new_random_sha256() under the hood — RFC 7636 S256). The verifier must stay on your server until step 3; the challenge is public.
2

Redirect the user to the IdP

authorize_url returns an AuthorizeUrl { url, state } pair. The state is a fresh CsrfToken (also written into the URL as the state query parameter) — persist it server-side alongside the verifier. Use the extra map for provider-specific parameters such as prompt=consent.
3

Validate the redirect

When the IdP redirects back, compare the state query parameter with the value you persisted. A mismatch (or an unknown state) is a forged redirect — reject it before touching the code.
4

Exchange the code for tokens

exchange_code takes the verifier by value (PkceCodeVerifier isn’t Clone), so it can only be spent once — reusing a verifier is a security bug and the signature makes it a compile-time one.

TokenSet

Every grant returns the same TokenSet shape: id_token and scope come from the raw wire JSON — the underlying oauth2 crate’s typed response drops them, nestrs-oauth2 preserves them.

Refresh, revoke, and userinfo

Client credentials

The machine-to-machine grant authenticates the client itself (no user context). PKCE does not apply. Requires a client_secretpublic_client options return OAuth2Error::InvalidConfig.

Share a token cache

TokenCache is a process-wide store keyed by access token. Pass one Arc<TokenCache> to several OAuth2Clients (same IdP, different scopes or redirect URIs) so concurrent refreshes coalesce instead of racing:
Every successful exchange/refresh populates the cache automatically. Look up with client.cached(access_token) or cache.by_refresh(refresh_token); inspect size with len / is_empty. The cache evicts nothing on its own — you decide retention.

Persist tokens across restarts

TokenSet deliberately doesn’t implement Serialize (Instant can’t). Use the wire-friendly TokenSetDto (nestrs_oauth2::client::TokenSetDto), which swaps expires_at for expires_at_unix (UNIX epoch seconds):

Resource server: verify incoming tokens

When your app is the one being called with a bearer token, JwtVerifier checks the token’s signature against the IdP’s JWKS endpoint (RFC 7517) and validates the claims:
from_url builds the JWKS cache and performs the initial fetch eagerly. To control the cache yourself, construct JwksCache::new(url) (plus .with_refresh_window(Duration), default 15 minutes) and JwtVerifier::new(Arc::new(cache), validation). verify(token) returns a TokenData { header, claims } pair of serde_json::Values (each IdP’s claim shape differs, so claims stay generic). It enforces:
  • Algorithm pinning — the token’s alg must be in your accepted set before any key lookup. The default set is EdDSA only; add RS256 / ES256 / PS256 via ValidationConfig::new(...) or with_algorithms. Pinning blocks the alg=none downgrade and cross-algorithm confusion (an RS256 token replayed as HS256).
  • exp and nbfnbf validation is on (RFC 7519 §4.1.5), with your clock-skew leeway.
  • iss / aud — enforced when with_issuer / with_audience are set. Without an audience configured, aud checking is explicitly disabled rather than left to defaults.
Key rotation is handled per kid: the cache reads are lock-free on the hot path; a miss triggers a single-flight refresh (concurrent misses share one HTTP fetch); a kid that’s still unknown afterwards returns OAuth2Error::UnknownKid. So an IdP that rotates its keys mid-flight doesn’t break in-flight requests — the next token with the new kid triggers exactly one re-fetch. Errors are typed: OAuth2Error::Grant(GrantError) for RFC 6749 §5.2 codes (invalid_grant, invalid_client, …), TokenEndpoint { status, body } when the endpoint returns something non-standard, JwksFetch / InvalidSignature / InvalidClaim / Validation / UnknownKid for the resource-server side.

Protect nestrs routes

Install the middleware

With the oauth2 feature on nestrs, call use_oauth2 with a verifier and the middleware is applied to every request:

How identity is resolved per request

For each request, install_oauth2_middleware (applied by use_oauth2) does the following:
  1. Reads the Authorization header (the Bearer scheme is matched case-insensitively).
  2. Verifies the token with the JwtVerifier.
  3. On success, reads the sub claim and stashes an OAuth2Identity { subject, claims } into the request extensions — where guards and extractors find it.
  4. On a missing header or a failed verification, logs at tracing::debug! and proceeds without an identity.
The middleware is deliberately not a bouncer — it never rejects a request by itself. Protection is decided by the guard or extractor on each route: OAuth2Guard is stateless — the heavy lifting (verification) already happened in the middleware, and the guard only checks whether an OAuth2Identity made it into the request extensions. It rejects with GuardError::unauthorized("OAuth2 identity required"), which the framework maps to HTTP 401.
OAuth2Principal (root-exported from nestrs) is the extractor form of the same check — use it when the handler wants the identity anyway. principal.claims is the full verified claim set as serde_json::Value. For routes that serve both anonymous and authenticated callers, take Option<OAuth2Principal>.

Register providers with OAuth2Module

OAuth2Module::register wires the client (and, optionally, the verifier) into the DI registry using the useValue primitive — pre-built singletons with no Injectable impl needed, injectable into any #[injectable] provider with an Arc<OAuth2Client> field:
When resource_server is Some, the module also exports Arc<JwtVerifier> and Arc<JwksCache>. The initial JWKS fetch is best-effort: a failure logs a warning and the first request retries, rather than failing boot.
OAuth2Module::register is async (it does the initial JWKS fetch) and returns a Result. The #[module(imports = [...])] list is evaluated synchronously, so call register wherever you assemble registries in async code — for typical apps, the use_oauth2 + direct-construction wiring shown in the end-to-end example is the simpler path.

Social providers quickstart

Each wrapper is a thin layer over OAuth2Client that pins the provider’s endpoints and default scopes. The flows are identical to the un-wrapped client — .client() gives you the underlying OAuth2Client:
Apple requires response_mode=form_post on the authorization request — Apple delivers the code as a POST body to your redirect URI, not a query parameter. Add it through the extra map:
Social access tokens from GitHub are opaque, not JWTs. The resource-server flow (JwtVerifier + OAuth2Guard) needs an OIDC provider that publishes a JWKS (Google, Microsoft, Apple, Keycloak, Auth0, …). For GitHub-style providers, validate tokens by calling the provider’s API (e.g. userinfo) instead.

End-to-end example

One app with the full loop: /auth/login starts a PKCE authorization-code flow, /auth/callback validates the CSRF state and exchanges the code, and /me is protected — it requires a verified bearer token or answers 401.
Call GET /me with Authorization: Bearer <access_token> — the middleware verifies the token against the JWKS, OAuth2Guard confirms the identity is present, and the handler reads principal.subject. Without a valid token the guard answers 401.

Security notes

  • Secrets never reach logs through Debug. OAuth2Options renders client_secret as <redacted> (its presence stays visible for operators), and TokenSet / TokenSetDto render the access, refresh, and id tokens as <redacted>. Serialize is deliberately untouched — it’s the persistence path, not a logging path.
  • PKCE verifiers are single-use by construction. exchange_code consumes the verifier by value; the v5 oauth2 crate doesn’t implement Clone on it, so a reuse attempt won’t compile.
  • Always validate state. The AuthorizeUrl.state CSRF token is your defence against forged redirects — compare it with the value you persisted before exchanging any code.
  • Pin algorithms explicitly. The default accepted set is EdDSA only; if your IdP signs with RS256, say so with ValidationConfig::new(Algorithm::RS256) rather than broadening the set later.
  • The middleware fails open by design. A token that fails verification doesn’t reject the request — it just leaves no identity. Every route that must be protected needs OAuth2Guard, OAuth2Principal, or an explicit check on Option<OAuth2Principal>.