Machine-to-Machine Grants
The Authorization Code flow exists to get a token on behalf of a user. Plenty of traffic has no user in it: a background worker calling an internal API, a job runner, a service that needs a token for itself. Since v0.9.8, volga-oauth-client drives the three grants for those cases, where the client is the subject.
All three are builders on OAuthClient, share the same with_scopes / with_resource (RFC 8707) / with_param options, and are sent with send():
| Method | Grant | Use it when |
|---|---|---|
client_credentials | RFC 6749 §4.4 | the service acts as itself, with its own credential |
jwt_bearer | RFC 7523 §2.1 | some other authority already issued a JWT that vouches for it |
exchange_token | RFC 8693 | one token has to be traded for another |
Tips
These grants need client authentication — a secret or a private_key_jwt key. A public client has nothing to present, and any sane server will refuse it.
Client Credentials
The plain machine-to-machine grant. There is no authorization request to carry scopes here, so they go on the token request itself — or are omitted, leaving the server to apply the client's default grant:
use volga_oauth_client::{AuthorizationServerMetadata, ClientError, OAuthClient};
async fn run(metadata: &AuthorizationServerMetadata) -> Result<(), ClientError> {
let client = OAuthClient::new("my-service").with_secret("s3cret");
let tokens = client
.client_credentials(metadata)
.with_scopes(["inventory:read"])
.with_resource("https://api.example.com")
.send()
.await?;
Ok(())
}
Holding a service token
RFC 6749 §4.4.3 issues no refresh token for this grant, so there is nothing for OAuthClient::token to renew a stored token with — re-running the grant is the renewal. That is what ClientCredentialsRequest::token does: it serves the stored token while it is fresh and runs the grant again — with the same scopes and resource indicators — when it is not.
use std::sync::Arc;
use volga_oauth_client::{
AuthorizationServerMetadata, ClientError, InMemoryTokenStore, OAuthClient,
};
async fn run(metadata: &AuthorizationServerMetadata) -> Result<(), ClientError> {
let client = OAuthClient::new("my-service")
.with_secret("s3cret")
.with_token_store(Arc::new(InMemoryTokenStore::new()));
// the first call requests, the rest are served from the store
// until the token nears its expiry
let tokens = client
.client_credentials(metadata)
.with_scopes(["inventory:read"])
.token("inventory")
.await?;
Ok(())
}
Warning
A stored token whose lifetime the server never stated is re-requested rather than served: expires_in is only RECOMMENDED by RFC 6749 §5.1, and an unknown lifetime is no evidence of freshness. Against a server that omits it, this therefore runs the grant on every call — that grant is a single request, and a caller who would rather cache anyway can hold the TokenSet from send() under its own policy.
The method panics when no TokenStore is attached.
JWT Bearer
Presents a JWT as an authorization grant. The assertion is supplied by the caller rather than minted here: it is what some other authority already issued — a workload identity token from the platform the client runs on, or an identity assertion obtained from a prior exchange.
use volga_oauth_client::{AuthorizationServerMetadata, ClientError, OAuthClient};
async fn run(
metadata: &AuthorizationServerMetadata,
workload_jwt: &str,
) -> Result<(), ClientError> {
let tokens = OAuthClient::new("my-workload")
.jwt_bearer(metadata, workload_jwt)
.with_scopes(["inventory:read"])
.send()
.await?;
Ok(())
}
Warning
A failure here is final: the assertion is either accepted or it is not. Do not retry it and do not fall back to another grant type — fix the assertion instead. An invalid_grant means the assertion itself was rejected.
Info
jwt_bearer and private_key_jwt both send a signed JWT and are easy to confuse. They answer different questions: the JWT bearer grant is why a token should be issued, a private_key_jwt client assertion is who is asking. One request can carry both.
Token Exchange
Trades one token for another (RFC 8693) — the delegation and impersonation grant. subject_token represents the party the new token is requested for, and subject_token_type identifies what it is: one of the token_type constants, or any URI the server understands.
use volga_oauth_client::{
AuthorizationServerMetadata, ClientError, OAuthClient, token_type,
};
async fn run(
idp: &AuthorizationServerMetadata,
resource_server: &AuthorizationServerMetadata,
id_token: &str,
) -> Result<(), ClientError> {
let client = OAuthClient::new("my-app").with_secret("s3cret");
// exchange the user's ID token for an assertion the resource's
// authorization server accepts...
let exchanged = client
.exchange_token(idp, id_token, token_type::ID_TOKEN)
.with_requested_token_type(token_type::ID_JAG)
.with_audience("https://api.example.com")
.send()
.await?;
// ...and present it there as a JWT bearer grant
let tokens = client
.jwt_bearer(resource_server, &exchanged.token)
.send()
.await?;
Ok(())
}
Unlike the other two, an exchange may hand back something that is not a bearer access token, so it answers with an ExchangedToken rather than a TokenSet:
issued_token_type— what the server decided to issue;is_bearer()— whether it is usable as anAuthorization: Bearercredential;is_expired()/expires_within()— the same absolute expiry handling as aTokenSet.
Besides the shared options, the request takes with_requested_token_type, with_audience and with_actor_token (delegation, where the acting party is named alongside the subject).
What Is Refused Before the Network
All three grants refuse a request rather than send one that cannot succeed. Both sides have to allow the grant:
- the server has to list it in
grant_types_supported; - a client built by
from_registrationhas to have had it approved in the registration'sgrant_types.
The registration check applies to the Authorization Code flow too — authorization_request().build() and exchange_code refuse it rather than redirect a user into a flow the client may not complete. An omitted grant_types means authorization_code alone (RFC 7591 §2); only a client that never went through a registration is unconstrained, and refresh_token is never refused.
Both failures surface as ClientError::Validation, which is a clearer signal than the unauthorized_client the token endpoint would have answered with.