A comprehensive OAuth2/OIDC authentication library for Axum web applications with PKCE (Proof Key for Code Exchange) support and token auto refresh capabilities.
- ✅ OAuth2/OIDC Authentication - Full support for OAuth2 and OpenID Connect protocols
- 🔐 PKCE Support - Implements RFC 7636 for enhanced security
- 🔄 Automatic Token Refresh - Seamlessly refreshes expired ID tokens and access tokens using OAuth2 refresh token flow
- 💾 Flexible Caching - Pluggable cache backends with built-in Redis support
- 🍪 Secure Sessions - Encrypted cookie-based session management
- 🚪 Logout Handlers - Support for both standard and OIDC logout flows
- 🎯 Type-safe Extractors - Convenient extractors for authenticated users and sessions
- 🔧 Customizable - Extensible with custom CA certificates and logout handlers
- ⚡ Production Ready - Battle-tested with comprehensive error handling
Add this to your Cargo.toml:
[dependencies]
axum-oidc-client = "0.1.0"
axum = "0.7"
tokio = { version = "1", features = ["full"] }redis- Enable Redis cache backend with default TLS (enabled by default)redis-rustls- Enable Redis with rustls for TLSredis-native-tls- Enable Redis with native-tls
[dependencies]
axum-oidc-client = { version = "0.1.0", features = ["redis"] }use axum::{Router, routing::get};
use axum_oidc_client::{
auth::{AuthLayer, CodeChallengeMethod},
auth_builder::OAuthConfigurationBuilder,
auth_cache::AuthCache,
logout::handle_default_logout::DefaultLogoutHandler,
};
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Build OAuth configuration
let config = OAuthConfigurationBuilder::default()
.with_authorization_endpoint("https://accounts.google.com/o/oauth2/auth")
.with_token_endpoint("https://oauth2.googleapis.com/token")
.with_client_id("your-client-id")
.with_client_secret("your-client-secret")
.with_redirect_uri("http://localhost:8080/auth/callback")
.with_private_cookie_key("your-secret-key-at-least-32-bytes")
.with_scopes(vec!["openid", "email", "profile"])
.with_code_challenge_method(CodeChallengeMethod::S256)
.with_session_max_age(30) // 30 minutes
.with_base_path("/auth") // Optional: customize auth routes (default: "/auth")
.build()?;
// Create cache (Redis example)
let cache: Arc<dyn AuthCache + Send + Sync> = Arc::new(
axum_oidc_client::redis::AuthCache::new("redis://127.0.0.1/", 3600)
);
// Create logout handler
let logout_handler = Arc::new(DefaultLogoutHandler);
// Build your application
let app = Router::new()
.route("/", get(home))
.route("/protected", get(protected))
.layer(AuthLayer::new(Arc::new(config), cache, logout_handler));
// Start server
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn home() -> &'static str {
"Hello, World!"
}
// This route requires authentication
async fn protected(session: axum_oidc_client::auth_session::AuthSession) -> String {
format!("Hello, authenticated user! Token expires in: {} seconds", session.expires)
}The library automatically refreshes expired ID tokens and access tokens when they expire. This happens transparently when you use the provided extractors.
- Token Expiration Check - On each request, the library checks if the ID token and access token have expired
- Automatic Refresh - If expired, uses the refresh token to obtain new ID token and access token
- Session Update - Updates the session with the new ID token, access token, and expiration time
- Cache Sync - Saves the refreshed session back to the cache
- Seamless Access - Your handler receives the fresh token automatically
use axum_oidc_client::extractors::{AccessToken, IdToken};
use axum_oidc_client::auth_session::AuthSession;
// Access token extractor with automatic refresh
async fn api_call(token: AccessToken) -> String {
// Access token is automatically refreshed if expired
format!("Using token: {}", *token)
}
// ID token extractor with automatic refresh
async fn user_info(token: IdToken) -> String {
// ID token is automatically refreshed if expired
format!("User ID token: {}", *token)
}
// Full session extractor with automatic token refresh
async fn dashboard(session: AuthSession) -> String {
// ID token and access token are automatically refreshed if expired
format!("Session expires: {}", session.expires)
}AccessToken- Extracts the access token (automatically refreshed if expired)IdToken- Extracts the ID token (automatically refreshed if expired)AuthSession- Extracts full session (ID token and access token automatically refreshed if expired)OptionalAccessToken- Optional access token (automatically refreshed if expired)OptionalIdToken- Optional ID token (automatically refreshed if expired)
Refresh tokens are automatically:
- Stored in the session during initial authentication
- Used to obtain new ID tokens and access tokens when they expire
- Updated if the provider issues a new refresh token during refresh
- Persisted in the cache with the session
The core authentication module providing the main layer and configuration types.
Key Types:
AuthLayer- Tower layer for adding authentication to your Axum appOAuthConfiguration- Configuration for OAuth2 endpoints and credentialsCodeChallengeMethod- PKCE code challenge method (S256 or Plain)LogoutHandler- Trait for implementing custom logout behavior
Builder pattern for constructing OAuth configurations.
Example:
use axum_oidc_client::auth_builder::OAuthConfigurationBuilder;
let config = OAuthConfigurationBuilder::default()
.with_client_id("my-client-id")
.with_client_secret("my-client-secret")
.with_redirect_uri("http://localhost:8080/auth/callback")
.with_authorization_endpoint("https://provider.com/oauth/authorize")
.with_token_endpoint("https://provider.com/oauth/token")
.with_private_cookie_key("secret-key-min-32-bytes-long")
.with_scopes(vec!["openid", "email", "profile"])
.build()?;Cache trait and implementations for storing authentication state.
Trait:
pub trait AuthCache {
async fn get(&self, key: &str) -> Option<String>;
async fn set(&self, key: &str, value: &str);
async fn delete(&self, key: &str);
}Built-in Implementations:
redis::AuthCache- Redis-backed cache (requiresredisfeature)
Custom Implementation:
use axum_oidc_client::auth_cache::AuthCache;
use async_trait::async_trait;
struct MyCache;
#[async_trait]
impl AuthCache for MyCache {
async fn get(&self, key: &str) -> Option<String> {
// Your implementation
}
async fn set(&self, key: &str, value: &str) {
// Your implementation
}
async fn delete(&self, key: &str) {
// Your implementation
}
}Session management and token handling.
Key Type:
AuthSession- Contains authenticated user's session data
Fields:
pub struct AuthSession {
pub id_token: String,
pub access_token: String,
pub token_type: String,
pub refresh_token: Option<String>,
pub scope: String,
pub expires: DateTime<Local>,
}Type-safe extractors for accessing authenticated user data with automatic ID token and access token refresh.
Available Extractors:
AuthSession- Full session (automatically refreshes ID token and access token if expired), redirects to OAuth if not authenticatedAccessToken- Access token extractor (automatically refreshes if expired)IdToken- ID token extractor (automatically refreshes if expired)OptionalAccessToken- Optional access token (automatically refreshes if expired)OptionalIdToken- Optional ID token (automatically refreshes if expired)
Example:
use axum_oidc_client::auth_session::AuthSession;
use axum_oidc_client::extractors::{AccessToken, IdToken, OptionalIdToken};
// Protected route with full session
async fn protected(session: AuthSession) -> String {
// ID token and access token are automatically refreshed if expired
format!("Authenticated! Token: {}", session.access_token)
}
// Protected route with just access token
async fn api_endpoint(token: AccessToken) -> String {
// Access token is automatically refreshed if expired
format!("API call with token: {}", *token)
}
// Protected route with ID token
async fn user_profile(token: IdToken) -> String {
// ID token is automatically refreshed if expired
format!("User profile with ID: {}", *token)
}
// Public route with optional authentication
async fn home(OptionalIdToken(token): OptionalIdToken) -> String {
match token {
Some(id_token) => format!("Welcome back!"),
None => format!("Please log in"),
}
}Logout handler implementations.
Built-in Handlers:
-
DefaultLogoutHandler - Simple local logout with session cleanup
Use this handler when:
- The OAuth provider doesn't support OIDC logout (e.g., Google, GitHub)
- You only need to clear the local session without notifying the provider
- You're implementing custom logout logic
use axum_oidc_client::logout::handle_default_logout::DefaultLogoutHandler; let handler = Arc::new(DefaultLogoutHandler);
Behavior:
- Removes session cookie
- Deletes session from cache
- Redirects to
post_logout_redirect_uri(default: "/")
-
OidcLogoutHandler - OIDC-compliant logout with provider notification
Use this handler when:
- The OAuth provider supports OIDC RP-Initiated Logout (e.g., Keycloak, Azure AD)
- You need to end the session at the provider
- You want single logout across multiple applications
use axum_oidc_client::logout::handle_oidc_logout::OidcLogoutHandler; let handler = Arc::new(OidcLogoutHandler::new("https://provider.com/oidc/logout"));
Behavior:
- Removes session cookie
- Deletes session from cache
- Redirects to provider's
end_session_endpointwithid_token_hint - Provider logs out user and redirects to
post_logout_redirect_uri
Custom Handler:
You can implement the LogoutHandler trait to create custom logout behavior:
use axum_oidc_client::auth::LogoutHandler;
use futures_util::future::BoxFuture;
use axum::response::{Redirect, IntoResponse};
struct CustomLogoutHandler {
custom_redirect: String,
}
impl LogoutHandler for CustomLogoutHandler {
fn handle_logout<'a>(
&'a self,
parts: &'a mut http::request::Parts,
configuration: Arc<OAuthConfiguration>,
cache: Arc<dyn AuthCache + Send + Sync>,
) -> BoxFuture<'a, Result<Response, Error>> {
Box::pin(async move {
// Your custom logout logic
// For example: audit logging, custom redirects, etc.
// Clean up session (similar to DefaultLogoutHandler)
let jar = PrivateCookieJar::from_headers(
&parts.headers,
configuration.private_cookie_key.clone(),
);
if let Some(session_cookie) = jar.get(SESSION_KEY) {
cache.invalidate_auth_session(session_cookie.value()).await?;
}
let jar = jar.remove(Cookie::build(SESSION_KEY).path("/"));
Ok((jar, Redirect::to(&self.custom_redirect)).into_response())
})
}
}The AuthLayer automatically adds the following routes (default base path is /auth):
GET /auth- Initiates OAuth2 authorization flowGET /auth/callback- OAuth2 callback endpoint (handles authorization code exchange)GET /auth/logout- Logout endpoint (redirects to home by default)GET /auth/logout?redirect=/path- Logout with custom redirect
You can customize the base path for authentication routes via configuration:
let config = OAuthConfigurationBuilder::default()
// ... other config
.with_base_path("/api/auth") // Custom base path (default: "/auth")
.with_redirect_uri("http://localhost:8080/api/auth/callback") // Match your base_path
.build()?;
// Routes will be available at:
// - GET /api/auth
// - GET /api/auth/callback
// - GET /api/auth/logoutOAuthConfigurationBuilder::default()
.with_client_id("...") // OAuth2 client ID
.with_client_secret("...") // OAuth2 client secret
.with_redirect_uri("...") // Callback URL
.with_authorization_endpoint("...") // Authorization URL
.with_token_endpoint("...") // Token exchange URL
.with_private_cookie_key("...") // Session encryption key (min 32 bytes)
.with_session_max_age(30) // Session duration in minutes
.build()?;builder
.with_scopes(vec!["openid", "email"]) // OAuth scopes (default: openid, email, profile)
.with_code_challenge_method(CodeChallengeMethod::S256) // PKCE method (default: S256)
.with_end_session_endpoint("...") // OIDC logout endpoint (only for OIDC-compliant providers)
.with_post_logout_redirect_uri("...") // Post-logout redirect (default: "/")
.with_custom_ca_cert("/path/to/ca.pem") // Custom CA certificate
.with_token_max_age(300) // Token max age in seconds
.with_base_path("/api/auth") // Custom base path for auth routes (default: "/auth")Note on end_session_endpoint:
- Only set this if your OAuth provider supports OIDC RP-Initiated Logout
- Use with
OidcLogoutHandlerto properly logout from the provider - Not all providers support this (e.g., Google, GitHub don't implement OIDC logout)
Google supports OAuth2 but does not implement OIDC logout. Use DefaultLogoutHandler for logout.
use axum_oidc_client::logout::handle_default_logout::DefaultLogoutHandler;
let config = OAuthConfigurationBuilder::default()
.with_authorization_endpoint("https://accounts.google.com/o/oauth2/auth")
.with_token_endpoint("https://oauth2.googleapis.com/token")
.with_client_id("your-client-id.apps.googleusercontent.com")
.with_client_secret("your-client-secret")
.with_redirect_uri("http://localhost:8080/auth/callback")
.with_private_cookie_key("your-secret-key-at-least-32-bytes")
.with_scopes(vec!["openid", "email", "profile"])
.with_session_max_age(30)
// Note: DO NOT set end_session_endpoint for Google
.build()?;
let logout_handler = Arc::new(DefaultLogoutHandler);GitHub uses OAuth2 (not OIDC). Use DefaultLogoutHandler for logout.
use axum_oidc_client::logout::handle_default_logout::DefaultLogoutHandler;
let config = OAuthConfigurationBuilder::default()
.with_authorization_endpoint("https://github.com/login/oauth/authorize")
.with_token_endpoint("https://github.com/login/oauth/access_token")
.with_client_id("your-client-id")
.with_client_secret("your-client-secret")
.with_redirect_uri("http://localhost:8080/auth/callback")
.with_private_cookie_key("your-secret-key-at-least-32-bytes")
.with_scopes(vec!["read:user", "user:email"])
.with_session_max_age(30)
// Note: GitHub doesn't support OIDC logout
.build()?;
let logout_handler = Arc::new(DefaultLogoutHandler);Keycloak fully supports OIDC including RP-Initiated Logout. Use OidcLogoutHandler.
use axum_oidc_client::logout::handle_oidc_logout::OidcLogoutHandler;
let realm = "your-realm";
let keycloak_url = "https://keycloak.example.com";
let config = OAuthConfigurationBuilder::default()
.with_authorization_endpoint(&format!("{}/realms/{}/protocol/openid-connect/auth", keycloak_url, realm))
.with_token_endpoint(&format!("{}/realms/{}/protocol/openid-connect/token", keycloak_url, realm))
.with_end_session_endpoint(&format!("{}/realms/{}/protocol/openid-connect/logout", keycloak_url, realm))
.with_client_id("your-client-id")
.with_client_secret("your-client-secret")
.with_redirect_uri("http://localhost:8080/auth/callback")
.with_post_logout_redirect_uri("http://localhost:8080")
.with_private_cookie_key("your-secret-key-at-least-32-bytes")
.with_scopes(vec!["openid", "email", "profile"])
.with_session_max_age(30)
.build()?;
// Use OidcLogoutHandler with the end_session_endpoint
let logout_handler = Arc::new(OidcLogoutHandler::new(
&format!("{}/realms/{}/protocol/openid-connect/logout", keycloak_url, realm)
));Azure AD supports OIDC logout. Use OidcLogoutHandler.
use axum_oidc_client::logout::handle_oidc_logout::OidcLogoutHandler;
let tenant = "common"; // or your tenant ID
let config = OAuthConfigurationBuilder::default()
.with_authorization_endpoint(&format!("https://login.microsoftonline.com/{}/oauth2/v2.0/authorize", tenant))
.with_token_endpoint(&format!("https://login.microsoftonline.com/{}/oauth2/v2.0/token", tenant))
.with_end_session_endpoint(&format!("https://login.microsoftonline.com/{}/oauth2/v2.0/logout", tenant))
.with_client_id("your-client-id")
.with_client_secret("your-client-secret")
.with_redirect_uri("http://localhost:8080/auth/callback")
.with_post_logout_redirect_uri("http://localhost:8080")
.with_private_cookie_key("your-secret-key-at-least-32-bytes")
.with_scopes(vec!["openid", "email", "profile"])
.with_session_max_age(30)
.build()?;
// Use OidcLogoutHandler for Azure AD
let logout_handler = Arc::new(OidcLogoutHandler::new(
&format!("https://login.microsoftonline.com/{}/oauth2/v2.0/logout", tenant)
));| Provider | OIDC Support | Logout Support | Recommended Handler |
|---|---|---|---|
| Partial | ❌ No OIDC logout | DefaultLogoutHandler |
|
| GitHub | ❌ OAuth2 only | ❌ No OIDC logout | DefaultLogoutHandler |
| Keycloak | ✅ Full | ✅ RP-Initiated Logout | OidcLogoutHandler |
| Azure AD | ✅ Full | ✅ RP-Initiated Logout | OidcLogoutHandler |
| Okta | ✅ Full | ✅ RP-Initiated Logout | OidcLogoutHandler |
| Auth0 | ✅ Full | ✅ RP-Initiated Logout | OidcLogoutHandler |
Always use CodeChallengeMethod::S256 in production. The Plain method is only for testing or legacy systems that don't support S256.
.with_code_challenge_method(CodeChallengeMethod::S256) // ✅ Recommended
.with_code_challenge_method(CodeChallengeMethod::Plain) // ⚠️ Not recommendedUse a cryptographically strong random value for the private cookie key:
// ✅ Good: Generate with a tool like openssl
// openssl rand -base64 64
.with_private_cookie_key("generated-random-key-at-least-32-bytes-long")
// ❌ Bad: Hardcoded or weak key
.with_private_cookie_key("my-secret-key")Always use HTTPS for all endpoints in production:
// ✅ Production
.with_redirect_uri("https://myapp.com/auth/callback")
// ⚠️ Development only
.with_redirect_uri("http://localhost:8080/auth/callback")Configure appropriate expiration times based on your security requirements:
.with_session_max_age(30) // 30 minutes - balance between UX and security
.with_token_max_age(300) // 5 minutes - force token refreshYou can mount authentication routes at a custom base path instead of the default /auth:
use axum::{Router, routing::get};
use axum_oidc_client::{
auth::AuthLayer,
auth_builder::OAuthConfigurationBuilder,
logout::handle_default_logout::DefaultLogoutHandler,
};
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = OAuthConfigurationBuilder::default()
.with_client_id("your-client-id")
.with_client_secret("your-client-secret")
.with_base_path("/api/auth") // Custom base path
// IMPORTANT: redirect_uri must match your custom base path
.with_redirect_uri("http://localhost:8080/api/auth/callback")
.with_authorization_endpoint("https://provider.com/oauth/authorize")
.with_token_endpoint("https://provider.com/oauth/token")
.with_private_cookie_key("your-secret-key-at-least-32-bytes")
.with_session_max_age(30)
.build()?;
let cache = Arc::new(/* your cache implementation */);
let logout_handler = Arc::new(DefaultLogoutHandler);
let app = Router::new()
.route("/", get(home))
.route("/api/protected", get(protected))
.layer(AuthLayer::new(Arc::new(config), cache, logout_handler));
// Routes are now available at:
// - GET /api/auth (start OAuth flow)
// - GET /api/auth/callback (OAuth callback)
// - GET /api/auth/logout (logout)
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?;
axum::serve(listener, app).await?;
Ok(())
}
async fn home() -> &'static str { "Home" }
async fn protected(session: axum_oidc_client::auth_session::AuthSession) -> String {
format!("Protected content! Token expires: {}", session.expires)
}Key Points:
- Use
.with_base_path("/api/auth")in the configuration builder to set a custom base path - Update your
redirect_uriin the configuration to match:http://localhost:8080/api/auth/callback - Update your OAuth provider settings with the new redirect URI
- The base path can be any valid path (e.g.,
/oauth,/api/v1/auth, etc.) - Trailing slashes are automatically removed
- Default is
/authif not specified
See the examples/sample-server directory for a complete working example with:
- Environment variable configuration
- CLI argument parsing
- Multiple route types (public and protected)
- Redis cache integration
- Custom logout handling
Run the example:
cd examples/sample-server
cargo run -- --client-id YOUR_ID --client-secret YOUR_SECRETThe library uses a custom Error type for all operations:
use axum_oidc_client::errors::Error;
match config.build() {
Ok(config) => { /* use config */ },
Err(Error::MissingParameter(param)) => {
eprintln!("Missing required parameter: {}", param);
},
Err(e) => {
eprintln!("Configuration error: {:?}", e);
}
}# Run all tests
cargo test
# Run with specific features
cargo test --features redis
# Run example
cargo run --example sample-serverContributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the MIT License - see the LICENSE file for details.
- Built on top of Axum
- Uses pkce for PKCE implementation
- Session management with tower-cookies