OAuth2, OIDC, and JWT
This is the heavyweight end of authentication: credentials that are obtained over
the network — an OAuth2 grant exchange, an OIDC discovery round trip, a signed JWT
assertion swapped for an access token — rather than typed in. However elaborate the
acquisition, the result is the same as in The Auth Model: an access token
lands in the AuthStore as a bearer credential — the token beside its "Bearer "
prefix — and endpoints inject it exactly as before. This machinery lives in capi_auth_oauth2 and capi_auth_jwt, which a
client depends on directly and gates behind its own feature. Both need a
platform — std on native and WASI, or the browser-wasm target, whose JS
bindings ride the crates’ std feature.
The shape: generic machinery + a vendor bridge
Three parties cooperate, and seeing the division up front makes the rest read easily:
- The generic machinery —
capi_auth_oauth2(grants, PKCE, discovery) andcapi_auth_jwt(client-signed assertions) — knows OAuth2 and JWT in the abstract. - A vendor crate — e.g.
capi_auth_google— supplies the provider-specific constants (endpoints, scopes, the service-account shape) as ready-made defaults. When no vendor crate exists, the client supplies those constants itself; the ringcentral client does, below. - Your client bridges the two: its config implements the bridge traits
(
ApiAuthConfig,OauthConfigAccess,ConfigOAuth2Grant,ConfigJwtAuth), and itsauthmodule re-exports the scheme markers users reach throughflow. Each marker is anAuthSchemewhose handle isOauth2Flow<I, Grant>orJwtBearerFlow<I>;Grantselects whichauthenticate…methods exist.
google_places is the worked example, and it’s a good one because it offers
three ways to authenticate at once — the default API key plus two network flows:
api.authenticate(key); // API key, the default scheme
api.flow::<GoogleJwt>().authenticate_from_bytes(sa_json, scope).await?; // service account
api.flow::<GoogleOAuth2<OAuth2AuthorizationCode>>().authenticate(id, Some(secret), h).await?; // user consent
Each network scheme’s handle runs the exchange and stores the resulting bearer
token. The markers are re-exported under the crate’s auth-jwt / auth-oauth2
features, so a user who only wants the API key doesn’t pull the OAuth2/JWT deps.
OAuth2 architecture
The grant machinery is a small cast:
OauthProviderDefaults— a provider template (endpoints, scopes, defaults). A vendor crate hands you one; OIDC discovery can produce one at runtime.OauthUserInput— your client-specific input (client id, optional secret).OauthGrantBuilder— merges the defaults and your input into a concrete grant config (.authorization_code()/.client_credentials()).Credentials+ClientAuthMethod— the client’s own credentials and how they’re presented (ClientSecretBasic,ClientSecretPost, …).Oauth2Authorizer— drives a built grant:authorize(handler)returns theOauth2Token.
The grant kinds are a six-variant GrantKind: AuthorizationCode,
ClientCredentials, DeviceAuthorization, Implicit, JwtBearer, and
ResourceOwnerPassword, each with a scheme marker of the same name prefixed
OAuth2 (OAuth2JwtBearer, …) and a builder method (.jwt_bearer(assertion), …).
(Refreshing an existing token is a method on the authorizer, not a grant kind.)
Whatever the grant, the token it yields becomes a bearer credential in the store.
The two headline grants
- Authorization Code — the interactive browser-redirect flow. Its shared scaffold
(
AuthorizationCodeFlow) generates the CSRFstateon construction — and anoncewhen the provider defaults set a nonce length, and a PKCE challenge when they set a method or the client is public — and validates the returnedstatebefore exchanging the code, so the anti-forgery machinery is on by default. A public client — an SPA, a mobile or desktop app, anything that cannot hold a secret — callsauthenticate_public(client_id, handler)instead: the client authenticates with no secret and PKCE stands in for it. - Client Credentials — the server-to-server, no-user flow: exchange the client id and secret directly for a token. Simplest to wire; no redirect, no PKCE.
PKCE
PKCE (RFC 7636) is folded into the authorization-code scaffold. The Pkce type
carries a verifier and a challenge; the method is a CodeChallengeMethod — S256
(the default) or Plain. The verifier is 32 random bytes drawn from the request
context’s RNG (context.rng(), an ApiRng), base64url-encoded; S256 sends
base64url(SHA-256(verifier)) as the challenge.
That the verifier comes from ApiRng matters for testing: the same injectable clock
and RNG that make replay tests deterministic make a PKCE flow
reproducible under test. You never hand-roll the randomness.
OIDC discovery
Rather than hardcode a provider’s endpoints, discovery fetches them. A
DiscoveryTarget names either an issuer or a full discovery URL:
DiscoveryTarget::issuer("https://accounts.example.com") // → appends /.well-known/openid-configuration
DiscoveryTarget::discovery_url("https://…/.well-known/openid-configuration")
target.discover(client, api).await fetches the document and returns an
OauthProviderDefaults you feed to the grant builder — so discovery and the static
vendor-defaults path converge on the same type. On the flow handle,
authenticate_with_discovery(target, ..) sits between authenticate (defaults from
the config’s bridge) and authenticate_with_grant (a grant you built yourself):
it fetches the provider’s document and builds the grant from it. HTTPS is enforced on the discovery
URL and the discovered token_endpoint/jwks_uri (unless you explicitly allow
insecure endpoints, which you’d only do against a local test IdP). Discovery is not
cached — each discover() refetches — so call it once at setup, not per request.
Keeping a token fresh
Access tokens expire. Every grant that can mint another returns an
Authenticated<Oauth2Refresher> rather than the bare token, and one line on it is
the whole opt-in:
api.flow::<GoogleOAuth2<OAuth2AuthorizationCode>>()
.authenticate(client_id, Some(secret), handler).await?
.auto_refresh();
That seats the refresher in the credential store under the key the grant minted at.
From there the store renews the credential as part of resolving it — whenever a
request finds it within a minute of expiry, the default RefreshPolicy skew that
.auto_refresh_with(policy) changes — single-flight across concurrent callers.
.refresh() on the same value renews by hand. Drop the line and nothing is
retained: .into_token() takes the payload and leaves the credential to age,
.into_parts() hands out both for a caller running a schedule of its own.
How a renewal is performed is fixed when the refresher is built, from what the
grant actually yielded. A response carrying a refresh token yields a refresher
that redeems it (RFC 6749 §6), adopting each rotated token the server returns —
on_rotation(sink) is how one reaches persistent storage. A response without one
yields a refresher that re-runs its own grant, which is what the three
non-interactive grants can do: client credentials, resource-owner password, and a
provider-issued JWT-bearer assertion all hold the material to ask again. The two
interactive grants cannot — re-running them means putting the user back in front of
a consent screen — so an authorization-code or device grant answered without a
refresh token yields an outcome that renews nothing, and is_refreshable() says so
before .auto_refresh() quietly seats nothing.
A process that persisted a refresh token from an earlier run skips the consent
screen entirely: resume(client_id, client_secret, refresh_token) on the
authorization-code and device flows runs the refresh-token grant now and hands back
the same outcome an interactive authorization would.
Two more nets sit beside resolution’s own. api.refresh_on_unauthorized() seats a
runner that renews on a 401 and dispatches once more — reactive rather than
proactive, and useful when the service expires a credential early.
capi_rs::extras::BackgroundRefresh drives a seated key on a timer, for a
credential that must stay fresh while the client is idle rather than sending. Both
act through the same seat, so no two of them mint twice.
The config bridges
The generic machinery reaches your config through bridge traits, which you
implement per-API — they can’t be derived because they connect your config type
to the provider surface. The OAuth2 side needs three: ApiAuthConfig (the JSON
and urlencoded codecs the token exchange encodes through), OauthConfigAccess
(which config carries the OAuth2 settings), and ConfigOAuth2Grant<Grant> (the
provider defaults and the client’s input); the JWT side needs ConfigJwtAuth. In
google_places they live in google_auth.rs:
impl ConfigOAuth2Grant<OAuth2AuthorizationCode> for GooglePlacesConfig {
fn provider_defaults(&self) -> OauthProviderDefaults {
capi_auth_google::google_provider_defaults()
}
fn build_user_input(client_id: String, client_secret: Option<String>) -> OauthUserInput {
capi_auth_google::google_user_input(client_id, client_secret)
}
}
impl ConfigJwtAuth for GooglePlacesConfig {
type Token = capi_auth_google::JwtTokenResponse;
type ExchangeError = capi_auth_google::GoogleAuthError;
type Endpoint = capi_auth_google::ExchangeJwtAssertion<Self>;
fn jwt_token_endpoint(&self) -> &str { capi_auth_google::constants::TOKEN_URI }
fn jwt_json_lib(&self) -> Codec<Json> { self.config.json_lib() }
}
The pattern is always the same: pull the provider constants from the vendor crate,
and wire the codec and token endpoint from your config. Gate each mod with its
Cargo feature so an API-key-only build stays lean.
No vendor crate: the client is the bridge
A provider without a vendor crate is the same three impls with the constants
written in place. The ringcentral client implements ApiAuthConfig,
OauthConfigAccess, and ConfigOAuth2Grant for two grants — the JWT-bearer
exchange and the authorization code — naming its token endpoint and HTTP Basic
client authentication in its own OauthProviderDefaults. Because the JWT-bearer
exchange is the credential most integrations use, the client makes that grant
its default scheme: its API type stores
Oauth2Flow<BaseInterface<RingCentralConfig, Client>, OAuth2JwtBearer> and
api.authenticate(client_id, client_secret, jwt) is the exchange, with the
authorization-code grant reached through flow.
JWT bearer, two shapes
RFC 7523’s JWT-bearer grant appears twice in the family, and the split is about who signs the assertion:
- Provider-issued — the developer downloads a ready-made JWT from a console
and presents it verbatim. That is
capi_auth_oauth2’sOAuth2JwtBearergrant, whose refresher re-runs the exchange with the same assertion. - Client-signed — the client builds the claims and signs them against a
private key it holds. That is
capi_auth_jwt’sJwtBearerscheme (api.flow::<JwtBearer>()), whoseJwtRefresherre-signs a fresh assertion on every renewal; Google service accounts are its canonical use.
JWT assertion auth
A JWT service-account flow signs a claims assertion with a private key and swaps it for a token — Google service accounts are the canonical case. Three pieces:
JwtBuilder::new(json_lib: Codec<Json>)builds the JWT, encoding through the wire-model codec.JwtClaimscarries the claim set. Because a claim set has runtime keys — a map, not a fixed struct — it can’t use#[wire(flatten)](which rejects a map field); instead it ships a hand-writtenWireEncodethat emits one flat map. This is a good real-world example of the wire model’s escape hatch: when a derive can’t express the shape, you implement the trait directly.JwtSigningKeyis a custody handle, not key bytes:from_rsa_pem(pem)loads a PKCS#8 PEM into one,from_handle(..)/from_key_material(..)wrap a key the credential store already holds, andkid()reports the key id the header carries.JwtAlgorithmisRS256(the type is#[non_exhaustive]).
For Google specifically, capi_auth_google::GoogleServiceAccountKey parses the
downloaded service-account JSON, and
api.flow::<GoogleJwt>().authenticate(&key, scope) — the key by reference and
the scope the token is minted for — handles the sign-and-exchange, depositing a
bearer token in the store like any other flow and handing back an
Authenticated<JwtRefresher<_>> to seat.
What the crates do not do
capi_auth_oauth2 states its own limits: discovery documents and JWKS are not
cached (every ID-token validation refetches both); the at_hash claim binding an
access token to its ID token is not validated; a JWKS whose keys carry no kid
is unsupported; and ClientAuthMethod::PrivateKeyJwt carries the metadata for the
assertion but signs none, so every token request under it fails.
With the credential families covered, one auth mechanism remains — schemes whose credential is computed over the request itself, and must run last: request signers.