OAuth2 / OIDC SSO
The sso feature adds full OAuth 2.0 / OIDC support: authorization-code + PKCE flow, session management, and asymmetric JWT verification via a live JWKS endpoint. The sso-server feature additionally lets rws act as its own OAuth 2.0 Authorization Server, issuing tokens instead of delegating to an external IdP.
[dependencies]rust-web-server = { version = "17", features = ["sso"] }Quick start
use std::sync::Arc;use rust_web_server::app::App;use rust_web_server::core::New;use rust_web_server::session::SessionStore;use rust_web_server::sso::{OidcAuth, OidcConfig};
let sessions = Arc::new(SessionStore::new(86_400)); // 24 h TTLlet config = OidcConfig::google( "my-client-id", "my-client-secret", "https://example.com/auth/callback",);let app = App::new().wrap(OidcAuth::new(config, sessions));Loading config from environment variables
OidcConfig::from_env() reads environment variables so credentials stay out of source code:
RWS_OIDC_PROVIDER=google # google | github | microsoft | okta | auth0 | keycloak | customRWS_OIDC_CLIENT_ID=my-client-idRWS_OIDC_CLIENT_SECRET=my-secretRWS_OIDC_REDIRECT_URI=https://example.com/auth/callbackRWS_OIDC_SCOPES=openid email profile # space-separated; default: openid email profileRWS_OIDC_POST_LOGIN_REDIRECT=/dashboardProvider-specific extras:
| Provider | Extra variable | Meaning |
|---|---|---|
microsoft | RWS_OIDC_TENANT_ID | Tenant GUID, common, organizations, or consumers |
okta | RWS_OIDC_ISSUER | Okta org domain, e.g. dev-12345.okta.com |
auth0 | RWS_OIDC_ISSUER | Auth0 domain, e.g. myapp.us.auth0.com |
keycloak | RWS_OIDC_ISSUER + RWS_OIDC_TENANT_ID | Base URL + realm name |
custom | RWS_OIDC_ISSUER | OIDC issuer URL for auto-discovery |
let config = OidcConfig::from_env().expect("OIDC config missing");Provider presets
All presets accept (client_id, client_secret, redirect_uri) unless noted.
// Googlelet cfg = OidcConfig::google("id", "secret", "https://example.com/auth/callback");
// Microsoft Entra ID / Azure ADlet cfg = OidcConfig::microsoft("common", "id", "secret", "https://example.com/auth/callback");
// GitHub OAuth 2.0 (no id_token — uses UserInfo endpoint instead)let cfg = OidcConfig::github("id", "secret", "https://example.com/auth/callback");
// Oktalet cfg = OidcConfig::okta("dev-12345.okta.com", "id", "secret", "https://example.com/auth/callback");
// Auth0let cfg = OidcConfig::auth0("myapp.us.auth0.com", "id", "secret", "https://example.com/auth/callback");
// Keycloaklet cfg = OidcConfig::keycloak( "https://keycloak.example.com", "my-realm", "id", "secret", "https://example.com/auth/callback",);
// Auto-discovery from any OIDC-compliant providerlet cfg = OidcConfig::discover("https://provider.example.com", "id", "secret", "https://example.com/auth/callback")?;Authorization-code + PKCE flow
Browser App Identity Provider | | | |-- GET /any-page --> | | | | (no session) | |<-- 302 /auth/login--| | | | | |-- GET /auth/login ->| | | | generate PKCE verifier+challenge, | | | state, nonce → save in session | |<-- 302 idp/authorize| | | | |------- GET idp/authorize?code_challenge=... ----------->| |<--------------------- 302 /auth/callback?code=... ------| | | |-- GET /auth/callback?code=... -->| | | | exchange code (with PKCE verifier)| | |<-------- POST idp/token ----------| | |----------- id_token+access_token->| | | verify id_token via JWKS | | | store OidcClaims in session | |<-- 302 /dashboard --| |OidcAuth intercepts three paths automatically:
| Path | Action |
|---|---|
GET /auth/login | Generates PKCE + state + nonce, redirects to provider |
GET /auth/callback | Validates state, exchanges code, verifies id_token, stores claims |
GET /auth/logout | Destroys session, redirects to / |
All other paths check the session. Authenticated requests have claims injected into the X-Rws-Oidc-Claims header (JSON) and are forwarded to the next layer. Unauthenticated requests redirect to /auth/login?return_to=<current-path>.
Claims extraction in handlers
use rust_web_server::sso::OidcAuth;use rust_web_server::request::Request;use rust_web_server::response::Response;
fn dashboard(req: &Request) -> Response { // Read the full claims object if let Some(claims) = OidcAuth::claims(req) { let user_id = &claims.sub; let email = claims.email.as_deref().unwrap_or("unknown"); let name = claims.name.as_deref().unwrap_or("unknown"); println!("User {user_id} ({email}) / {name}"); }
// Shortcuts let sub = OidcAuth::sub(req); let email = OidcAuth::email(req);
Response::new()}OidcClaims fields
| Field | Type | Description |
|---|---|---|
sub | String | Subject (unique user ID at the provider) |
iss | String | Issuer URL |
aud | Vec<String> | Intended audience (your client ID) |
exp | u64 | Expiration as Unix seconds |
iat | u64 | Issued-at as Unix seconds |
nonce | Option<String> | Nonce for replay protection |
email | Option<String> | User’s email address |
email_verified | Option<bool> | Whether the email is verified |
name | Option<String> | Full display name |
given_name | Option<String> | First name |
family_name | Option<String> | Last name |
picture | Option<String> | Profile picture URL |
locale | Option<String> | User’s locale |
Public path exclusion
Exclude paths from the authentication check (health endpoints, public assets, etc.):
let app = App::new() .wrap( OidcAuth::new(config, sessions) .exclude("/healthz") .exclude("/public/") );Custom paths
OidcAuth::new(config, sessions) .login_path("/login") .callback_path("/oauth/callback") .logout_path("/logout")RS256/ES256 JWT verification via JWKS
JwksCache fetches the provider’s public keys from the JWKS URI and verifies id_token JWTs. Key rotation is handled automatically: on a verification failure the cache is refreshed and the verification is retried once.
use rust_web_server::sso::{JwksCache, VerifyOptions};
let jwks = JwksCache::new("https://accounts.google.com/.well-known/jwks");let opts = VerifyOptions { audience: "my-client-id", issuer: "https://accounts.google.com", leeway_secs: 60,};let claims = jwks.verify_jwt(&id_token, &opts)?;OidcAuth calls JwksCache::verify_jwt internally; you only need to use it directly when processing tokens outside the middleware (e.g. in a mobile API that receives tokens from a separate auth service).
The outbound HTTP client behind it all
Token exchange, JWKS fetch, discovery, and UserInfo calls are all plain
http_client::Client requests — the same synchronous,
dependency-free HTTP/1.1 + TLS client used everywhere else in rws. There is
no separate “SSO HTTP client”; sso just depends on the http-client
feature (implied automatically) for HTTPS support.
The token endpoint expects an application/x-www-form-urlencoded body, so
OidcClient::exchange_code uses Client’s .form() builder method:
use rust_web_server::http_client::Client;
let resp = Client::new() .post("https://oauth2.googleapis.com/token") .form(&[ ("grant_type", "authorization_code"), ("code", "abc123"), ("redirect_uri", "https://example.com/auth/callback"), ("client_id", "my-client-id"), ("client_secret", "my-client-secret"), ]) .send()?;.form() percent-encodes each pair, joins them with &, and sets
Content-Type: application/x-www-form-urlencoded — available on both the
sync RequestBuilder and the async AsyncRequestBuilder (http2 feature).
rws as its own OAuth 2.0 Authorization Server
Everything above covers rws as an OAuth 2.0 / OIDC client, delegating
login to an external identity provider. The sso-server feature (implies
sso and auth) flips the role: AuthServer lets rws issue its own
short-lived JWTs to downstream services or single-page apps, instead of
always delegating to Google/Okta/etc.
[dependencies]rust-web-server = { version = "17", features = ["sso-server"] }use std::sync::Arc;use std::time::Duration;use rust_web_server::app::App;use rust_web_server::core::New;use rust_web_server::session::SessionStore;use rust_web_server::sso::server::{AuthServer, AuthServerConfig};use rust_web_server::sso::client_store::{ClientStore, OAuthClient, GrantType};
let auth_server = AuthServer::new(AuthServerConfig { issuer: "https://myapp.com".into(), signing_secret: std::env::var("RWS_AUTH_SIGNING_SECRET").unwrap(), access_token_ttl: Duration::from_secs(3600), refresh_token_ttl: Duration::from_secs(86_400 * 30), clients: ClientStore::new() .add(OAuthClient { client_id: "spa-frontend".into(), client_secret: None, // public client, uses PKCE redirect_uris: vec!["https://spa.example.com/callback".into()], grants: vec![GrantType::AuthorizationCode], scopes: vec!["openid".into(), "email".into()], }) .add(OAuthClient { client_id: "backend-service".into(), client_secret: Some("s3cr3t".into()), redirect_uris: vec![], grants: vec![GrantType::ClientCredentials], scopes: vec!["api:read".into(), "api:write".into()], }), sessions: Arc::new(SessionStore::new(86_400)),});
let app = App::new().wrap(auth_server);AuthServer intercepts four paths and passes everything else through:
| Path | Method | Purpose |
|---|---|---|
/oauth/token | POST | Issues tokens for the client_credentials, authorization_code (+ PKCE), and refresh_token grants |
/oauth/authorize | GET | Starts the authorization-code flow for an end user |
/.well-known/openid-configuration | GET | Discovery document |
/.well-known/jwks.json | GET | Always {"keys":[]} — see below |
A machine-to-machine client calls the token endpoint directly:
POST /oauth/tokengrant_type=client_credentials&client_id=backend-service&client_secret=s3cr3t
→ {"access_token":"eyJ...","token_type":"Bearer","expires_in":3600}ClientStore has no ::from_env() — a list of clients with per-client
secrets has no natural flat-env-var encoding (unlike OidcConfig’s
one-client-per-process shape above). ClientStore::new().add(...) is the
only registration path; load clients from wherever your application already
keeps them (config file, database, secret manager) and build the store once
at startup.