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: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).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
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 sameTokenSet 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 aclient_secret — public_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:
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
algmust be in your accepted set before any key lookup. The default set isEdDSAonly; addRS256/ES256/PS256viaValidationConfig::new(...)orwith_algorithms. Pinning blocks thealg=nonedowngrade and cross-algorithm confusion (anRS256token replayed asHS256). expandnbf—nbfvalidation is on (RFC 7519 §4.1.5), with your clock-skew leeway.iss/aud— enforced whenwith_issuer/with_audienceare set. Without an audience configured,audchecking is explicitly disabled rather than left to defaults.
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 theoauth2 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:
- Reads the
Authorizationheader (theBearerscheme is matched case-insensitively). - Verifies the token with the
JwtVerifier. - On success, reads the
subclaim and stashes anOAuth2Identity { subject, claims }into the request extensions — where guards and extractors find it. - On a missing header or a failed verification, logs at
tracing::debug!and proceeds without an identity.
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:
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 overOAuth2Client that pins the provider’s endpoints and default scopes. The flows are identical to the un-wrapped client — .client() gives you the underlying OAuth2Client:
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.
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.OAuth2Optionsrendersclient_secretas<redacted>(its presence stays visible for operators), andTokenSet/TokenSetDtorender the access, refresh, and id tokens as<redacted>.Serializeis deliberately untouched — it’s the persistence path, not a logging path. - PKCE verifiers are single-use by construction.
exchange_codeconsumes the verifier by value; the v5oauth2crate doesn’t implementCloneon it, so a reuse attempt won’t compile. - Always validate
state. TheAuthorizeUrl.stateCSRF 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
EdDSAonly; if your IdP signs withRS256, say so withValidationConfig::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 onOption<OAuth2Principal>.