Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 machinerycapi_auth_oauth2 (grants, PKCE, discovery) and capi_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 its auth module re-exports the scheme markers users reach through flow. Each marker is an AuthScheme whose handle is Oauth2Flow<I, Grant> or JwtBearerFlow<I>; Grant selects which authenticate… 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 the Oauth2Token.

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 CSRF state on construction — and a nonce when the provider defaults set a nonce length, and a PKCE challenge when they set a method or the client is public — and validates the returned state before 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 — calls authenticate_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 CodeChallengeMethodS256 (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’s OAuth2JwtBearer grant, 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’s JwtBearer scheme (api.flow::<JwtBearer>()), whose JwtRefresher re-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.
  • JwtClaims carries 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-written WireEncode that 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.
  • JwtSigningKey is 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, and kid() reports the key id the header carries. JwtAlgorithm is RS256 (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.