# Authkestra — Complete Documentation Corpus (v0.13) > Source: https://docs.authkestra.com | Index: https://docs.authkestra.com/llms.txt | Crate: https://crates.io/crates/authkestra | Skills: https://github.com/Authkestra/skills --- # Introduction > What Authkestra is, how it's put together, and where to go next. URL: https://docs.authkestra.com/ Authkestra is an embedded identity engine for Rust: authentication and authorization logic that lives inside your application rather than a separate service you run alongside it. The core, `authkestra-engine`, is pure Rust with no web framework baked in — it defines the traits (`Provider`, `AuthMethod`, `Flow`, `SessionStore`, `KvStore`) and the `Engine` orchestrator built with a typestate builder, so a misconfigured stack fails to compile instead of failing at runtime. Framework support is added through thin adapter crates — `authkestra-axum` and `authkestra-actix` — that translate the engine's types into extractors and middleware for each framework. Everything the engine needs to persist (sessions, OAuth client state, OP tokens) is defined behind storage traits rather than a fixed schema, so you bring your own database and your own user table; Authkestra doesn't have one. On top of that core, `authkestra-providers` and `authkestra-oidc` supply concrete OAuth2/OIDC providers, and `authkestra-op` lets you run your own OpenID Connect Provider instead of only consuming one. ## Where to start - [Quickstart](https://docs.authkestra.com/guides/quickstart/): Get a working GitHub OAuth2 login running end to end. - [Framework Integration](https://docs.authkestra.com/guides/framework-integration/): Wire the engine into Axum or Actix-web. - [Authentication Flows](https://docs.authkestra.com/providers/oauth2/): Add a specific method: OAuth2, OIDC, passkeys, TOTP, device flow, and more. - [Building Servers](https://docs.authkestra.com/advanced/resource-server/): Run a resource server or your own OpenID Provider (OP). - [Data & Storage](https://docs.authkestra.com/storage/overview/): Choose and implement the stores the engine reads and writes. - [Why Authkestra?](https://docs.authkestra.com/concepts/architecture/): The design principles behind the typestate builder, stateless OAuth, and the trait-based storage model. ## Try it, read it Try the flows in a live sandbox at [play.authkestra.com](https://play.authkestra.com), or browse the source and open an issue on [GitHub](https://github.com/marcjazz/authkestra). --- # Architecture > Learn about Authkestra's unified engine, adapters, and framework-agnostic design. URL: https://docs.authkestra.com/concepts/architecture/ Authkestra is designed around a strictly layered architecture. Our goal is to provide a unified, highly composable authentication engine (`authkestra-engine`) that seamlessly integrates into various web frameworks via lightweight adapters, while remaining strictly database-agnostic. ## The Three Layers Our system is split into three distinct zones, ensuring clear boundaries and preventing leaky abstractions. ### 1. Engine (The Core) The **`authkestra-engine`** crate is the brain of the system. It is entirely framework-agnostic and contains pure Rust logic. - **Responsibilities**: Defines identity modeling, standard traits (`Provider`, `OAuthProvider`, `AuthMethod`, `Flow`, `SessionStore`, `KvStore`), protocol helpers (like PKCE and OIDC discovery), and the central `Engine` orchestrator. - **Why?**: By decoupling the engine from any specific web server (like Axum or Actix), we ensure your authentication logic is portable, highly testable, and isolated from HTTP request intricacies. ### 2. Extensions (Plugins) Pluggable components that implement the strict traits defined by the core engine and extend it with authorization, continuous trust, AI agent security, and identity providers. - **Examples**: - `authkestra-providers` (GitHub, Google, Discord) & `authkestra-oidc`: Concrete OAuth2 / OIDC provider discovery and token verification. - `authkestra-policy`: Declarative Attribute-Based Access Control (ABAC) using AWS Cedar policies, wired into Axum/Actix route guards, resource servers, and MCP tool execution. - `authkestra-ssf`: OpenID Shared Signals and Events (SSE) and Continuous Access Evaluation Profile (CAEP) push event receiver with automated session revocation in `SessionStore`. - `authkestra-mcp`: Embedded Model Context Protocol security with JCS parameter-bound step-up approval and zero-trust token vault. - `authkestra-devsig`: Request signatures and device-bound hardware attestation. - **Responsibilities**: These crates provide specialized protocol, policy, and cryptographic implementations without polluting the core engine or leaking HTTP transport logic. ### 3. Adapters (Integrations) Framework-specific wrappers that embed the engine into your web application. - **Examples**: `authkestra-axum`, `authkestra-actix`. - **Responsibilities**: They provide native middleware, explicit extractors (e.g., `AuthSession`), route guards, and handle raw HTTP request/response translations. ## Core Design Principles 1. **Typestate Builder Pattern**: The `Authkestra::builder()` utilizes Rust's typestate pattern to enforce configuration validity at compile-time. Misconfigurations are compile errors, not runtime panics. 2. **Stateless OAuth by Default**: OAuth `state` and `nonce` parameters are stored in securely encrypted cookies rather than a database. This guarantees horizontal scalability. 3. **Database Agnostic**: Authkestra never enforces schemas. All data access occurs through traits — `KvStore`/`SessionStore` for protocol state, `CredentialStore` for TOTP/WebAuthn/recovery-code secrets, and `authkestra-op`'s `OpStore` for OAuth clients, codes and tokens — allowing you to use SQLx, Diesel, Redis, or purely in-memory solutions. Note there is deliberately **no** user or account trait: your application owns those tables outright. 4. **Trait Objects over Generics**: For I/O-bound paths, we prefer dynamic dispatch (`Box`) to optimize compilation times and improve Developer Experience without sacrificing noticeable runtime performance. 5. **Observability**: Every handler and endpoint is deeply instrumented via the `tracing` crate, providing granular visibility into authentication flows in production. --- # Typestate Builder Pattern > Learn how Authkestra leverages the typestate pattern for compile-time safety. URL: https://docs.authkestra.com/concepts/typestate-builder/ Authkestra relies heavily on Rust's **Typestate Pattern** through the `Authkestra::builder()` API. This design pattern ensures that your authentication stack is configured correctly *at compile-time*, completely eliminating an entire class of runtime panics and misconfigurations. :::tip[Authkestra is an Alias] When you use the `authkestra` facade crate, `Authkestra` is exposed as a type alias for `authkestra_engine::Engine`. This means `Authkestra::builder()` is exactly the same as `Engine::builder()`, but we use the `Authkestra` name at the highest level for better semantic alignment. ::: ## What is the Typestate Pattern? In Rust, the typestate pattern is used to encode a state machine into the type system. Methods return new types representing the transitioned state, meaning certain methods simply do not exist unless the builder is in the correct state. For Authkestra, this means that the builder structurally guarantees you cannot use a feature if you haven't provided its prerequisite configuration. ## How Authkestra Uses It When you call `Authkestra::builder()`, it starts in an empty state. As you append configuration options (like providing an OAuth flow or a session store), the builder transitions through defined types. ### Example: Enforcing Session Configuration Consider building an engine that issues sessions. If you attempt to invoke session-related handler generation without first providing a `SessionStore`, the compiler will reject it. ```rust use authkestra::Authkestra; use authkestra_engine::store::memory::MemoryStore; use authkestra_engine::{OAuth2Flow, SessionStore}; use authkestra_providers::github::GithubProvider; use std::sync::Arc; // 1. Initialize a Provider let github_provider = GithubProvider::new( "CLIENT_ID".to_string(), "SECRET".to_string(), "URI".to_string() ); let session_store: Arc = Arc::new(MemoryStore::default()); // 2. Build the Engine let auth_engine = Authkestra::builder() .provider(OAuth2Flow::new(github_provider)) // If you omit this line, methods reliant on a session store won't exist! .session_store(session_store) .build(); ``` The typestate lives in the two generic parameters of `Engine` — the session-store slot and the token-manager slot — each of which is either `Missing` or `Configured<_>`. The aliases `AkWebAppEngine` (sessions), `AkApiEngine` (tokens) and `AkEngine` (both) name the combinations you are most likely to store in your application state, and are far more readable in a compiler error than the expanded generics. By leveraging this pattern, Authkestra provides an incredibly robust Developer Experience (DX). You can confidently rely on cargo and rust-analyzer to guide you through a correct, secure authentication setup. --- # Quickstart > The absolute fastest way to get an OAuth login working with Authkestra. URL: https://docs.authkestra.com/guides/quickstart/ This guide will walk you through the fastest way to get a working GitHub OAuth2 login flow using Authkestra. We provide unified integrations for both **Axum** and **Actix-web**. ## Prerequisites Add the `authkestra` facade crate to your `Cargo.toml`. The facade re-exports all sub-crates behind feature flags, allowing you to easily opt-in to the framework and identity providers you need. #### Axum ```toml [dependencies] authkestra = { version = "0.13", features = ["axum", "memory", "github"] } tower-cookies = "0.11" tokio = { version = "1", features = ["full"] } ``` #### Actix-web ```toml [dependencies] authkestra = { version = "0.13", features = ["actix", "macros", "memory", "github"] } actix-web = "4" ``` ## Setting Up the Engine The core logic of Authkestra is identical regardless of which web framework you choose. First, initialize the Identity Provider (`GithubProvider`), wrap it in an `OAuth2Flow`, and construct the `Engine` using the Typestate Builder Pattern (`Authkestra::builder()`). ```rust use authkestra::core::{OAuth2Flow, SessionStore}; use authkestra::providers::github::GithubProvider; use authkestra::store::memory::MemoryStore; use authkestra::Authkestra; use std::sync::Arc; // 1. Initialize the GitHub Provider let github_provider = GithubProvider::new( "YOUR_CLIENT_ID".to_string(), "YOUR_CLIENT_SECRET".to_string(), "http://localhost:3000/auth/callback/github".to_string() ); // 2. Setup the Session Store let session_store: Arc = Arc::new(MemoryStore::default()); // 3. Build the Authkestra Engine let auth_engine = Authkestra::builder() // `.provider()` takes a `Flow`, not a bare provider — wrap it in `OAuth2Flow`. .provider(OAuth2Flow::new(github_provider)) .session_store(session_store) .build(); ``` :::note `.session_store()` expects an `Arc`. The explicit type annotation above is what coerces the concrete `MemoryStore` into that trait object; without it the call will not resolve. ::: ## Wiring the Router Once the engine is built, integrate it into your web framework's routing layer. Authkestra provides specialized adapters (`authkestra-axum` and `authkestra-actix`) that automatically map standard authentication endpoints. #### Axum ```rust use authkestra::axum::{AxumExt, AxumState}; use authkestra::core::{AkWebAppEngine, OAuth2Flow, SessionStore}; use authkestra::providers::github::GithubProvider; use authkestra::store::memory::MemoryStore; use authkestra::Authkestra; use axum::Router; use std::sync::Arc; use tower_cookies::CookieManagerLayer; #[derive(Clone, AxumState)] #[authkestra(crate = ::authkestra::axum)] struct AppState { #[authkestra(engine)] auth: AkWebAppEngine, } #[tokio::main] async fn main() { let session_store: Arc = Arc::new(MemoryStore::default()); let auth_engine = Authkestra::builder() .provider(OAuth2Flow::new(GithubProvider::new( "YOUR_CLIENT_ID".to_string(), "YOUR_CLIENT_SECRET".to_string(), "http://localhost:3000/auth/callback/github".to_string(), ))) .session_store(session_store) .build(); let state = AppState { auth: auth_engine.clone() }; let app = Router::new() // `axum_router()` wires `/auth/login/{provider}`, `/auth/callback/{provider}` // and `/auth/logout`. .merge(auth_engine.axum_router()) .layer(CookieManagerLayer::new()) .with_state(state); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); axum::serve(listener, app).await.unwrap(); } ``` The full version of this example is runnable from the repository: ```bash cargo run -p authkestra --example axum_oauth2_github --all-features ``` #### Actix-web ```rust use actix_web::{web, App, HttpServer}; use authkestra::actix::{ActixExt, ActixState}; use authkestra::core::{AkWebAppEngine, OAuth2Flow, SessionStore}; use authkestra::providers::github::GithubProvider; use authkestra::store::memory::MemoryStore; use authkestra::Authkestra; use std::sync::Arc; #[derive(Clone, ActixState)] #[authkestra(crate = ::authkestra::actix)] struct AppState { #[authkestra(engine)] auth: AkWebAppEngine, } #[actix_web::main] async fn main() -> std::io::Result<()> { let session_store: Arc = Arc::new(MemoryStore::default()); let auth_engine = Authkestra::builder() .provider(OAuth2Flow::new(GithubProvider::new( "YOUR_CLIENT_ID".to_string(), "YOUR_CLIENT_SECRET".to_string(), "http://localhost:3000/auth/callback/github".to_string(), ))) .session_store(session_store) .build(); let state = AppState { auth: auth_engine }; HttpServer::new(move || { let app_state = state.clone(); let config_state = app_state.clone(); App::new() // `configure_authkestra` (from `ActixState`) registers the session store // and config as app data so the extractors can find them. .configure(move |cfg| config_state.configure_authkestra(cfg)) // `actix_scope()` mounts `/auth/login/{provider}`, // `/auth/callback/{provider}` and `/auth/logout`. .service(app_state.auth.actix_scope()) }) .bind("0.0.0.0:3000")? .run() .await } ``` The full version of this example is runnable from the repository: ```bash cargo run -p authkestra --example actix_oauth2_github --all-features ``` With just these few lines of code, your application is now fully equipped to handle OAuth logins, validate responses, and issue secure sessions! --- # Framework Integration > Using Authkestra with Axum and Actix-web. URL: https://docs.authkestra.com/guides/framework-integration/ Authkestra provides first-class support for both the `axum` and `actix-web` web frameworks through lightweight adapter crates (`authkestra-axum` and `authkestra-actix`). ## Prerequisites Ensure you have enabled the correct web framework feature on the `authkestra` crate in your `Cargo.toml`. #### Axum ```toml [dependencies] authkestra = { version = "0.13", features = ["axum", "memory"] } ``` #### Actix-web ```toml [dependencies] authkestra = { version = "0.13", features = ["actix", "macros", "memory"] } ``` ## Which extractor needs which engine Both extractors below read what they need out of your **router state**, not out of a global. On Axum that is a compile-time requirement: the `AxumState` derive (see *Router Setup*) generates the `FromRef` impls, and it only generates the ones your engine's typestate actually has. So the engine alias you store in state decides which extractor compiles: | Engine alias | `.session_store()` | `.jwt_secret()` / `.token_manager()` | `AuthSession` | `AuthToken` | | --- | --- | --- | --- | --- | | `AkWebAppEngine` | ✅ | — | ✅ | ❌ compile error | | `AkApiEngine` | — | ✅ | ❌ compile error | ✅ | | `AkEngine` | ✅ | ✅ | ✅ | ✅ | Using `AuthToken` with an `AkWebAppEngine` state fails with `the trait bound Result, AxumError>: FromRef is not satisfied` — the fix is to build the engine with a token manager and store it as `AkApiEngine` or `AkEngine`, not to change the handler. On Actix the same requirement exists but is resolved at *runtime* from `app_data`: a handler taking `Option` compiles against any state and simply always yields `None` when no `TokenManager` was registered. ## The `AuthSession` Extractor The primary way to interact with an authenticated user in a handler when using sessions is via the `AuthSession` extractor. It automatically validates the session from cookies and injects the user's identity. #### Axum ```rust use authkestra::axum::{AuthSession, AxumError}; use axum::response::{IntoResponse, Json}; use serde_json::json; pub async fn my_handler( session: Result ) -> impl IntoResponse { match session { Ok(AuthSession(session_data)) => Json(json!({ "message": "Welcome!", "user_id": session_data.identity.external_id, })), Err(_) => Json(json!({ "error": "Not authenticated" })) } } ``` #### Actix-web Actix extractors reject with `actix_web::Error`, so the idiomatic form here is `Option` (or `Result`) — there is no `ActixError` type. ```rust use authkestra::actix::AuthSession; use actix_web::{get, HttpResponse, Responder}; #[get("/api/user")] async fn get_user(session: Option) -> impl Responder { match session { Some(AuthSession(session_data)) => HttpResponse::Ok().json( serde_json::json!({ "message": "Welcome!", "user_id": session_data.identity.external_id, }) ), None => HttpResponse::Unauthorized().json( serde_json::json!({ "error": "Not authenticated" }) ), } } ``` ## The `AuthToken` Extractor For stateless JWT authentication, Authkestra provides the `AuthToken` extractor which reads and validates the `Authorization: Bearer ` header. #### Axum ```rust use authkestra::axum::{AuthToken, AxumError}; use axum::response::{IntoResponse, Json}; use serde_json::json; pub async fn token_protected_handler( token: Result ) -> impl IntoResponse { match token { Ok(AuthToken(claims)) => Json(json!({ "message": "Valid Token!", "sub": claims.sub, })), Err(_) => Json(json!({ "error": "Invalid or missing token" })) } } ``` #### Actix-web ```rust use authkestra::actix::AuthToken; use actix_web::{get, HttpResponse, Responder}; #[get("/api/protected")] async fn get_protected(token: Option) -> impl Responder { match token { Some(AuthToken(claims)) => HttpResponse::Ok().json( serde_json::json!({ "message": "Valid Token!", "sub": claims.sub, }) ), None => HttpResponse::Unauthorized().json( serde_json::json!({ "error": "Invalid token" }) ), } } ``` ## Router Setup To wire up standard authentication endpoints automatically, use `axum_router()` / `axum_router_stateless()` for Axum, or `actix_scope()` / `actix_scope_stateless()` for Actix-web on the built `Engine`. The Axum methods return an `axum::Router`; the Actix methods return an `actix_web::Scope` you mount with `.service(...)`. :::note[The stateless routers live on their own traits] `axum_router()` comes from `AxumExt` and `actix_scope()` from `ActixExt`, but their stateless counterparts come from **`AxumStatelessExt`** and **`ActixStatelessExt`**. Importing only `AxumExt` and then calling `axum_router_stateless()` fails with "no method named `axum_router_stateless` found". See [Stateless OAuth2](/providers/oauth2/) for the full wiring. ::: #### Axum ```rust use authkestra::axum::{AxumExt, AxumState}; use authkestra::core::AkWebAppEngine; use axum::Router; use tower_cookies::CookieManagerLayer; #[derive(Clone, AxumState)] #[authkestra(crate = ::authkestra::axum)] struct AppState { #[authkestra(engine)] auth: AkWebAppEngine, } let state = AppState { auth: auth_engine.clone() }; let app: Router = Router::new() .merge(auth_engine.axum_router()) .layer(CookieManagerLayer::new()) .with_state(state); ``` `.with_state(state)` is not optional decoration: `axum_router()` returns a `Router`, and the extractors resolve their dependencies out of that `AppState`. A `Router<()>` will not compile. #### Actix-web ```rust use actix_web::{App, HttpServer}; use authkestra::actix::{ActixExt, ActixState}; use authkestra::core::AkWebAppEngine; #[derive(Clone, ActixState)] #[authkestra(crate = ::authkestra::actix)] struct AppState { #[authkestra(engine)] auth: AkWebAppEngine, } let state = AppState { auth: auth_engine }; HttpServer::new(move || { let app_state = state.clone(); let config_state = app_state.clone(); App::new() .configure(move |cfg| config_state.configure_authkestra(cfg)) .service(app_state.auth.actix_scope()) }) ``` --- # Wired Endpoints > Learn how Authkestra automatically maps authentication endpoints, and when to wire them manually. URL: https://docs.authkestra.com/guides/wired-endpoints/ When building an authentication flow, you need HTTP endpoints that initiate the login and endpoints that handle the callback from the identity provider. ## Automatic Route Wiring (Session Mode) If you are using a stateful session store, the easiest way to get started is by letting Authkestra automatically wire the endpoints for you using `auth_engine.axum_router()` (returns an `axum::Router`) or `auth_engine.actix_scope()` (returns an `actix_web::Scope` you mount with `.service(...)`). ### Client Login Endpoints The auto-wired router generates three standard endpoints, all resolving the provider at runtime: #### 1. `GET /auth/login/{provider}` This endpoint **initiates the OAuth2/OIDC flow**. - It constructs the authorization URL using the provider's configuration. - It generates a cryptographically secure `state` and `nonce`, storing them in an encrypted, HTTP-only cookie. - Finally, it returns an HTTP 303 See Other to send the user to the provider (e.g., Google or GitHub). Both adapters use 303, so the status code does not change if you swap axum for actix. #### 2. `GET /auth/callback/{provider}` This endpoint **handles the provider's redirect callback**. - It verifies that the `state` parameter matches the one in the secure cookie (preventing CSRF attacks). - It exchanges the authorization code for an Access Token (and optionally an ID Token). - It establishes a server-side session and issues a session cookie to the client. #### 3. `GET /auth/logout` This endpoint **ends the session**: it deletes the session from the store and clears the session cookie. ## Automatic Route Wiring (Stateless Mode) What if you don't want to use sessions at all? For example, in a purely **Stateless OAuth2** flow, you want the callback to return a JSON Web Token (JWT) in the response body instead of setting a session cookie. For stateless environments, Authkestra provides the `auth_engine.axum_router_stateless()` and `auth_engine.actix_scope_stateless()` counterparts. They wire the login and callback endpoints only (there is no server-side session to log out of), and the callback handler returns a JWT instead of initializing a session. These two methods live on **`AxumStatelessExt`** and **`ActixStatelessExt`**, separate traits from the `AxumExt` / `ActixExt` that carry the session-mode routers. Import the stateless trait or the method will not resolve. They also require an engine with a token manager (`AkApiEngine` or `AkEngine`), because the callback has to mint the JWT it returns. ## Manual Route Wiring & Custom Flows If you need absolute control over the HTTP response (e.g. to append headers, write to custom databases, or augment the JWT response), you can bypass the automatic routers entirely. Instead, you manually define your routes and call Authkestra's `helpers`. (See the **Stateless OAuth2** page for a detailed breakdown of how these manual handlers work). ## Multiple Providers Authkestra's routing is deeply dynamic. The `{provider}` path parameter is resolved at runtime against the providers registered in the engine. If you register multiple providers (e.g., `.provider(OAuth2Flow::new(github)).provider(OAuth2Flow::new(google))`), the same routes handle *both* providers dynamically based on whether you call `/auth/login/github` or `/auth/login/google`. Each provider's `provider_id()` is what the path segment is matched against. --- # AI Coding Assistants & Agent Auth > Using Authkestra with Claude Code, Cursor, and Windsurf via llms.txt and official skills, and securing AI agents & MCP servers in Rust. URL: https://docs.authkestra.com/guides/ai-agents/ Authkestra is designed for two sides of the AI era: 1. **Zero-Hallucination Code Generation ("Vibe-Coding" Guardrails)**: Because `Authkestra::builder()` uses a strict **Typestate Builder Pattern**, an AI coding assistant cannot accidentally wire a session route without a session store or a stateless JWT callback without a token manager — invalid authentication flows fail at `cargo check` instead of shipping a runtime vulnerability. 2. **High-Assurance AI Agent & MCP Authorization**: Authkestra's embedded OIDC Provider (`authkestra-op`), Resource Server (`authkestra-resource`), Device-Bound Signatures (`authkestra-devsig`), WebAuthn Passkeys, and AWS Cedar Policy Engine (`authkestra-policy`) provide the exact cryptographic primitives needed to authenticate AI agents, broker scoped downstream tokens, and enforce human-in-the-loop (HITL) step-up approval. --- ## 1. `llms.txt` & Official Coding Agent Skills ### `llms.txt` and `llms-full.txt` Every deploy of `docs.authkestra.com` generates machine-readable documentation indexes following the [llms.txt standard](https://llmstxt.org/): - **[`https://docs.authkestra.com/llms.txt`](/llms.txt)**: Concise index of all Authkestra crates, feature flags, architectural rules, and documentation links. - **[`https://docs.authkestra.com/llms-full.txt`](/llms-full.txt)**: Complete, single-file concatenation of the entire Authkestra documentation corpus for pasting into Claude, Cursor `@Docs`, Windsurf, or local RAG pipelines. ### Official Agent Skill Pack (`Authkestra/skills`) Install the official [Authkestra skill pack](https://github.com/Authkestra/skills) into Claude Code, Cursor, or any SKILL.md-compatible coding agent: ```bash /plugin marketplace add Authkestra/skills /plugin install authkestra@authkestra ``` | Skill | What it teaches your coding agent | | --- | --- | | `authkestra-quickstart` | End-to-end Axum/Actix setup, TLS backend selection, typestate builder, and extractor wiring (`v0.13`). | | `authkestra-features` | Diagnosing missing Cargo feature flags vs. unmet typestate prerequisites. | | `authkestra-store` | Implementing `KvStore`, `AtomicConsume`, `AtomicDecrement`, `CredentialStore`, and `OpStore`, validated with `authkestra-store-testsuite`. | | `authkestra-social-login` | GitHub/Google/Discord OAuth2, OIDC discovery caching, and stateless encrypted-cookie handshakes. | | `authkestra-op` | Running an embedded OpenID Provider: PKCE, DPoP, `private_key_jwt`, Device Flow, and RFC 8693 Token Exchange. | | `authkestra-resource-server` | Protecting APIs and MCP servers with `Guard`, multi-issuer JWKS, DPoP, and mTLS. | | `authkestra-mfa` | Passkeys (WebAuthn), TOTP, Magic Links, OTP, Recovery Codes, `ReproofRequirement`, and `authkestra-devsig`. | | `authkestra-upgrade` | Migrating from pre-RFC-001 crates (`authkestra-core`, `authkestra-flow`, `authkestra-session`, `authkestra-token`) and upgrading to `0.13` `ReproofRequirement` signatures. | | `authkestra-troubleshoot` | Resolving `rustls` CryptoProvider panics, cross-site OAuth cookie drops, and clock skew. | --- ## 2. Securing AI Agents & MCP Servers with `authkestra-mcp` Enable the `mcp` feature on the `authkestra` facade crate (or depend on `authkestra-mcp` directly) to embed a three-tier Policy Enforcement Point directly into your Rust Model Context Protocol (MCP) server: ```toml [dependencies] authkestra = { version = "0.13", features = ["axum", "mcp", "memory"] } ``` ### Three Composable Security Tiers (`McpSecurityTier`) Register tools on `McpRouter::builder()` with per-tool security policies: - **`McpSecurityTier::Public`**: Callable without authentication (or with optional caller identity). - **`McpSecurityTier::Scoped { required_scopes }`**: Validates Bearer or DPoP-bound tokens via `authkestra-resource`, returning RFC 9728 `WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource"` on `401 Unauthorized` and `insufficient_scope` on `403 Forbidden`. - **`McpSecurityTier::HumanApproved { required_scopes, reproof, approval_ttl_secs }`**: Requires both scope validation and a single-use cryptographic step-up human approval bound to the exact tool name and canonicalized arguments. ### RFC 8785 JCS Cryptographic Parameter Binding When an agent invokes a `HumanApproved` tool without an execution proof, `authkestra-mcp` canonicalizes the JSON arguments per **RFC 8785 (JCS)** and computes a deterministic SHA-256 digest over `(session_id, tool_name, jcs_canonical_arguments, expires_at)`. It returns a `-32042` step-up elicitation challenge (`approval_id`, `challenge_digest`, `verification_url`). Once the human user approves the challenge with a fresh proof (`ReproofRequirement` + [Passkey](/providers/passkeys/), [TOTP](/providers/totp/), or [Device Signature](/providers/device-signatures/)), `authkestra-mcp` mints a single-use execution token consumed atomically via `AtomicConsume`. Mutating a single tool parameter (e.g., changing `amount: 50` to `amount: 5000`) deterministically invalidates the approval. ### Zero-Trust Downstream Token Vault (`EncryptedKvTokenVault`) Never expose long-lived OAuth refresh tokens or third-party API keys inside an LLM's context window. `EncryptedKvTokenVault` encrypts downstream provider tokens at rest in your `KvStore` using **AES-256-GCM** with AAD subject/provider binding (`{subject_id}:{provider_id}`), and `ToolContext::downstream_token("github")` resolves scoped tokens on behalf of the authenticated `Identity` inside your tool handler. ### Guardrails & Instant Kill-Switch (`authkestra-policy` + `authkestra-ssf`) Evaluate tool invocations against declarative [AWS Cedar policies](/advanced/policy-engine/) (`authkestra-policy`) and terminate compromised agent sessions in real time via [OpenID CAEP `SessionRevoked` events](/advanced/continuous-access-evaluation/) (`authkestra-ssf`). --- # Stateless OAuth2 > Configuring Stateless OAuth with Authkestra. URL: https://docs.authkestra.com/providers/oauth2/ By default, OAuth2 logins can create a server-side session. However, Authkestra allows you to build a strictly **stateless** flow where authentication results in a signed JWT (JSON Web Token). "Stateless" means that the entire flow can be completed *without* using a session store. ## The OAuth2 Standard Authkestra strictly implements the [OAuth 2.0 Authorization Framework (RFC 6749)](https://datatracker.ietf.org/doc/html/rfc6749) and [PKCE (RFC 7636)](https://datatracker.ietf.org/doc/html/rfc7636). We provide a memory-safe, idiomatic Rust engine to execute them. ## Prerequisites If you are using multiple OAuth providers (e.g., GitHub and Google), enable their respective feature flags: ```toml [dependencies] authkestra = { version = "0.13", features = ["axum", "github", "google"] } ``` ## Stateless Engine Configuration Notice how we completely omit `.session_store()` and use `.jwt_secret()` instead. You can chain multiple providers, each wrapped in an `OAuth2Flow`: ```rust use authkestra::Authkestra; use authkestra::providers::github::GithubProvider; use authkestra::providers::google::GoogleProvider; use authkestra_engine::OAuth2Flow; // Initialize Authkestra in stateless mode supporting multiple providers let auth_engine = Authkestra::builder() .provider(OAuth2Flow::new(GithubProvider::new( "GITHUB_CLIENT_ID".into(), "GITHUB_CLIENT_SECRET".into(), "http://localhost:3000/auth/callback/github".into(), ))) .provider(OAuth2Flow::new(GoogleProvider::new( "GOOGLE_CLIENT_ID".into(), "GOOGLE_CLIENT_SECRET".into(), "http://localhost:3000/auth/callback/google".into(), ))) .jwt_secret(b"your-256-bit-secret-key-at-least-32-bytes-long") .build(); ``` A runnable version of this setup lives in the repository: ```bash cargo run -p authkestra --example axum_oauth_stateless --all-features ``` ## Wiring the Routes Because we are not using sessions, we use the stateless router counterpart: `auth_engine.axum_router_stateless()` for Axum or `auth_engine.actix_scope_stateless()` for Actix-web. These live on `AxumStatelessExt` / `ActixStatelessExt`, **not** on `AxumExt` / `ActixExt` — importing the wrong trait is the usual cause of "no method named `axum_router_stateless` found". #### Axum ```rust use authkestra::axum::{AxumState, AxumStatelessExt}; use authkestra::core::AkApiEngine; // The router's state must carry the token-configured engine; the derive generates the // `FromRef` impls `axum_router_stateless()` requires. A bare `Router<()>` will not compile. #[derive(Clone, AxumState)] struct AppState { #[authkestra(engine)] auth: AkApiEngine, } let state = AppState { auth: auth_engine.clone() }; let app: axum::Router = axum::Router::new() .merge(auth_engine.axum_router_stateless()) .with_state(state); ``` #### Actix Web ```rust use authkestra::actix::{ActixState, ActixStatelessExt}; use authkestra::core::AkApiEngine; #[derive(Clone, ActixState)] struct AppState { #[authkestra(engine)] auth: AkApiEngine, } let state = AppState { auth: auth_engine.clone() }; let app = actix_web::App::new() // `configure_authkestra` registers the token manager as app data so `AuthToken` resolves. .configure(move |cfg| state.configure_authkestra(cfg)) .service(auth_engine.actix_scope_stateless()); ``` ### Wiring Handlers Manually If you need absolute control over the HTTP response, you can bypass the automatic router and handle requests manually using Authkestra's helpers in `authkestra-axum` or `authkestra-actix`. #### Axum ```rust use axum::{extract::{Path, State, Query}, response::IntoResponse}; use authkestra::axum::helpers; async fn login_handler( Path(provider): Path, State(state): State, Query(params): Query, cookies: tower_cookies::Cookies, ) -> impl IntoResponse { helpers::axum_login_handler::(Path(provider), State(state), Query(params), cookies).await } ``` #### Actix Web ```rust use actix_web::{web, Responder}; use authkestra::actix::helpers; // `Engine` is the typestate engine itself — `S`/`T` are the session-store and // token-manager states, not your application state. async fn login_handler( path: web::Path, authkestra: web::Data, params: web::Query, ) -> impl Responder { helpers::actix_login_handler(path, authkestra, params).await } ``` By manually exposing these handlers, you gain full control over the HTTP response. You can append headers, record analytics, or return the JWT inside a custom JSON body. --- # OIDC Client > Using the generic OpenID Connect client with Authkestra. URL: https://docs.authkestra.com/providers/oidc/ Authkestra provides a generic OpenID Connect (OIDC) client that automatically discovers endpoints and keys from any OIDC-compliant provider and background-caches discovery documents. ## Prerequisites To use the OIDC client, enable the `oidc` feature on the `authkestra` crate. ```toml [dependencies] authkestra = { version = "0.13", features = ["oidc", "memory"] } ``` ## Configuring the OIDC Provider Instantiate `OidcProvider` directly by pointing it to the issuer URL. The client automatically fetches and caches `/.well-known/openid-configuration` to discover authorization and token endpoints. ```rust use authkestra::Authkestra; use authkestra::oidc::OidcProvider; use authkestra_engine::store::memory::MemoryStore; use authkestra_engine::{OAuth2Flow, SessionStore}; use std::sync::Arc; use std::time::Duration; #[tokio::main] async fn main() -> Result<(), Box> { // Perform discovery automatically let oidc_provider = OidcProvider::discover( "CLIENT_ID".to_string(), "CLIENT_SECRET".to_string(), "http://localhost:3000/auth/callback/oidc".to_string(), "https://accounts.google.com", // Issuer URL (&str) // Fallback refresh interval, used when the discovery response carries // no usable `Cache-Control: max-age`. Duration::from_secs(3600), ).await?; let session_store: Arc = Arc::new(MemoryStore::default()); let auth_engine = Authkestra::builder() .provider(OAuth2Flow::new(oidc_provider)) .session_store(session_store) .build(); Ok(()) } ``` A runnable version (using Google as the issuer) lives in the repository: ```bash cargo run -p authkestra --example axum_oidc_google --all-features ``` *(Note: If you are building a Resource Server that needs to validate incoming JWTs against an external OIDC provider, refer to the **Resource Server** guide for information on OIDC Discovery caching and JWKS validation).* --- # Client Credentials > Machine-to-machine authentication using the Client Credentials flow. URL: https://docs.authkestra.com/providers/client-credentials/ The **Client Credentials** grant type (defined in [RFC 6749 Section 4.4](https://datatracker.ietf.org/doc/html/rfc6749#section-4.4)) is used for machine-to-machine authentication. Unlike standard OAuth flows, there is no interactive user involved. Instead, the application authenticates itself to the Identity Provider using its own Client ID and Client Secret to obtain an access token. ## Prerequisites The Client Credentials flow is built natively into `authkestra-engine`. The snippet below uses the engine's own types, so depend on it directly (the `authkestra` facade re-exports the same module as `authkestra::core`, if you prefer a single dependency): ```toml [dependencies] authkestra-engine = "0.13" tokio = { version = "1", features = ["full"] } ``` A runnable version lives in the repository — it needs no web framework at all: ```bash cargo run -p authkestra-engine --example client_credentials ``` ## Using `ClientCredentialsFlow` Authkestra provides a dedicated `ClientCredentialsFlow` struct to handle the token exchange securely. ```rust use authkestra_engine::ClientCredentialsFlow; #[tokio::main] async fn main() -> Result<(), Box> { let client_id = "your_client_id".to_string(); let client_secret = "your_client_secret".to_string(); let token_url = "https://example.com/oauth/token".to_string(); // 1. Initialize the Flow let flow = ClientCredentialsFlow::new(client_id, client_secret, token_url); // 2. Request a token (with optional scopes) let scopes = ["api:read", "api:write"]; match flow.get_token(Some(&scopes)).await { Ok(token) => { println!("Successfully obtained access token!"); println!("Access Token: {}", token.access_token); if let Some(expires_in) = token.expires_in { println!("Expires in: {} seconds", expires_in); } } Err(e) => { eprintln!("Failed to obtain access token: {}", e); } } Ok(()) } ``` ## When to use this flow You should use the Client Credentials flow when: 1. You are building a backend daemon, cron job, or microservice. 2. The application needs to interact with an API on its *own* behalf, rather than on behalf of a specific user. 3. The credentials (Client ID and Secret) can be stored securely (e.g., in a secret manager or environment variables) away from public access. ## `private_key_jwt` client authentication (RFC 7523) Instead of sending a shared `client_secret`, a client can authenticate by signing a short-lived JWT assertion with its own private key. `ClientCredentialsFlow::new_private_key_jwt` replaces `new` for that: ```rust use authkestra_engine::ClientCredentialsFlow; use jsonwebtoken::{Algorithm, EncodingKey}; let flow = ClientCredentialsFlow::new_private_key_jwt( "your_client_id".to_string(), EncodingKey::from_ec_pem(private_key_pem)?, Algorithm::ES256, // Also becomes the `aud` claim of every assertion. "https://example.com/oauth/token".to_string(), ); ``` `alg` must agree with the key type the authorization server has registered for this client — Authkestra's own OP derives the algorithm it accepts from the *registered public key*, never from the assertion header. If the server holds several keys for the client, chain `.with_kid("k1")` so it can tell which one signed. Everything after that is unchanged: call `get_token(...)` as above. On the *server* side, accepting `private_key_jwt` requires wiring a `ClientAssertionStore` for single-use `jti` tracking — see the [OP Server](/advanced/op-server/) page; the default refuses every assertion. --- # Device Flow > Authenticating input-constrained devices using OAuth 2.0 Device Authorization Grant. URL: https://docs.authkestra.com/providers/device-flow/ The **Device Authorization Grant** (defined in [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) is designed for devices that either lack a browser or have limited input capabilities (e.g., Smart TVs, CLI tools, IoT devices). Instead of authenticating directly on the device, the user is presented with a short code and a URL. They open the URL on a secondary device (like a smartphone or laptop), enter the code, and authenticate normally. Once completed, the primary device (which has been polling the server in the background) automatically receives its access token. ## Prerequisites The Device Flow logic is built natively into `authkestra-engine`. The snippet below uses the engine's own types, so depend on it directly (the `authkestra` facade re-exports the same module as `authkestra::core`, if you prefer a single dependency): ```toml [dependencies] authkestra-engine = "0.13" tokio = { version = "1", features = ["full"] } ``` A runnable version lives in the repository — it needs no web framework at all: ```bash cargo run -p authkestra-engine --example device_flow ``` ## Using `DeviceFlow` Authkestra provides the `DeviceFlow` struct which handles the two-step process: initiating the request and polling for completion. ```rust use authkestra_engine::DeviceFlow; #[tokio::main] async fn main() -> Result<(), Box> { // Example: GitHub's Device Flow Endpoints let client_id = "your_client_id".to_string(); let device_auth_url = "https://github.com/login/device/code".to_string(); let token_url = "https://github.com/login/oauth/access_token".to_string(); let flow = DeviceFlow::new(client_id, device_auth_url, token_url); // 1. Initiate Device Authorization let device_resp = flow .initiate_device_authorization(&["user", "repo"]) .await?; println!("Open your browser and go to: {}", device_resp.verification_uri); println!("Enter the code: {}", device_resp.user_code); // Some providers return a complete URL that pre-fills the code if let Some(complete_uri) = &device_resp.verification_uri_complete { println!("Or click this direct link: {}", complete_uri); } println!("Waiting for authorization..."); // 2. Poll the token endpoint // Authkestra automatically handles the polling interval and respects `authorization_pending` errors match flow.poll_for_token(&device_resp.device_code, device_resp.interval).await { Ok(token) => { println!("Authorization successful!"); println!("Access Token: {}", token.access_token); } Err(e) => eprintln!("Authorization failed: {}", e), } Ok(()) } ``` ## How Polling Works `poll_for_token(&self, device_code: &str, interval: Option)` takes the interval as an `Option` — pass `device_resp.interval` straight through; it falls back to 5 seconds when the provider did not specify one. It then automatically: - Executes HTTP requests at the provider-requested `interval`. - Intercepts `authorization_pending` and `slow_down` error responses without failing the future, dynamically adjusting the poll rate if the provider requests a slow down. - Exits with an `expired_token` error if the user takes too long and the device code expires. :::caution[`poll_for_token` blocks the thread between polls] The wait between polls is a blocking sleep, not an async one, so awaiting `poll_for_token` on a multi-purpose async runtime stalls that worker thread for the whole ceremony (potentially minutes). Until that is fixed upstream, drive it from a dedicated thread — `tokio::task::spawn_blocking`, or its own runtime — rather than from a request handler. ::: --- # Passkeys (WebAuthn) > Learn how to implement phishing-resistant authentication using Passkeys in Authkestra. URL: https://docs.authkestra.com/providers/passkeys/ Passkeys provide a highly secure, phishing-resistant alternative to passwords by utilizing public-key cryptography (WebAuthn). Authkestra supports Passkeys out of the box through the `WebAuthnAuthMethod` which is directly integrated into the `Engine` for seamless multi-factor and primary authentication flows. ## Enabling Passkeys To use Passkeys, enable the `webauthn` feature in your `Cargo.toml` (either on the `authkestra` facade or directly on `authkestra-engine`): ```toml [dependencies] authkestra = { version = "0.13", features = ["webauthn"] } ``` ## Configuration Authkestra uses `webauthn-rs` to power passkey support. You'll need to instantiate a `WebauthnBuilder`, configure your Relaying Party (RP) ID and Origin, and pass it into a `WebAuthnAuthMethod` instance when building your `Engine`. ```rust use std::sync::Arc; use webauthn_rs::prelude::WebauthnBuilder; use url::Url; use authkestra::Authkestra; let rp_id = "example.com"; let origin = Url::parse("https://example.com").unwrap(); let webauthn = WebauthnBuilder::new(rp_id, &origin) .unwrap() .rp_name("Authkestra Demo") .build() .unwrap(); // `my_store` implements the `CredentialStore` trait. // `with_webauthn` is shorthand for `.with_auth_method(WebAuthnAuthMethod::new(..))` // and registers the method under the name `"webauthn"`. let engine = Authkestra::builder() .with_webauthn(Arc::new(webauthn), my_store) .build(); ``` A runnable example combining passkeys with TOTP lives in the repository: ```bash cargo run -p authkestra --example axum_mfa_server --all-features ``` ## Registration Ceremony (Re-Proof Gated) Enrolling a passkey is a security-posture change, so `start_register` requires an authenticated `Identity` and a [`ReproofRequirement`](/providers/passwordless/#re-proof-gating-reproofrequirement) (introduced in `0.13`) rather than a bare user ID string. This prevents a stale or stolen session from silently enrolling an attacker's passkey, and binds the subject into the returned `PasskeyEnrolment` state so `finish_register` cannot be tricked into filing the credential under a different account: ```rust use authkestra_engine::auth::{webauthn::WebAuthnAuthMethod, AuthMethod, ReproofRequirement}; let method = WebAuthnAuthMethod::new(Arc::new(webauthn), my_store); // Require authentication within the last 300 seconds, plus step-up if a passkey is already enrolled let gate = ReproofRequirement::new(300) .require_step_up(method.has_enrolled(&session.identity.external_id).await?); // 1. Start: return `challenge` to the browser for `navigator.credentials.create()`, // and stash `enrolment` (`PasskeyEnrolment`) in server-side session state. let (challenge, enrolment) = method.start_register(&session.identity, &gate, "ada")?; // 2. Finish: verify the attestation and persist the passkey via the CredentialStore. // `enrolment` already carries the verified subject, so no user_id argument is passed here. let passkey = method.finish_register(enrolment, reg_response).await?; ``` ### User handles `user.id` in the creation options is the **user handle**. The authenticator stores it inside the credential and returns it as `response.userHandle` on every later assertion, which is what makes discoverable ("usernameless") sign-in able to resolve an account. It must be stable for the lifetime of the account and map back to exactly one user. `start_register` derives the handle from `identity.external_id` with `derive_user_handle`: - a `user_id` that already parses as a UUID is used verbatim; - anything else (CUID, ULID, prefixed id, integer) is hashed into a **UUIDv5** over the documented `USER_HANDLE_NAMESPACE` constant. Because the derivation is a plain UUIDv5, you can reproduce the same handle in any language to index your own users by handle. If your application allocates handles itself — or needs a display name distinct from the username, since `start_register` passes `username` for both — use `start_register_with_handle`: ```rust use authkestra_engine::auth::webauthn::derive_user_handle; let handle = derive_user_handle(&session.identity.external_id); // or your own stable handle let (challenge, enrolment) = method.start_register_with_handle(&session.identity, &gate, handle, "ada", "Ada Lovelace")?; ``` ## Authentication Ceremony WebAuthn uses a two-step "ceremony": 1. **Start**: The server generates a cryptographic challenge and temporarily stores the session state. 2. **Finish**: The client (browser) signs the challenge with the authenticator and returns the signature to the server. The server verifies the signature and completes the authentication via the unified `Engine::authenticate` method. ### Starting Authentication When the user wants to sign in, call `start_authentication`. You must pass the user's previously registered `Passkey` objects (retrieved from your credential store). `Engine::start_webauthn` is the usual entry point; to call it directly on a `WebAuthnAuthMethod` you must bring the `authkestra_engine::auth::WebAuthnStarter` trait into scope, since that is where the method lives: ```rust // `passkeys` is a `Vec` loaded from your store let (challenge_response, auth_state) = engine.start_webauthn(&passkeys)?; ``` You return the `challenge_response` (which contains the `PublicKeyCredentialRequestOptions`) to the client so it can invoke `navigator.credentials.get()`. You must also temporarily store the `auth_state` (the internal session state) associated with this login attempt (e.g. in a session or Redis). ### Finishing Authentication Once the client returns the signed assertion, pass it to the engine's `authenticate` method using the `AuthInput::WebAuthnAuthentication` variant. The engine expects the `auth_state_json` (the serialized internal session state created during `start_authentication`) to be provided. ```rust use authkestra_engine::auth::{AuthInput, AuthResult}; let result = engine.authenticate(AuthInput::WebAuthnAuthentication { // The internal user ID you are trying to authenticate user_id: "user_123".to_string(), // `id` from the PublicKeyCredential response credential_id: "base64_url_credential_id".to_string(), // `response.clientDataJSON` from the PublicKeyCredential response, base64url-encoded client_data_json: "base64_url_client_data".to_string(), // `response.authenticatorData` from the PublicKeyCredential response, base64url-encoded authenticator_data: "base64_url_auth_data".to_string(), // `response.signature` from the PublicKeyCredential response, base64url-encoded signature: "base64_url_signature".to_string(), // `response.userHandle` from the PublicKeyCredential response, base64url-encoded (optional) user_handle: None, // Or Some("user_handle") if returned // The state string stored during `start_webauthn`, retrieved from your session/cache store auth_state_json: Some(auth_state_json_string), }).await?; match result { AuthResult::Success(identity) => { println!("Successfully authenticated as: {}", identity.external_id); } AuthResult::MfaRequired { mfa_token, allowed_methods, .. } => { // Handle step-up authentication if they need a second factor! } } ``` Authkestra will perform rigorous cryptographic validation of the assertion against the stored public key, update the signature counter in the credential store to detect cloned authenticators, and return the authenticated `Identity` if successful! --- # TOTP (Authenticator Apps) > Learn how to implement Time-based One-Time Passwords (TOTP) in Authkestra. URL: https://docs.authkestra.com/providers/totp/ Time-based One-Time Passwords (TOTP) allow users to authenticate using 6-digit codes generated by apps like Google Authenticator, Authy, or 1Password. ## Enabling TOTP To use TOTP, enable the `totp` feature in your `Cargo.toml`: ```toml [dependencies] authkestra = { version = "0.13", features = ["totp"] } ``` ## Configuration In Authkestra, TOTP is implemented via the `TotpAuthMethod` struct. You initialize it by passing a `CredentialStore` where the secrets will be securely saved. Because a 6-digit TOTP code alone is generally insufficient as a primary credential, it is strongly recommended to register TOTP as an **MFA-only** method. You do this by registering it with the `EngineBuilder` using `.with_mfa_method()`. ```rust use authkestra::Authkestra; use authkestra_engine::auth::totp::TotpAuthMethod; // `my_store` implements the `CredentialStore` trait (e.g. `SqlxCredentialStore`) let engine = Authkestra::builder() .with_mfa_method(TotpAuthMethod::new(my_store)) // Step-up only! .build(); ``` :::note `EngineBuilder::with_totp(store)` also exists, but it registers TOTP as a *primary* `AuthMethod`. Use `.with_mfa_method(TotpAuthMethod::new(store))` as shown above when you want the step-up-only behaviour described here. ::: By registering it this way, the Engine will automatically block any attempt to use TOTP directly for primary authentication (enforcing that it can only be submitted in an `MfaChallenge` after a primary method like a password is authenticated). ## Registering a Device (Re-Proof Gated) To enroll a user, keep a reference to your `TotpAuthMethod` and call `register_totp`. Like Passkeys and Recovery Codes, TOTP enrolment is gated by [`ReproofRequirement`](/providers/passwordless/#re-proof-gating-reproofrequirement) so a stale session cannot silently enrol a second factor, and the account ID is taken directly from `identity.external_id`: ```rust use authkestra_engine::auth::{AuthMethod, ReproofRequirement}; let gate = ReproofRequirement::new(300) .require_step_up(totp_method.has_enrolled(&session.identity.external_id).await?); let (secret_b32, uri) = totp_method.register_totp( &session.identity, // Authenticated identity (provides external_id + auth_time) &gate, // Freshness + step-up gate "Authkestra", // Issuer (shows in authenticator app) "alice@example.com" // Account name ).await?; ``` You can convert the returned `uri` into a QR code using a frontend library (like `qrcode.js`) for the user to scan. ## Authenticating Because TOTP is registered as an MFA-only method, you cannot simply pass `AuthInput::Totp` to `engine.authenticate()`. If you try, you will receive an `AuthError::Internal` explaining that the method is not registered for primary authentication. Instead, TOTP must be provided in response to an `AuthResult::MfaRequired` returned by a primary authentication method (such as a password). ### Using TOTP as a Second Factor (MFA Continuation) If the user logged in with a password and they have TOTP enrolled, the engine will return `AuthResult::MfaRequired` containing an `mfa_token`. You complete the login by sending an `MfaChallenge`: ```rust let result = engine.authenticate(AuthInput::MfaChallenge { mfa_token: previous_mfa_token, challenge_input: Box::new(AuthInput::Totp { user_id: "user_123".to_string(), code: "123456".to_string(), }), }).await?; ``` ### Built-in Replay Protection Authkestra strictly adheres to [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238). It automatically tracks the highest `last_used_step` in the credential store, preventing the exact same code from being submitted more than once within the validity window. This completely mitigates replay attacks! --- # Magic Link > Learn how to implement magic-link authentication in Authkestra, delivered however you choose. URL: https://docs.authkestra.com/providers/magic-link/ A magic link authenticates someone by proving they control an inbox (or phone number): the application sends them a single-use URL, and following it produces an identity. Authkestra's part of that is deliberately small — it mints a single-use, TTL-bounded, subject-bound secret and consumes it exactly once. It does not own the address book, the mail transport, the template, or the question of whether an address corresponds to an account; delivering the link is always the application's job. ## Enabling Magic Link To use magic links, enable the `magic-link` feature in your `Cargo.toml`: ```toml [dependencies] authkestra-engine = { version = "0.13", features = ["magic-link"] } ``` ## Configuration Magic link is implemented via the `MagicLinkAuthMethod` struct. You initialize it by passing a store that implements both `KvStore` and `AtomicConsume` (e.g. the built-in `RedisStore`, or `MemoryStore` for tests) — atomic fetch-and-remove is what makes a link single-use even against concurrent requests. There is no `.with_magic_link()` convenience wrapper the way `.with_totp()` or `.with_webauthn()` exist; register it directly with `.with_auth_method()`: ```rust use authkestra::Authkestra; use authkestra_engine::auth::magic_link::MagicLinkAuthMethod; // `my_store` implements `KvStore + AtomicConsume` let engine = Authkestra::builder() .with_auth_method(MagicLinkAuthMethod::new(my_store)) .build(); ``` There is no enrolment step: `has_enrolled()` always returns `false` and `is_mfa_equivalent()` always returns `false`. Both are deliberate statements rather than inherited defaults — there is no enrolment ceremony (any subject the application can resolve can be sent a link), and proving you clicked a link is not phishing-resistant, so it earns no second-factor credit for an account whose recovery address is the same inbox. ## Minting a Link Your application resolves whatever the user typed (an email address, say) into a `subject` string, then calls `mint` to get a secret to embed in a URL. Authkestra never sends anything — rendering and delivering the mail (or SMS) is entirely up to you. ```rust use std::time::Duration; use authkestra_engine::auth::magic_link::MagicLinkBinding; let token = magic_link_method .mint("alice", Duration::from_secs(300), MagicLinkBinding::AnyContext) .await?; // token.secret -> embed in the link, e.g. https://example.com/login/confirm?token=... // token.expires_at -> Unix seconds, useful for rendering in the email ``` `ttl` has no default — you must name it. `token.secret` is returned exactly once and is never recoverable afterward; if sending the mail fails, mint a new one rather than trying to retrieve the old secret. :::caution Minting twice for the same subject leaves **both** secrets live until they expire. That's intentional (the first mail may simply be slow) but means you cannot assume a fresh mint invalidates an earlier one still in someone's inbox. ::: ### Choosing a `MagicLinkBinding` `MagicLinkBinding` has no default, and picking one is not optional — you must choose: - **`MagicLinkBinding::AnyContext`** — the link works from anywhere. Cross-device flows work ("request on my laptop, open on my phone"), but a forwarded link, a shared inbox, or a screenshot in a support ticket authenticates whoever holds it. - **`MagicLinkBinding::SameContext(nonce)`** — the link is only valid when redeemed alongside the same `nonce` it was minted with. Set an unguessable nonce as a cookie when the link is requested, and pass that same value back at redemption time. This defends against a forwarded link, at the cost of breaking cross-device use. :::note The nonce must be unguessable — a CSPRNG value, not a username or email address. Binding to something an attacker can predict reproduces `AnyContext` while looking like it doesn't. ::: ## Authenticating Authentication is not tied to the `GET` that loads the landing page — it must happen on an explicit, application-driven call (typically the `POST` behind a "Sign in" button), because corporate mail scanners and link-preview generators routinely `GET` every URL in an email the moment it arrives. If following the link consumed the token, the real user would find it already burned on their first click. Redeem the secret with `AuthInput::MagicLink`, passing the same binding value the caller presented (or `None` if the link was minted with `AnyContext`): ```rust use authkestra_engine::auth::AuthInput; let result = engine.authenticate(AuthInput::MagicLink { token: secret_from_the_url, binding: nonce_from_the_cookie, // Some(nonce) for SameContext, None for AnyContext }).await?; ``` A link works exactly once: the store entry is atomically fetched and removed, so a second attempt with the same token — even the correct one — fails. Every rejection (unknown, expired, already used, or wrong binding) returns the same `AuthError::InvalidCredentials`, deliberately: distinguishing them in the response would hand an attacker an oracle. The distinction is still visible in `tracing` output for operators. :::caution A binding mismatch **consumes** the secret rather than leaving it available for a retry. A link presented from the wrong context is exactly the attack `SameContext` exists to catch, so it's burned on the spot — the correct binding cannot rescue it afterward. ::: --- # Email/SMS One-Time Codes > Learn how to send and verify email or SMS one-time passcodes (OTP) in Authkestra. URL: https://docs.authkestra.com/providers/otp/ Email/SMS one-time codes let a user sign in with a short numeric code your application delivers however it likes — over email, over SMS, read aloud on a support call. The engine never sends anything and never sees an address: you resolve the subject, ask it to mint a code, deliver the digits yourself, and hand back whatever the user typed. Because a six-digit code is too short to double as a lookup key the way a magic-link secret can, the challenge is stored under the *subject* instead of the code, with a server-enforced attempt budget doing the work a longer secret would otherwise do. ## Enabling One-Time Codes To use OTP, enable the `otp` feature in your `Cargo.toml`. The example below also needs the `memory` feature, for the in-memory store used to hold live challenges: ```toml [dependencies] authkestra-engine = { version = "0.13", features = ["otp", "memory"] } ``` ## Registering the Method OTP has no enrolment ceremony — there is nothing to register in advance. Any subject you've already resolved (an email address, a phone number, whatever your application uses) can be minted a code. There is also no dedicated `.with_otp()` convenience wrapper the way `.with_totp()` / `.with_webauthn()` exist for the older methods, so `OtpAuthMethod` is registered like any other `AuthMethod`, through `.with_auth_method(...)`. ```rust use authkestra::Authkestra; use authkestra_engine::auth::otp::OtpAuthMethod; use authkestra_engine::store::memory::MemoryStore; let otp_store = MemoryStore::new(); // Keep a handle you can call `.mint()` on directly — minting isn't // authentication, so it isn't reached through `Engine::authenticate`. // `MemoryStore` (and `RedisStore`) are cheap to clone: both wrap their // backing state behind an `Arc`, so this clone shares the same live // challenges as the copy registered with the engine below. let otp_method = OtpAuthMethod::new(otp_store.clone()); // `MemoryStore` works for a single process. Swap in `RedisStore` (feature // `redis`) for anything that needs a live challenge to survive a restart or // be shared across more than one instance. let engine = Authkestra::builder() .with_auth_method(OtpAuthMethod::new(otp_store)) .build(); ``` :::note There is no `.with_otp(store)` shorthand on `EngineBuilder`. Build the `OtpAuthMethod` yourself and pass it to `.with_auth_method(...)`, as shown above. ::: You can optionally chain `.with_resend_cooldown(...)` onto the instance you call `.mint()` through, to refuse a repeat mint for the same subject within an interval. The engine's copy only ever calls `.authenticate()`, never `.mint()`, so it doesn't need the cooldown configured on it too: ```rust use std::time::Duration; let otp_method = OtpAuthMethod::new(otp_store.clone()) // Opt-in, and off by default: refuses a second mint for the same // subject within 60 seconds. .with_resend_cooldown(Duration::from_secs(60)); ``` :::caution The resend cooldown is per subject, not a replacement for rate limiting at your HTTP edge. It stops one subject's mailbox or phone from being flooded by repeated resends; an attacker rotating IP addresses still needs edge-level limits to be stopped. ::: ## Minting a Code Once you've resolved who is signing in, call `OtpAuthMethod::mint`: ```rust use authkestra_engine::auth::otp::OtpChannel; use std::time::Duration; let minted = otp_method .mint( "alice@example.com", // subject: whatever you resolved this login attempt to Duration::from_secs(300), // ttl OtpChannel::Email, // channel 3, // max_attempts ) .await?; // Deliver `minted.code` over your own transport. Authkestra has no mailer // and no SMS gateway — it only hands you the digits and their expiry. send_email("alice@example.com", &minted.code); ``` - **`subject`** is the identifier you already resolved outside the engine — the same string you'll pass back in on redemption. The challenge is keyed by it. - **`ttl`** bounds how long the code stays valid; `minted.expires_at` is the resulting Unix timestamp. - **`channel`** is `OtpChannel::Email` or `OtpChannel::Sms`. It changes nothing about verification — it's recorded purely so the delivery method can be reported later (for `amr`/audit purposes), and this module never inspects it otherwise. - **`max_attempts`** is the lockout threshold: the number of wrong guesses this one challenge tolerates before it is discarded outright, even if the right code is typed afterwards. It's a required `u32` — `mint` refuses `0` with `AuthError::InvalidInput` rather than silently minting a code nobody could ever redeem. :::note Minting again for the same subject replaces any still-live challenge. A "resend" simply invalidates the old code and restores a fresh attempt budget — it does not stack a second, independent challenge alongside the first. ::: ## Authenticating Redeem the code the user typed with `AuthInput::Otp`: ```rust use authkestra_engine::auth::{AuthInput, AuthResult}; let result = engine.authenticate(AuthInput::Otp { subject: "alice@example.com".to_string(), code: "482913".to_string(), }).await?; match result { AuthResult::Success(identity) => { println!("Signed in as: {}", identity.external_id); } AuthResult::MfaRequired { mfa_token, allowed_methods, .. } => { // Handle step-up authentication if this subject also has a // second factor enrolled. } } ``` A code is single-use: redemption wins the challenge atomically from the store, so a correct code submitted concurrently from two requests still only ever produces one successful `Identity`. Every wrong guess spends one attempt from the budget you passed to `mint`, tracked server-side alongside the challenge; once the budget reaches zero the challenge is discarded even for the correct code. :::caution An unknown subject, a wrong code, an expired code, and an exhausted attempt budget are all indistinguishable to the caller — every one of them returns `AuthError::InvalidCredentials`. Don't try to branch on *why* an attempt failed from that return value; if you need to know, look at your `tracing` output instead. ::: --- # Recovery Codes > Learn how to implement one-time-use recovery codes, a look-up secret authenticator, in Authkestra. URL: https://docs.authkestra.com/providers/recovery-codes/ Recovery codes are a set of one-time-use codes an account can fall back on when its usual factor is unreachable — the phone with the authenticator app is in a river, the passkey's device is lost, the recovery mailbox is gone. Authkestra implements them as a **look-up secret authenticator** per [NIST SP 800-63B §5.1.2](https://pages.nist.gov/800-63-3/sp800-63b.html#sec5), and that framing is deliberate: this is a fallback for *whatever* the account has registered — a passkey, TOTP, magic link, anything — not an appendix to one factor. An account whose only factor is a passkey can enrol recovery codes with no TOTP in sight. Never call these "TOTP backup codes"; they are their own `AuthMethod`, keyed by subject and independent of what else the account has enrolled. ## Enabling Recovery Codes To use recovery codes, enable the `recovery-codes` feature in your `Cargo.toml`: ```toml [dependencies] authkestra-engine = { version = "0.13", features = ["recovery-codes"] } ``` ## Configuration Recovery codes are implemented via the `RecoveryCodeAuthMethod` struct. You initialize it by passing a `CredentialStore` where the code hashes will be saved — the store never sees a plaintext code. ```rust use authkestra::Authkestra; use authkestra_engine::auth::recovery::RecoveryCodeAuthMethod; // `my_store` implements the `CredentialStore` trait (e.g. `SqlxCredentialStore`) let engine = Authkestra::builder() .with_auth_method(RecoveryCodeAuthMethod::new(my_store.clone())) .build(); ``` There is no `.with_recovery_codes()` convenience wrapper the way `.with_totp()` and `.with_webauthn()` exist for the two older methods — register it through `.with_auth_method(...)` as shown above. ## Generating a Set (Enrolment) Recovery codes are the one method on this track that actually **enrols**, so generating a set is gated behind a `ReproofRequirement` — the same re-proof gate that guards enrolling any other sensitive credential. Keep a reference to your `RecoveryCodeAuthMethod` and call `generate`, passing the already-authenticated `Identity`, the gate, and how many codes to issue: ```rust use authkestra_engine::auth::{AuthMethod, ReproofRequirement}; use authkestra_engine::auth::recovery::RecoveryCodeAuthMethod; let recovery = RecoveryCodeAuthMethod::new(my_store); // `identity` is the `Identity` from the user's current, freshly-proved session. // Drive `require_step_up` from whether the account already has a working // fallback: one that does is made to use it, one that does not (this is // plausibly its very first enrolment) is gated on freshness alone. let has_fallback = recovery.has_enrolled(&identity.external_id).await?; let gate = ReproofRequirement::new(300).require_step_up(has_fallback); let codes: Vec = recovery.generate(&identity, &gate, 10).await?; // e.g. ["4KDW-9JMQ-2XPT-R7HV", ...] ``` Calling `generate` again replaces the previous set entirely — there is no partial top-up. :::caution `codes` is the **only time** these values are ever readable. The store only ever holds a SHA-256 hash of each code, never the code itself, and there is no "show them to me again" endpoint. Render them to the user immediately (a printable page, a download) and discard them from memory afterward. Note the argument order on `generate`: `identity`, then `gate`, then `count: u8`. The gate is checked, and nothing is written, before any code is generated — a rejected gate leaves the account's existing set (if any) untouched. ::: ## Checking How Many Remain `remaining` reports how many unredeemed codes an account still has, so an application can prompt a user to regenerate before they run out: ```rust let left: usize = recovery.remaining(&identity).await?; ``` :::note `has_enrolled` (from the `AuthMethod` trait, hence the `AuthMethod` import above) means "has at least one **unredeemed** code", not "has ever generated a set". An account that has burned through every code reports `false` again — which is exactly what makes it safe to drive `require_step_up` from, as shown above: it can only report a usable fallback exists when one actually does. ::: ## Authenticating (Redeeming a Code) A code is redeemed with `AuthInput::RecoveryCode`, naming the account and the code as typed: ```rust use authkestra_engine::auth::{AuthInput, AuthResult}; let result = engine.authenticate(AuthInput::RecoveryCode { user_id: "user_123".to_string(), code: "4KDW-9JMQ-2XPT-R7HV".to_string(), }).await?; match result { AuthResult::Success(identity) => { println!("Successfully authenticated as: {}", identity.external_id); } AuthResult::MfaRequired { mfa_token, allowed_methods, .. } => { // A recovery code satisfies an MFA challenge as a step-up factor. } } ``` Redemption is single-use and safe under concurrency: if two requests race to redeem the same code, exactly one succeeds and the other gets `AuthError::InvalidCredentials`, the same error returned for an unknown or already-used code — nothing about the failure distinguishes the two cases. Dashes and letter case are also normalized before comparison, so `4kdw9jmq2xptr7hv` and `4KDW-9JMQ-2XPT-R7HV` are the same code as far as redemption is concerned. --- # Bot Protection (CAPTCHA) > Learn how to protect your authentication endpoints using CAPTCHA verification in Authkestra. URL: https://docs.authkestra.com/providers/bot-protection/ Authkestra provides built-in bot protection verifiers that validate CAPTCHA tokens. This prevents automated credential stuffing and brute force attacks on your authentication endpoints. ## Enabling Bot Protection Enable the `captcha` feature in your `Cargo.toml`: ```toml [dependencies] authkestra = { version = "0.13", features = ["captcha"] } ``` ## Configuration Authkestra supports Cloudflare Turnstile, hCaptcha, and Google reCAPTCHA out of the box via the `CaptchaVerifier` utility. You instantiate the verifier with your chosen provider and secret key. ```rust use authkestra_engine::captcha::{CaptchaVerifier, CaptchaProvider}; // Example using Cloudflare Turnstile let turnstile = CaptchaVerifier::new(CaptchaProvider::Turnstile, "your_secret_key"); ``` ## How It Works Because bot protection occurs *before* standard authentication logic (like password hashing), you should manually invoke the verifier in your HTTP handlers prior to passing the credentials to Authkestra. ```rust // Extract the captcha token from the user's login request let token = req.captcha_token; let remote_ip = req.remote_ip; // Optional, e.g. from X-Forwarded-For // Validate the token against the provider API match turnstile.verify(&token, remote_ip.as_deref()).await { Ok(true) => { // Success! Proceed with standard authentication // match engine.authenticate(...).await? { // AuthResult::Success(identity) => ... // AuthResult::MfaRequired { .. } => ... // } } Ok(false) | Err(_) => { // Bot detected or token invalid, reject the login request immediately! // return HTTP 403 Forbidden } } ``` ### Fail-Closed Design Authkestra's bot protection is designed to be strictly **fail-closed**. This means if the CAPTCHA token is invalid, missing, or if the network request to the verification provider fails, the `verify` method returns an `Err`. It will never silently succeed and allow an attacker to bypass the protection! --- # Device Signatures > How to implement device-bound signature authentication in Authkestra. URL: https://docs.authkestra.com/providers/device-signatures/ Authkestra supports device-bound signature authentication (proof-of-possession) through the `authkestra-devsig` crate. This allows you to cryptographically bind sessions or API requests to a specific hardware device, significantly reducing the risk of token theft. ## What are Device Signatures? Unlike traditional bearer tokens (like a standard JWT or session cookie) which can be stolen and replayed by an attacker, device signatures require the client to prove possession of a private key for every request. The server registers the public key (the "Device Identity") during enrollment. On subsequent requests, the client signs specific request parameters (like the URI, HTTP method, and a nonce) using their private key. The server verifies this signature using the enrolled public key. ## Prerequisites Add the `authkestra-devsig` crate to your dependencies: ```toml [dependencies] authkestra = { version = "0.13", features = ["axum", "devsig"] } # Or with Actix: # authkestra = { version = "0.13", features = ["actix", "devsig"] } ``` ## How to use `authkestra-devsig` The easiest way to use `authkestra-devsig` is via the official web framework integrations (`authkestra-axum` and `authkestra-actix`). These integrations provide ready-to-use middleware that intercepts requests, buffers the body to compute the required `bdh` (body digest hash), and verifies the signatures before your route handlers are ever executed. ### 1. Building the Verifier State To start, construct the `DevSig` configuration state using the unified typestate builder. You will need a `VerifierConfig`, an `IssuerJwks` (a JWKS cache of the issuers trusted to mint attestations), and a `ReplayStore` (to protect against replay attacks). ```rust use authkestra_devsig::{DevSig, InMemoryReplayStore, IssuerJwks, ReplayStore, VerifierConfig}; use jsonwebtoken::Algorithm; use std::sync::Arc; use std::time::Duration; // `VerifierConfig` is `#[non_exhaustive]`, so build it with `new(...)` rather than a // struct literal. Arguments, in order: // trusted_issuers, allowed_algs, max_clock_skew, max_signature_lifetime, audience let config = VerifierConfig::new( ["https://op.example.test"], [Algorithm::ES256, Algorithm::EdDSA], Duration::from_secs(5), Duration::from_secs(60), "https://api.example.test", ); // The JWKS cache does no network I/O of its own — populate it with // `jwks.insert(issuer, kid, jwk).await` from wherever you fetch and refresh keys. let jwks = Arc::new(IssuerJwks::new()); let replay_store: Arc = Arc::new(InMemoryReplayStore::new()); let devsig = DevSig::builder() .config(config) .jwks(jwks) .replay_store(replay_store) .build(); ``` :::caution `allowed_algs` is asymmetric-only. `VerifierConfig::new` silently drops any HMAC algorithm you pass, and `verify()` rejects the whole HMAC family regardless — the device's private key never leaves the device, so there is no symmetric secret a verifier could legitimately share. ::: ### 2. Wiring the Middleware #### Axum In Axum, `authkestra-axum` provides the `DeviceSignatureLayer` middleware and an `AuthDeviceSignature` extractor. ```rust use authkestra_axum::devsig::{DeviceSignatureLayer, AuthDeviceSignature}; use axum::{Router, routing::post, response::IntoResponse}; // Protect the route using the layer let app: Router<()> = Router::new() .route("/v1/transfer", post(transfer_handler)) .layer(DeviceSignatureLayer::from(devsig)); // The extractor reads the verified identity populated by the layer async fn transfer_handler(AuthDeviceSignature(identity): AuthDeviceSignature) -> impl IntoResponse { format!("Verified request for subject: {}, device: {}", identity.subject, identity.device) } ``` #### Actix In Actix, `authkestra-actix` provides the equivalent `DeviceSignatureAuth` middleware and `AuthDeviceSignature` extractor. ```rust use authkestra_actix::devsig::{DeviceSignatureAuth, AuthDeviceSignature}; use actix_web::{App, HttpServer, post, Responder}; // The extractor reads the verified identity populated by the middleware #[post("/v1/transfer")] async fn transfer_handler(identity: AuthDeviceSignature) -> impl Responder { let AuthDeviceSignature(identity) = identity; format!("Verified request for subject: {}, device: {}", identity.subject, identity.device) } // Protect the route using the wrap() middleware HttpServer::new(move || { let auth = DeviceSignatureAuth::from(devsig.clone()); App::new().wrap(auth).service(transfer_handler) }) ``` By verifying the signature, you ensure that the request was genuinely initiated by the enrolled device, providing high-assurance authentication suitable for sensitive operations. ## Testing your principal mapping The verified identity is an `authkestra_devsig::DeviceIdentity`, and it is `#[non_exhaustive]` — you cannot build one with a struct expression from your own crate. Use `DeviceIdentity::new(subject, device, key_thumbprint, attributes)` to unit-test the code that maps an identity onto your own principal or session type, which is where the security-relevant defaults live (for example, an absent `role` attribute must fall back to your least-privileged value): ```rust use authkestra_devsig::DeviceIdentity; let identity = DeviceIdentity::new( "usr_1".to_owned(), "vk_1".to_owned(), "jkt-1".to_owned(), serde_json::json!({}), ); assert_eq!(my_mapping(&identity).role, "user"); ``` `DeviceIdentity::new` performs no verification at all: a value built this way is test data and carries no proof that any signature was checked. Only `verify()` (and therefore the layer/middleware above) produces a `DeviceIdentity` that means a request passed the algorithm. Complete, runnable versions of both wirings live in the repository: ```bash cargo run -p authkestra --example axum_devsig --all-features cargo run -p authkestra --example actix_devsig --all-features ``` --- # Resource Server > Protecting your APIs with Authkestra using OIDC discovery. URL: https://docs.authkestra.com/advanced/resource-server/ Authkestra isn't just for logging users in; you can also use it to build an **OAuth2 Resource Server** to protect your internal APIs using tokens (like JWTs) issued by an external provider (like Google or Auth0). ## What is a Resource Server? According to [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749#section-1.1), a Resource Server is the server hosting protected resources, capable of accepting and responding to protected resource requests using access tokens. To securely validate an external JWT, the Resource Server verifies the cryptographic signature using the external provider's public keys (JWKS, defined in [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517)). ## Prerequisites Like the OP Server, the Resource Server is an advanced component and is not included by default. You must explicitly include the `authkestra-resource` crate and enable the `resource` feature on your chosen web adapter. ```toml [dependencies] authkestra = { version = "0.13", features = ["axum", "resource"] } # Or if using Actix: # authkestra = { version = "0.13", features = ["actix", "resource"] } ``` ### TLS Backend Selection Outbound HTTP requests for JWKS discovery require a TLS backend. Authkestra enables `rustls-aws-lc-rs` by default. If your project uses a different TLS implementation (such as `ring`), you can select `rustls-no-provider` and install your own `rustls::CryptoProvider`: ```toml [dependencies] authkestra-resource = { version = "0.13", default-features = false, features = ["rustls-no-provider"] } ``` ## Building with Authkestra Guard Authkestra provides the `authkestra-resource` crate, which features a `Guard` and a `JwtStrategy` that automatically caches and refreshes external JWKS. ### 1. Configure the Strategy First, set up `ValidationConfig` and point it to the issuer's JWKS URL. The strategy automatically fetches and caches public keys in the background. ```rust use authkestra_resource::{jwt::JwtStrategy, jwt::ValidationConfig, Guard}; let issuer = "https://accounts.google.com".to_string(); let validation_config = ValidationConfig::builder() .jwks_url(format!("{}/.well-known/jwks.json", issuer)) .issuer(issuer) .build(); // UserIdentity is your custom struct that implements Deserialize let jwt_strategy = JwtStrategy::::new(validation_config); ``` ### Advanced Cryptography & JWT Validation The `ValidationConfig::builder()` supports advanced validation rules and cryptographic key types: - **Supported Signing Algorithms**: Supports `RS256`, `RS384`, `RS512`, `ES256`, `ES384`, and `EdDSA` (`Ed25519` OKP keys). - **Multi-Audience Support**: To accept tokens minted for any of multiple distinct client IDs, chain `.audiences(["client_a", "client_b"])`. - **Strict Key IDs (`kid`)**: To strictly reject malformed tokens that lack a `kid` header instead of falling back to the first JWKS key, chain `.require_kid(true)`. - **Clock-skew tolerance**: `exp` and `nbf` are checked with a 60-second allowance, so a token is accepted for about a minute past its expiry. That absorbs disagreement between the issuer's clock and yours. Chain `.leeway(0)` where both share a clock, or in tests that assert a token is rejected once it expires — with the default, such a test passes when you expect it to fail. The value is `authkestra_engine::token::DEFAULT_LEEWAY_SECS`, the same constant `TokenManager` uses. ```rust let advanced_config = ValidationConfig::builder() .jwks_url("https://example.com/jwks") .audiences(["admin_portal", "mobile_app"]) .require_kid(true) // Reject tokens without a 'kid' header .build(); ``` :::caution[`build()` panics on misconfiguration, deliberately] `ValidationConfig::builder().build()` panics if neither `jwks_url` nor any `trusted_issuer` was set (there would be no key material at all), and if `dpop_resource_origin` is not a bare `scheme://host` — no path, no query, no trailing slash. Both are startup-time failures by design: the alternative is a resource server that boots fine and then rejects every request for reasons indistinguishable from an attack. ::: ### Accepting several issuers One verifier can accept tokens from several issuers, each with its own JWKS endpoint. The token's `iss` claim selects which JWKS verifies it: ```rust let config = ValidationConfig::builder() .trusted_issuer("https://tenant-a.example", "https://tenant-a.example/.well-known/jwks.json") .trusted_issuer("https://tenant-b.example", "https://tenant-b.example/.well-known/jwks.json") .audience("my-app") .build(); ``` Once any trust-map entry is present the verifier is in multi-issuer mode: an `iss` outside the map is rejected outright and a token with no `iss` at all is rejected too. There is deliberately **no** fallback endpoint — a default JWKS for unknown issuers is issuer confusion. For issuers only known at runtime, implement `JwksResolver` and use `JwtStrategy::with_resolver(config, resolver)`; the same rule applies, your implementation must reject issuers it does not recognise. ### Sender-constrained tokens (DPoP and mTLS) Two optional checks bind an access token to the client that was issued it, so a stolen token is not usable on its own. Both are **off by default**, and both are no-ops for a token that carries no binding claim at all: - `.require_cert_binding(true)` — RFC 8705 mutual-TLS binding: a token with `cnf.x5t#S256` is only accepted if the request's client certificate matches. - `.require_dpop(true)` — RFC 9449 DPoP: a token with `cnf.jkt` is only accepted if the request carries a `DPoP` proof header for the same key, bound (`ath`) to this token and not seen before. Optionally add `.dpop_resource_origin("https://api.example.com")` to also check the proof's `htu` against this server's own origin. ```rust use std::sync::Arc; let config = ValidationConfig::builder() .jwks_url("https://idp.example/jwks") .require_dpop(true) .dpop_resource_origin("https://api.example.com") // scheme + host only .build(); let strategy = JwtStrategy::::new(config) // Required: the default `NoDpopReplayStore` refuses every DPoP-bound token. .with_dpop_replay_store(Arc::new(my_replay_store)); ``` :::caution[`require_dpop` without a replay store rejects everything] `JwtStrategy` starts with the fail-closed `NoDpopReplayStore`, which refuses every proof. That is intentional — DPoP without single-use `jti` tracking gives no replay protection while looking like it does — but it means `require_dpop(true)` on its own turns every DPoP-bound token into a `401`. Always pair it with `.with_dpop_replay_store(...)`. ::: ### JWKS behind a private CA `JwksCache` fetches with a `reqwest::Client` that trusts only the platform certificate store, which makes a JWKS published under a private CA unreachable — the common shape being a resource server validating tokens against an issuer inside its own cluster. Supply your own client instead of replacing the whole process's trust store via `SSL_CERT_FILE`: ```rust use authkestra_resource::jwt::JwksCache; use std::time::Duration; let cache = JwksCache::new("https://idp.internal/jwks".to_string(), Duration::from_secs(300)) .with_client(my_client_with_private_ca) // reqwest::ClientBuilder::add_root_certificate(...) .require_kid(true); ``` ### 2. Flexible Strategy Chaining The `Guard` builder allows you to chain multiple strategies together. Under the default policy — `AuthPolicy::FirstSuccess` — a request passes if *any* of the chained strategies successfully validates it (e.g., accepting an OIDC JWT *or* an API key). `AuthPolicy::AllSuccess` and `AuthPolicy::FailFast` are the alternatives; pass one to `.policy(...)` to override. `ApiKeyStrategy` below is illustrative, not something Authkestra ships. Any type implementing `authkestra_engine::strategy::AuthenticationStrategy` slots into the chain — the trait is a single `async fn authenticate(&self, parts: &Parts) -> Result, AuthError>`, where `Ok(None)` means "no credentials of my kind here, try the next strategy" and `Err` fails the whole chain. #### Axum ```rust use authkestra_axum::AxumState; use std::sync::Arc; // We chain the JWT strategy alongside an API Key strategy! let guard = Guard::builder() .strategy(jwt_strategy) .strategy(ApiKeyStrategy::new(...)) .build(); #[derive(Clone, AxumState)] struct AppState { #[authkestra(store)] guard: Arc>, } ``` ### 3. Protect Endpoints Now use the `Auth` extractor in your handlers. If the incoming request satisfies any strategy in your Guard, it is deserialized into your `UserIdentity` struct: ```rust use authkestra_axum::Auth; use axum::response::{IntoResponse, Json}; async fn protected(Auth(user): Auth) -> impl IntoResponse { Json(serde_json::json!({ "message": "Access granted via Resource Server Strategy!", "user": user, })) } ``` #### Actix ```rust use authkestra_actix::ActixState; use std::sync::Arc; // We chain the JWT strategy alongside an API Key strategy! let guard = Guard::builder() .strategy(jwt_strategy) .strategy(ApiKeyStrategy::new(...)) .build(); #[derive(Clone, ActixState)] struct AppState { #[authkestra(store)] guard: Arc>, } ``` ### 3. Protect Endpoints Now use the `Auth` extractor in your handlers: ```rust use authkestra_actix::Auth; use actix_web::{post, Responder, web::Json}; #[post("/protected")] async fn protected(user: Auth) -> impl Responder { let Auth(user) = user; Json(serde_json::json!({ "message": "Access granted via Resource Server Strategy!", "user": user, })) } ``` --- # OpenID Provider (OP) Server > Building your own identity provider with Authkestra. URL: https://docs.authkestra.com/advanced/op-server/ Beyond consuming identities, Authkestra allows you to *become* the identity provider using the `authkestra-op` crate. This allows you to build your own service similar to **Keycloak** or **Auth0**. ## What is an OpenID Provider? An OpenID Provider (OP) is an OAuth 2.0 Authorization Server capable of authenticating End-Users and providing claims to a Relying Party (RP). We strictly implement [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html). ## Prerequisites Because OP Servers are an advanced use case, the OP logic is not included in the default `authkestra` facade crate. You must include the `authkestra-op` crate directly and explicitly enable the `op` feature on your chosen web framework adapter. ```toml [dependencies] authkestra = { version = "0.13", features = ["axum", "op"] } # Or if using Actix: # authkestra = { version = "0.13", features = ["actix", "op"] } ``` ## The OpStore Interface To run an OP Server, you need to persist four specific types of records: Clients, Auth Codes, Refresh Tokens, and Device Codes. In Authkestra, this is handled through the unified `OpStore` supertrait. `OpStore` aggregates the four granular storage traits, each of whose methods take `&mut self` rather than `&self` — a pool-backed implementation typically borrows a single connection for the duration of the call — and adds a handful of **defaulted** methods on top: ```rust pub trait OpStore: ClientStore + AuthorizationCodeStore + RefreshTokenStore + DeviceCodeStore + Send + Sync { // All defaulted — override only what you need. async fn record_client_assertion_jti(&mut self, jti: &str, expires_at: DateTime) -> Result; // RFC 7523 private_key_jwt replay guard async fn check_and_record_dpop_jti(&mut self, jti: &str, expires_at: DateTime) -> Result; // RFC 9449 DPoP proof replay guard async fn handle_custom_grant(/* ... */) -> Result; async fn handle_refresh_token(/* ... */) -> Result; } ``` :::caution[The two replay guards default to *refusing*] `record_client_assertion_jti` and `check_and_record_dpop_jti` are defaulted so that adding replay tracking did not break every existing `OpStore` — but their defaults **reject every assertion and every proof**. That is deliberate: a `private_key_jwt` or DPoP deployment without single-use `jti` tracking would let a captured assertion or proof stay replayable for its whole lifetime, which is strictly worse than not offering the feature. If you enable either mechanism you *must* wire a real store, or every such request will be refused. ::: You can implement `OpStore` directly on a single monolithic database struct (e.g., your main Postgres pool struct). Alternatively, if you want to use different backends for different types of data (e.g., config for clients, Redis for codes), you can use the `CompositeOpStore` helper to delegate to four individual store implementations: ```rust use authkestra_op::store::CompositeOpStore; use std::sync::Arc; let op_store: Arc = Arc::new(CompositeOpStore::new( client_store, // e.g., PostgreSQL for persistent clients auth_code_store, // e.g., Redis for short-lived codes refresh_token_store, device_code_store, )); ``` `Op::builder().store(...)` takes an `Arc`, not a bare `Arc` behind a lock. Because each `OpStore` method needs `&mut self`, a single shared instance can't be called concurrently — `CloneableOpStore` (blanket-implemented for any `OpStore + Clone`) lets every request clone a cheap, independent handle instead of contending on a global `Mutex`. This is why `CompositeOpStore`, `SqlxOpStore`, and `RedisStore` all implement `Clone`: cloning them clones an inner connection pool handle (e.g. `sqlx::Pool` or a Redis client), not the underlying connections themselves. A custom `OpStore` implementation needs `#[derive(Clone)]` (or a manual `Clone` impl) for the same reason. :::caution[`Clone` must share state, not copy it] A handler clones the store once per request and drops the clone when the request ends. `Clone` here has to mean "a new handle onto the same underlying state" — never "an independent copy of the data". A naive `#[derive(Clone)]` over a plain `HashMap` field, for example, compiles and satisfies `CloneableOpStore`, but every write a handler makes disappears with that request's clone: the code `/authorize` stores would never be visible to the following `/token` exchange, which would always fail with `invalid_grant` — no compile error, no log, no failing test. Put mutable in-memory state behind `Arc>`/`Arc>` (as `authkestra_op::MemoryClientAssertionStore` and `authkestra_engine::store::memory::MemoryStore` do), or use a connection-pool handle that is already cheap-clone-shares-state by construction. ::: `CompositeOpStore` carries two further, optional slots for the replay guards above — supply them with `.with_client_assertion_store(...)` and `.with_dpop_replay_store(...)`. Left unset they resolve to `NoClientAssertionStore` / `NoDpopReplayStore`, which are the fail-closed defaults described in the caution box. :::tip[Overriding Refresh Token Logic] The `OpStore` interface allows overriding `handle_refresh_token` for custom refresh token rotation or revocation policies. When the `openid` scope is requested during the refresh flow, Authkestra automatically issues an updated `id_token` alongside the new access token. ::: Check the [axum_op_server.rs](https://github.com/marcjazz/authkestra/blob/main/crates/authkestra/examples/axum_op_server.rs) example in the repository for full database wiring code (`cargo run -p authkestra --example axum_op_server --all-features`); `actix_op_server.rs` is the Actix counterpart. ## Supported Grant Types Authkestra's OP server natively supports the following OAuth 2.0 / OIDC grant types: 1. **Authorization Code** (`GrantType::AuthorizationCode`): The standard interactive OIDC login flow. 2. **Client Credentials** (`GrantType::ClientCredentials`): Server-to-server machine authentication. 3. **Refresh Token** (`GrantType::RefreshToken`): Allows clients to exchange a refresh token for new tokens (with automatic `id_token` issuance when `openid` scope is active). 4. **Device Code** (`GrantType::DeviceCode`): The OAuth 2.0 Device Authorization Grant (RFC 8628). 5. **Token Exchange** (`GrantType::TokenExchange`): The OAuth 2.0 Token Exchange grant (RFC 8693). Gated behind `OpConfig.token_exchange_enabled`, which defaults to `false` so delegation endpoints are never exposed by accident. ## Supported Algorithms & Scopes ### Signing Algorithms Authkestra uses `jsonwebtoken` and `sha2`/`ed25519-dalek` to provide robust support for modern cryptographic signing algorithms: - `RS256`, `RS384`, `RS512` (RSA) - `ES256`, `ES384` (ECDSA) - `EdDSA` / `Ed25519` (Octet Key Pair) When configuring your OP server, **you must choose an asymmetric algorithm** (such as `RS256` or `EdDSA`). Authkestra intentionally rejects symmetric algorithms (`HS256`) for OP servers so Resource Servers can verify tokens via the public `/jwks.json` endpoint. ### Scopes Authkestra is entirely **scope-agnostic**. To be OpenID Connect compliant, include `"openid"`, `"profile"`, and `"email"` in `OpConfig.scopes_supported`. ## Building the OP State The behavior of your OpenID Provider is driven by `OpConfig`: ```rust use authkestra_op::config::OpConfig; use authkestra_op::Op; let config = OpConfig { // No trailing slash — the discovery/JWKS URLs are built by appending to this. issuer: "http://localhost:3000".to_string(), scopes_supported: vec!["openid".to_string(), "profile".to_string(), "email".to_string()], response_types_supported: vec!["code".to_string()], grant_types_supported: vec!["authorization_code".to_string(), "refresh_token".to_string()], id_token_signing_alg: "EdDSA".to_string(), // Supports RS256, ES256, EdDSA, etc. authorization_code_ttl_secs: 60, // RFC-003 §7 recommends ≤ 60 access_token_ttl_secs: 3600, device_code_ttl_secs: 600, token_exchange_enabled: false, // RFC 8693, off by default }; let op = Op::builder() .engine(auth_engine) .config(config) // `.store()` takes `Arc`, not a bare store — wrap it. .store(Arc::new(op_store)) .build(); ``` :::caution `OpConfig` has no `Default` impl, so every field above is required. The four TTL/toggle fields are easy to miss when copying an older snippet — a struct literal that omits them will not compile. ::: ## Custom Grant Types Beyond built-in grant types, Authkestra allows extension grants by adding `GrantType::Custom("urn:my:grant".into())` to client registrations and overriding `handle_custom_grant` on your `OpStore`. ## Wiring the OP Endpoints Wire server routes using `op_axum_router()` (Axum) or `op_actix_scope()` (Actix-web): ```rust use authkestra_axum::op::{OpExt, OpState}; use axum::Router; let app = Router::new() .merge(op.op_axum_router()) .with_state(OpState(op)); ``` ```rust // Actix returns a `Scope`; `configure_op` registers the OP's pieces as app data. use authkestra_actix::op::{OpActixExt, OpExt}; App::new() .configure(|cfg| { cfg.configure_op(op.clone()); }) .service(op.op_actix_scope()) ``` Exposed endpoints: 1. **`GET /.well-known/openid-configuration`**: OIDC Discovery endpoint. 2. **`GET /jwks.json`**: Public key set (supports RSA, ECDSA, and Ed25519 / OKP keys). 3. **`GET /authorize`**: Authorization endpoint. 4. **`POST /token`**: Token endpoint (code, refresh token, client credentials, device code, token exchange). 5. **`GET`/`POST /userinfo`**: UserInfo endpoint. 6. **`POST /device_authorization`**: Device Authorization Grant endpoint (RFC 8628). 7. **`POST /device/verify`**: Verification endpoint where the user submits their `user_code`. The device/service attestation routes (`POST /enrol`, `POST /enrol/complete`, `POST /reissue`) are deliberately **not** part of `op_axum_router()`. On Axum they live in a separate `op_axum_attestation_router()`, so an application that only wants the standard OIDC surface is not forced to supply attestation-specific dependencies just to compile: ```rust let app = Router::new() .merge(op.op_axum_router()) .merge(op.op_axum_attestation_router()) .with_state(state); ``` On Actix, `op_actix_scope()` wires all of them together and resolves each attestation dependency from `app_data`, leaving the optional `AttestationStatusProvider` as `None` when it is absent. ## Device/Service Attestation Issuance Beyond the standard OIDC surface, `authkestra-op` can issue **device/service attestations**: short-lived, `cnf.jkt`-bound JWS tokens that let a mobile device or a backend service prove possession of a key it enrolled. This is the *issuing* side of device-bound signature authentication; the verifying side is [`authkestra-devsig`](/providers/device-signatures/). A step-by-step client walkthrough lives in the [Device Attestation guide](/guides/device-attestation/). ### The three routes | Route | Purpose | | --- | --- | | `POST /enrol` | Validate the caller's public key and second factor, then issue a single-use proof-of-possession challenge. | | `POST /enrol/complete` | Consume the challenge, verify the signature came from the enrolled key, compute `cnf.jkt` from that key (never from caller input), and mint the attestation. | | `POST /reissue` | Silently renew a near-expiry attestation by re-proving possession of the same key. No second factor — continuity of the key stands in for it. | ### `AttestationConfig` `AttestationConfig` is `#[non_exhaustive]`, so build it with `new()` (or `Default`) and adjust the fields you care about — a struct literal will not compile from outside the crate. The defaults are 24h / 12h / 5min: ```rust use authkestra_op::attestation::AttestationConfig; let mut attestation_config = AttestationConfig::new(); attestation_config.attestation_ttl_secs = 86_400; // how long an attestation is valid attestation_config.attestation_reissue_after_secs = 43_200; // returned as `reissue_after` attestation_config.challenge_ttl_secs = 300; // a PoP nonce, not a session ``` ### The two pluggable hooks `authkestra-op` deliberately hardcodes neither a telecom integration nor an attribute/revocation store, so the host application supplies both: - **`SecondFactorVerifier`** (required to enrol) — verifies whatever `SecondFactorProof` (`{ kind, value }`, opaque to the OP) the caller submits at enrolment. An SMS/TOTP check for a device; an out-of-band admin approval or one-time bootstrap secret for a service principal, which has no phone to text. Any `Err` surfaces as `OpError::SecondFactorFailed`, so log your own specifics rather than leaking them into the response. ```rust #[async_trait::async_trait] impl SecondFactorVerifier for MyVerifier { async fn verify( &self, subject: &str, principal_type: PrincipalType, proof: &SecondFactorProof, ) -> Result<(), OpError> { /* ... */ } } ``` - **`AttestationStatusProvider`** (optional) — consulted at re-issuance. Return `Ok(Some(attributes))` to renew with (possibly updated) attributes, or `Ok(None)` to refuse a revoked principal, which fails with `OpError::PrincipalRevoked`. **If you do not configure one, re-issuance copies the previous attestation's attributes forward unchanged** — so an application that revokes devices out-of-band needs this hook, or a revoked device keeps renewing. The challenge store is an `EnrolmentChallengeStore`, which has a blanket impl over any `KvStore` + `AtomicConsume` backend — the same mechanism session and OP data already use, so memory/Redis/SQL all work unchanged. ### Wiring ```rust let op = Op::builder() .engine(engine) .config(op_config) .store(Arc::new(op_store)) .challenge_store(Arc::new(MemoryStore::::new())) .second_factor_verifier(Arc::new(MyVerifier)) .attestation_config(AttestationConfig::new()) // .status_provider(Arc::new(MyStatusProvider)) // optional .build(); // Axum: a router separate from op_axum_router(), so an OIDC-only app never has to // supply attestation dependencies just to compile. let app: axum::Router<()> = op .op_axum_attestation_router() .with_state(authkestra_axum::op::OpState(op)); ``` On Actix there is no split: `op_actix_scope()` wires the standard endpoints and the three attestation routes together, resolving each dependency from `app_data`. Runnable end-to-end versions (server plus a client that enrols and then re-issues, no external services needed): ```bash cargo run -p authkestra --example axum_op_server_attestation --features full cargo run -p authkestra --example actix_op_server_attestation --features full ``` --- # Device Attestation > Step-by-step walkthrough of enrolling a device key and re-issuing its attestation against a running authkestra-op server. URL: https://docs.authkestra.com/guides/device-attestation/ This guide walks through the **device/service attestation** ceremony end to end: generating a keypair, enrolling it against an `authkestra-op` server, receiving a short-lived attestation, and silently renewing it before it expires. It narrates exactly what the two runnable examples in the repository do — [`crates/authkestra/examples/axum_op_server_attestation.rs`](https://github.com/marcjazz/authkestra/tree/main/crates/authkestra/examples/axum_op_server_attestation.rs) and [`crates/authkestra/examples/actix_op_server_attestation.rs`](https://github.com/marcjazz/authkestra/tree/main/crates/authkestra/examples/actix_op_server_attestation.rs) — so you can follow along and then adapt the same steps into your own mobile client or backend service. This is the how-to companion to the [Device/Service Attestation Issuance](/advanced/op-server/#deviceservice-attestation-issuance) reference, which covers the API shape (`AttestationConfig`, `SecondFactorVerifier`, wiring) in more depth. If you're implementing the *verifying* side of a request signed with one of these attestations, see the `authkestra-devsig` section of the adapters chapter instead — this guide only covers issuance. ## Prerequisites You need a running `authkestra-op` instance with the attestation router wired in (`op_axum_attestation_router()` / `op_actix_scope()`), as described in the reference doc. For this guide, we'll run the example server, which needs no external services — its `EnrolmentChallengeStore` is an in-memory `MemoryStore`: ```sh # Axum cargo run -p authkestra --example axum_op_server_attestation --features full # Actix cargo run -p authkestra --example actix_op_server_attestation --features full ``` Both examples start a real server and then immediately play the client side of the ceremony against it — enrolling a device key and re-issuing its attestation — so running either one shows you the whole flow below happening live, with logging. ## Step 1: Generate a device keypair The device (or service) generates an EC P-256 keypair locally — in production this lives in hardware-backed storage (Secure Enclave on iOS, StrongBox/Keystore on Android). The private key never leaves the device; only the public key, as a JWK, is sent to the server: ```rust use p256::ecdsa::SigningKey; use p256::elliptic_curve::{JwkEcKey, PublicKey}; use rand_core::OsRng; let signing_key = SigningKey::random(&mut OsRng); let public_key: PublicKey = signing_key.verifying_key().into(); let public_jwk = serde_json::to_value(&JwkEcKey::from(&public_key))?; ``` ## Step 2: Enrol the key (`POST /enrol`) Submit the public JWK together with an identity to bind it to and a second-factor proof. `authkestra-op` doesn't interpret the second factor itself — it hands it to whatever `SecondFactorVerifier` the server configured (SMS/TOTP for a device, an out-of-band approval or bootstrap secret for a service): ```rust let enrol_body = serde_json::json!({ "subject": "user-42", "principal_id": "device-abc123", "principal_type": "device", "public_jwk": public_jwk, "attributes": { "kyc_level": "verified" }, "second_factor": { "kind": "demo_otp", "value": "123456" }, }); let challenge: serde_json::Value = client .post(format!("{base}/enrol")) .json(&enrol_body) .send().await? .json().await?; let challenge_value = challenge["challenge"].as_str().unwrap(); ``` If the second factor checks out, the server responds with a single-use, short-lived `challenge` string — a proof-of-possession nonce, not a session. Nothing is bound to the key yet; that happens in the next step. ## Step 3: Complete the challenge (`POST /enrol/complete`) Sign the challenge with the same private key you generated in Step 1 (a compact JWS over `{"challenge": ...}`, `ES256`), then submit the challenge and its signature: ```rust use jsonwebtoken::{Algorithm, EncodingKey, Header}; let encoding_key = EncodingKey::from_ec_der(&pkcs8_der); let challenge_signature = jsonwebtoken::encode( &Header::new(Algorithm::ES256), &serde_json::json!({ "challenge": challenge_value }), &encoding_key, )?; let attestation: serde_json::Value = client .post(format!("{base}/enrol/complete")) .json(&serde_json::json!({ "challenge": challenge_value, "challenge_signature": challenge_signature, })) .send().await? .json().await?; ``` The server consumes the challenge (it cannot be replayed), verifies the signature was produced by the *enrolled* key, computes `cnf.jkt` from that key — never from anything the client claims — and mints the attestation. The response carries the attestation itself plus its lifetime: ```json { "attestation": "eyJhbGciOi...", "expires_in": 86400, "reissue_after": 43200 } ``` At this point enrolment is done: `attestation` is a JWS whose `cnf.jkt` claim is bound to the key you generated in Step 1. A verifier that trusts this OP's JWKS can confirm that binding — but the attestation is not itself a bearer credential; using it to authenticate a request also requires a per-request signature over that request, which is what `authkestra-devsig` verifies on the resource-server side. ## Step 4: Re-issue before expiry (`POST /reissue`) Well before `reissue_after` seconds have elapsed, silently renew the attestation by proving possession of the same key again — no second factor required this time, since continuity of the key is what stands in for it: ```rust let reissue_body = serde_json::json!({ "attestation": attestation["attestation"], "public_jwk": public_jwk, }); let reissue_challenge: serde_json::Value = client .post(format!("{base}/reissue")) .json(&reissue_body) .send().await? .json().await?; ``` `/reissue` returns a fresh challenge, exactly like `/enrol` did. Sign it and complete it through `/enrol/complete` again — the same call as Step 3 — and you get back a renewed attestation with a new expiry: ```rust let reissue_challenge_value = reissue_challenge["challenge"].as_str().unwrap(); // sign + POST /enrol/complete again, same as Step 3 ``` If the server has an `AttestationStatusProvider` configured, re-issuance is also where it gets a chance to refuse a revoked principal outright, or refresh the attestation's attributes (e.g. an updated `kyc_level`) — see that trait's docs in the reference page. Without one configured, re-issuance just copies the previous attributes forward. ## Putting it together Both example files run this entire sequence — enrolment followed by one re-issuance — against a real in-process server, printing each step's response as it goes. They're the best place to see the full, working request/response cycle in one place, and a reasonable starting point to copy from when wiring up your own mobile client or backend service: - [`crates/authkestra/examples/axum_op_server_attestation.rs`](https://github.com/marcjazz/authkestra/tree/main/crates/authkestra/examples/axum_op_server_attestation.rs) - [`crates/authkestra/examples/actix_op_server_attestation.rs`](https://github.com/marcjazz/authkestra/tree/main/crates/authkestra/examples/actix_op_server_attestation.rs) --- # Policy Engine (Cedar) > Declarative fine-grained authorization with AWS Cedar policies. URL: https://docs.authkestra.com/advanced/policy-engine/ Authkestra's other crates answer *"who are you?"*. This one answers *"may you do this?"*, using [AWS Cedar](https://www.cedarpolicy.com/) — so authorization rules live in a policy file you can review, diff and reload, instead of in `if role == "admin"` branches scattered across handlers. `authkestra-policy` is a production authorization engine wired across the Authkestra ecosystem: - **Axum & Actix route guards:** Wire declarative policy evaluation into router pipelines with `PolicyGuard`. - **Resource Server & API:** Restrict access to endpoints based on principal attributes, action names, and resource context. - **Model Context Protocol (MCP) tool authorization:** Evaluate fine-grained Cedar policies before executing high-impact AI agent tool calls. - **Structured observability:** Permit and deny decisions emit structured `tracing` spans with policy IDs and decision rationale. ## Enabling it ```toml [dependencies] authkestra = { version = "0.13", features = ["policy"] } # or the crate on its own: authkestra-policy = "0.13" ``` ## The one rule: the engine never touches your database Cedar decides using an *entity store* — the principal, the resource, their groups and attributes. The engine holds no database handle and never fetches any of it. Everything arrives through a `ResourceLoader` you implement: ```rust use async_trait::async_trait; use authkestra_policy::cedar_policy::Entities; use authkestra_policy::{AuthorizationRequest, PolicyError, ResourceLoader}; struct PostgresLoader { /* your pool */ } #[async_trait] impl ResourceLoader for PostgresLoader { async fn load_entities( &self, request: &AuthorizationRequest, ) -> Result { // Fetch just this request's principal, resource, and their group memberships. // Storage faults become PolicyError::loader(e) — never a silent deny. todo!() } } ``` This is the same database-agnosticism rule as `UserStore` and `SessionStore`: Authkestra never assumes your schema. ## Evaluating a request ```rust use authkestra_policy::cedar_policy::RestrictedExpression; use authkestra_policy::{ AuthorizationRequest, EntityRecord, MemoryResourceLoader, PolicyEngine, parse_entity_uid, }; #[tokio::main] async fn main() -> Result<(), Box> { let loader = MemoryResourceLoader::new(); loader.extend([ EntityRecord::new(parse_entity_uid(r#"User::"alice""#)?) .parent(parse_entity_uid(r#"Group::"eng""#)?), EntityRecord::new(parse_entity_uid(r#"Group::"eng""#)?), EntityRecord::new(parse_entity_uid(r#"Doc::"design""#)?).attribute( "owner", RestrictedExpression::new_entity_uid(parse_entity_uid(r#"Group::"eng""#)?), ), ]); let engine = PolicyEngine::builder() .policies(r#" @id("eng-reads-own-docs") permit(principal in Group::"eng", action == Action::"read", resource) when { resource.owner == Group::"eng" }; "#) .loader(loader) .build()?; let request = AuthorizationRequest::builder() .principal_str(r#"User::"alice""#) .action_str(r#"Action::"read""#) .resource_str(r#"Doc::"design""#) .build()?; let decision = engine.is_authorized(&request).await?; assert!(decision.is_allowed()); assert_eq!(decision.reasons(), ["eng-reads-own-docs"]); Ok(()) } ``` Cedar is **deny by default** and **forbid overrides permit**: a request is allowed only if some `permit` matches and no `forbid` does. ## Reading a decision - `decision.is_allowed()` — the answer. - `decision.reasons()` — the ids of the policies that decided it. Empty on a deny means *no rule allowed it* (the default deny); non-empty means a `forbid` matched. Name your rules with `@id("...")` so these stay stable when you reorder a policy file. A policy without one keeps Cedar's positional id (`policy2` for the third policy in the file), and because those are handed out by position, `@id("policy0")` and anything else of the form `policy` is rejected at load time — it would name the wrong rule. `policy-admin-override` and the like are fine. - `decision.errors()` — policies that failed to evaluate (a missing attribute, a type error). Cedar skips those and carries on, so a non-empty list means the decision was made on only part of your rule set. They are logged at `warn` with the policy id. A `PolicyError` is never a deny. If your loader cannot reach its database you get `Err`, so "the rules say no" is always distinguishable from "we could not find out". ## A skipped `forbid` is refused, not allowed Because Cedar skips an erroring policy rather than failing the request, a broken `forbid` would otherwise produce an `Allow` that only happened because the denying rule never ran — and `is_allowed() == true` would give you no way to tell. Authkestra refuses to answer instead: | decision | erroring policies | you get | | --- | --- | --- | | `Allow` | at least one `forbid` | `Err(PolicyError::UnreliableDecision { .. })` | | `Allow` | `permit`s only | `Ok(allow)` — another permit matched; errors listed on the decision | | `Deny` | any | `Ok(deny)` — nothing skipped could have turned a Deny into an Allow | Treat `UnreliableDecision` as a 500, not a 403: the request was not denied, it was unanswerable, and the fix is the broken policy or the missing entity attribute named in the error. ## Updating policies at runtime ```rust // A bad update changes nothing: the previous policies keep serving traffic. assert!(engine.reload("permit(principal").is_err()); // A good one takes effect for the next request. No restart. engine.reload(new_policy_source)?; ``` The swap is atomic and needs only `&self`, so the engine can sit in an `Arc` shared by every handler. Requests already in flight finish against the policies they started with. ## Optional: a schema ```rust let engine = PolicyEngine::builder() .policies(policy_source) .schema(r#" entity User in [Group]; entity Group; entity Doc { owner: Group }; action read appliesTo { principal: [User], resource: [Doc] }; "#) .loader(loader) .build()?; ``` With a schema, a policy referring to an action or attribute the schema does not declare is rejected **when it is loaded** rather than silently never matching, and each request's context is type-checked. Without one, nothing is validated — a typo becomes a rule that never fires. ## Performance On a 24-core i7-13700KF, evaluating 10 policies against a 414-entity store takes p50 23 µs / p99 40 µs end-to-end in a release build (about a third of which is entity hydration). Policy evaluation is not the expensive part of your request — see RFC-008 §7 for the method and the caveats. ## Framework & MCP Integration ### Axum & Actix Route Protection When the `policy` feature is enabled on `authkestra-axum` or `authkestra-actix`, wrap your routes with `PolicyGuard`: ```rust use std::sync::Arc; use authkestra::policy::CedarPolicyEngine; use authkestra::axum::PolicyGuard; use axum::{routing::get, Router}; let policy_engine = Arc::new( CedarPolicyEngine::builder() .policies(policy_source) .loader(loader) .build()?, ); let app = Router::new() .route("/api/documents/:id", get(get_document)) .layer(PolicyGuard::new(policy_engine.clone())); ``` ### Resource Server & MCP Tool Enforcement In `authkestra-resource`, `Guard` can validate incoming Bearer tokens and delegate fine-grained permission checks to `CedarPolicyEngine`. In `authkestra-mcp`, tool invocations enforce Cedar policies before dispatch: - **Principal:** The authenticated client or agent `Identity`. - **Action:** `Action::"CallTool"` or custom tool action names. - **Resource:** Target MCP tool name and canonicalized parameters. Permit and deny decisions automatically emit structured `tracing` logs with `cedar.decision`, `cedar.reasons`, and policy IDs. --- # Continuous Access Evaluation (CAEP/SSF) > Ingesting and validating Security Event Tokens so an external IdP or EDR can tell your service that something changed. URL: https://docs.authkestra.com/advanced/continuous-access-evaluation/ A session that was legitimate when it started does not stay legitimate. The user's password gets reset, their laptop drops out of compliance, an administrator revokes their session — and none of that reaches your service until the next token refresh, which may be an hour away. The **Shared Signals Framework** closes that gap: a cooperating provider pushes you a signed **Security Event Token (SET)** the moment something changes. `authkestra-ssf` is the receiving side of that exchange. ## Standards implemented | Specification | What it defines | Where it lands | | --- | --- | --- | | [RFC 8417](https://www.rfc-editor.org/rfc/rfc8417) | The SET itself: a JWT whose payload is a set of events | `SecurityEventToken`, `SetVerifier` | | [RFC 8935](https://www.rfc-editor.org/rfc/rfc8935) | Push delivery over HTTP, and the 202/400 response semantics | `PushReceiver`, `PushResponse` | | [RFC 9493](https://www.rfc-editor.org/rfc/rfc9493) | Structured subject identifiers (`sub_id`) | `SubjectIdentifier` | | [OpenID CAEP 1.0](https://openid.net/specs/openid-caep-specification-1_0.html) | The event vocabulary: session revoked, credential change, and friends | `CaepEvent` | ## Production Architecture & Capabilities `authkestra-ssf` provides production-ready Security Event Token ingestion, OpenID CAEP 1.0 event processing, and automatic session revocation: - **Ingest and validate SETs:** Explicit `typ` checking, an algorithm allow-list that can never contain `none`, signature verification against a single key or a `kid`-based resolver, issuer and audience checks, `iat` freshness with configurable leeway and optional maximum age, `exp` honoured when a transmitter sends one, non-empty `events`, and `jti` replay detection. - **Typed CAEP events:** All five CAEP 1.0 event types with their specific claims. Event types this crate does not model (RISC, SCIM, vendor extensions) are preserved verbatim rather than dropped, so a handler that understands them still can. - **Framework-agnostic push receiver:** Full RFC 8935 push delivery response semantics, including registered error codes (`invalid_request`, `invalid_key`, `invalid_issuer`, `invalid_audience`, `authentication_failed`, `access_denied`). - **Production HTTP Push Receiver Routes:** Dedicated endpoints mounted at `POST /.well-known/ssf/events` and `POST /ssf/events` in `authkestra-axum` and `authkestra-actix` (via the `ssf` feature). - **Automated Session Revocation:** Built-in `SessionRevocationHandler` implementing `SsfEventHandler` that automatically invalidates active user and agent sessions in your `SessionStore` upon receiving `SessionRevoked` or `CredentialChange` events. ## Setting up a receiver with automatic session revocation ```toml [dependencies] authkestra = { version = "0.13", features = ["ssf", "axum", "memory"] } # or crate directly: authkestra-ssf = "0.13" jsonwebtoken = { version = "11", features = ["rust_crypto"] } ``` ```rust use std::sync::Arc; use std::time::Duration; use authkestra::ssf::{ InMemorySetReplayGuard, PushReceiver, SessionRevocationHandler, SetVerifier, }; use jsonwebtoken::{Algorithm, DecodingKey}; let verifier = SetVerifier::builder("https://idp.example.com/") .audience("https://sp.example.com/caep") .algorithms([Algorithm::EdDSA]) .key(transmitter_key) .max_age(Duration::from_secs(60 * 60)) .replay_guard(Arc::new(InMemorySetReplayGuard::new(Duration::from_secs(60 * 60)))) .build()?; // Built-in SessionRevocationHandler automatically revokes live sessions in SessionStore: let revocation_handler = SessionRevocationHandler::new(session_store.clone()); let receiver = PushReceiver::new(Arc::new(verifier)) .with_handler(Arc::new(revocation_handler)); ``` ## Mounting SSF Push Routes in Axum & Actix In Axum, mount the standard push receiver endpoints (`POST /.well-known/ssf/events` and `POST /ssf/events`): ```rust use authkestra_axum::ssf::ssf_routes; use axum::Router; let app = Router::new() .merge(ssf_routes(Arc::new(receiver))); ``` In Actix-web: ```rust use authkestra_actix::ssf::configure_ssf; use actix_web::{web, App}; App::new() .configure(|cfg| configure_ssf(cfg, Arc::new(receiver))); ``` If you prefer custom endpoint handling, `PushReceiver::receive` is framework-agnostic: ```rust let response = receiver.receive(content_type_header, body_bytes).await; // response.status() -> 202, 400 or 500 // response.content_type() -> "application/json" on failure // response.content_language() -> "en" on failure (RFC 8935 §2.3 requires it) // response.body() -> {"err": ..., "description": ...} on failure ``` ## Reacting to an event ```rust use async_trait::async_trait; use authkestra_ssf::{CaepEvent, HandlerError, SecurityEventToken, SetHandler}; struct MyHandler; #[async_trait] impl SetHandler for MyHandler { async fn handle( &self, set: &SecurityEventToken, event: &CaepEvent, ) -> Result<(), HandlerError> { if let CaepEvent::SessionRevoked(revoked) = event { // `revoked.metadata.subject` is the RFC 9493 subject identifier, when the // transmitter sent one; `set.sub_id` is the SET-level one. let _ = revoked; // ... look the subject up and drop its sessions ... } Ok(()) } } ``` Returning `HandlerError::Rejected` answers the transmitter with `400 access_denied`; returning `HandlerError::Internal` answers `500`, so a conformant transmitter retries. Handlers run *before* the response is produced, because this crate has no store of its own — if you want the asynchronous shape RFC 8935 §2 suggests, enqueue inside the handler and return immediately. ### Your handlers must be idempotent The replay slot for a SET is taken during verification, before any handler runs — that is what makes two *concurrent* deliveries of the same SET dispatch only once. When a handler returns `HandlerError::Internal`, the receiver gives that slot back before answering 500, so the transmitter's retry is actually verified and dispatched instead of being acknowledged as a duplicate and dropped. The consequence is that a retry replays the **whole** delivery: every event in the SET, through every handler, including the ones that already succeeded before the one that failed. Write handlers that can run twice — key your writes on `set.jti`, or make them upserts. `HandlerError::Rejected` keeps the slot, since a `400` tells the transmitter not to retry. One window is left open deliberately: a concurrent duplicate that arrives between the record and the release is answered `202` without dispatch. If the original delivery then fails, that particular duplicate has already been answered — but the transmitter's retry of the failed delivery finds the slot free and delivers the event, so it is not lost. Holding the slot across dispatch instead would close the window at the cost of losing an event on every handler failure. ## Behaviours that surprise people - **A retransmitted SET is answered `202`, not an error.** RFC 8935 §2 requires the recipient to respond to a repeat transmission exactly as it would to a first one, so replay detection suppresses handler dispatch, not the acknowledgement. - **`Content-Type` must be `application/secevent+jwt`.** Anything else is a `400 invalid_request`. - **A malformed event payload is refused without spending the `jti`.** Events are decoded as part of verification, so if a transmitter sends a `credential-change` missing its required claims you get a `400 invalid_request`, the replay slot stays free, and the corrected retransmission under the same `jti` is processed normally instead of being mistaken for a duplicate. - **Error responses do not echo your configuration.** A wrong-issuer or wrong-audience SET gets `invalid_issuer` / `audience mismatch` and nothing more, and a stale or future-dated one does not reveal your `iat_leeway` or `max_age`: the push endpoint is unauthenticated, so anything that describes your policy stays in your logs (as structured tracing fields) rather than going back to whoever POSTed. - **Explicit typing is mandatory here.** RFC 8417 §2.3 only requires `typ: secevent+jwt` when a SET could be confused with another kind of JWT — which is exactly the situation a general receiver is in, so this crate always requires it. - **`exp` is optional; `nbf` is ignored.** A SET describes something that already happened, so RFC 8417 §2.2 discourages `exp` entirely and never profiles `nbf`. Freshness comes from `iat`. - **An empty `jti` is refused.** RFC 8417 §2.2 makes `jti` the SET's unique identifier. An empty or whitespace-only one identifies nothing, and would collapse the replay guard to one slot per issuer — so it is a `400 invalid_request`. ## Body size limits `PushReceiver` refuses any body larger than `DEFAULT_MAX_BODY_BYTES` (1 MiB) with `400 invalid_request`, before it parses anything. Change it with: ```rust let receiver = PushReceiver::new(verifier).with_max_body_bytes(64 * 1024); ``` **Set a limit in your HTTP layer as well.** By the time `receive` is called, the body has already been read into memory by the framework, so this check is a second line of defence — it stops an oversized body reaching the parser and the signature verifier, but it cannot prevent the allocation. What actually bounds what gets buffered is axum's `DefaultBodyLimit`, actix's `PayloadConfig`, or your reverse proxy's `client_max_body_size`. ## Building the models in your own tests The public models are `#[non_exhaustive]`, so they cannot be built with struct-literal syntax from outside the crate — but every one of them has a constructor, precisely so that the code you write to map a CAEP event onto your own domain stays unit-testable without minting and verifying a real SET: ```rust use authkestra_ssf::{CaepMetadata, CredentialChange, CredentialType, ChangeType, SecurityEventToken}; let set = SecurityEventToken::new("https://idp.example.com/", "jti-1", 1_700_000_000, events); let mut change = CredentialChange::new(CredentialType::Password, ChangeType::Update); change.metadata = CaepMetadata::empty(); ``` None of these constructors validate anything: a SET your service should act on comes out of `SetVerifier`, never out of a constructor. --- # Instrumentation & Tracing > Deep observability built into Authkestra. URL: https://docs.authkestra.com/advanced/instrumentation/ Authentication issues in production can be notoriously difficult to debug. To solve this, Authkestra is deeply instrumented with the `tracing` crate. Every major decision, protocol step, and logical branch emits a granular span or event. ## Definition of Done As part of our internal Definition of Done (DoD), every new feature in Authkestra *must* include adequate tracing instrumentation. ## Viewing Logs Simply initialize a `tracing_subscriber` in your application to see the rich logs generated by the engine. ```rust tracing_subscriber::fmt() .with_max_level(tracing::Level::DEBUG) .init(); ``` You'll see exactly when tokens are exchanged, when sessions are created, and precise error reasons if an authentication flow fails. --- # Storage Overview > Understanding Authkestra's database-agnostic design and storage options. URL: https://docs.authkestra.com/storage/overview/ Authkestra is entirely **Database Agnostic**. We never enforce database schemas, migrations, or ORM choices for the core library. ## The Trait-Based Approach All data persistence is defined through simple Rust traits. For example, to manage sessions, the engine relies on the `SessionStore` trait: ```rust #[async_trait] pub trait SessionStore: Send + Sync + 'static { /// Load a session by its ID. async fn load_session(&self, id: &str) -> Result, AuthError>; /// Save or update a session. async fn save_session(&self, session: &Session) -> Result<(), AuthError>; /// Delete a session by its ID. async fn delete_session(&self, id: &str) -> Result<(), AuthError>; } ``` Because this is a trait, you can implement it using `sqlx`, `diesel`, `redis`, or a simple in-memory `HashMap`. In practice you rarely implement it directly: `authkestra-engine` carries a blanket impl of `SessionStore` for any `KvStore`, so implementing the smaller [`KvStore`](/storage/kv-store) trait gets you a session store for free — which is exactly how `MemoryStore` and `RedisStore` both qualify. ## Included Stores Authkestra comes with a few generic implementations out of the box for convenience. To use them, you must enable the corresponding feature flags in your `Cargo.toml`. There are two primary paradigms for built-in storage in Authkestra: 1. **[KV Stores](/storage/kv-store)**: General-purpose Key-Value persistence used by the main `authkestra-engine`. This includes the Memory Store and the Redis Store. 2. **[SQL Stores](/storage/sql-store)**: Highly opinionated, normalized relational SQL tables designed specifically for building an OpenID Provider (OP) via `authkestra-op`. Continue reading to explore how to set up the [KV Store](/storage/kv-store) for typical setups, or the specialized [SQL Store](/storage/sql-store) for building your own OP. Or, if you have unique infrastructure needs, learn how to [Implement Custom Stores](/storage/implementing-stores). --- # KV Stores > General-purpose Key-Value persistence for Authkestra. URL: https://docs.authkestra.com/storage/kv-store/ KV Stores (Key-Value) are the primary way `authkestra-engine` persists session data and state. Because Authkestra treats most runtime structures as opaque blobs for storage, a Key-Value approach is perfectly sufficient for 99% of web applications. ## Available Implementations Authkestra includes built-in generic KV stores (`memory` and `redis`), forwarded directly through the `authkestra` facade crate: ```toml [dependencies] authkestra = { version = "0.13", features = ["axum", "redis"] } ``` ### 1. Memory Store (`memory`) Great for testing and local development. Data is stored entirely in memory and is lost on restart. ```rust use authkestra_engine::store::memory::MemoryStore; use authkestra_engine::SessionStore; use std::sync::Arc; // `MemoryStore` is generic; the `Arc` annotation picks // `T = Session` via the blanket `KvStore` impl. let store: Arc = Arc::new(MemoryStore::default()); ``` ### 2. Redis Store (`redis`) A production-ready distributed cache backed by `redis`. `RedisStore::new` is synchronous and takes a key prefix alongside the URL; the prefix namespaces every key this store writes, so several stores can share one Redis instance without colliding. ```rust use authkestra_engine::store::redis::RedisStore; let store = RedisStore::new("redis://127.0.0.1:6379/", "session".to_string())?; ``` If you already hold an open `redis::Client`, prefer `RedisStore::with_client(client, prefix)` — it lets several prefixed stores share one connection pool instead of opening a new one each. If you'd rather use a relational database for generic KV/session storage, implement `authkestra_engine::store::KvStore` directly against it — see the **[SQL Store](/storage/sql-store)** page for the OP-specific relational store `authkestra-op` ships, and `authkestra-example-seaorm`/`authkestra-example-diesel` in the repository for real, compiled examples of a third-party ORM implementing storage traits from scratch. --- # OP SQL Store > Opinionated SQL relational persistence for building an OpenID Provider (OP). URL: https://docs.authkestra.com/storage/sql-store/ If you are building an OpenID Provider (OP) using `authkestra-op`, you can implement the storage traits (`ClientStore`, `AuthorizationCodeStore`, `RefreshTokenStore`, and `DeviceCodeStore`) however you prefer. However, to simplify building OPs, Authkestra provides a batteries-included **native SQL** implementation in a separate crate: `authkestra_store_sqlx::SqlxOpStore`. It lives outside `authkestra-op` so that core has zero `sqlx` dependency — you only pull in `sqlx` (and its Postgres/MySQL/SQLite driver) if you actually want SQL-backed storage. ## Opinionated Relational Schema Unlike a generic [KV store](/storage/kv-store) that would store opaque JSON blobs, `SqlxOpStore` provides highly opinionated, normalized SQL tables with proper relational foreign keys, `ON DELETE CASCADE` constraints, and strictly defined columns (like `client_id`, `scopes`, `expires_at`). This allows you to easily query your OAuth clients, see exactly which users have active refresh tokens, and manage authorization codes directly via standard SQL queries in your dashboard or admin tools. ## Enabling the Feature To use it, add the `authkestra-store-sqlx` crate and enable the feature for the backend you want: ```toml [dependencies] authkestra-store-sqlx = { version = "0.13", features = ["postgres"] } # or mysql, sqlite ``` ## Setup Differences (SQL Store vs KV Store) The generic KV store creates a single `authkestra_kv` table; `SqlxOpStore` instead owns a set of normalized tables with foreign keys between them. Neither runs its migration for you on connection — both expose an idempotent `migrate()` you call once at startup. If you would rather manage the OP schema with your own migration runner (`sqlx-cli`, `refinery`, or hand-written SQL), skip `migrate()` and create the tables yourself; the store issues plain queries against them and does not track a migration version of its own. ### Upgrading a hand-managed schema to `authkestra-op` 0.7 0.7 added RFC 9449 DPoP refresh-token continuity and RFC 7523 `private_key_jwt` client authentication, which need three new, nullable columns **and one new table** (`oauth_dpop_jti`, which backs DPoP proof replay tracking per RFC 9449 §11.1 — without it every DPoP request is refused). `SqlxOpStore`'s `find_client` and `store_token` reference these columns unconditionally — **not** only when you call `migrate()` — so if you provision the schema yourself, upgrading to 0.7 without adding them breaks every token request against your existing tables. If you call `migrate()`, it adds them for you (idempotently, safe to run against a pre-0.7 database); this section is only for a schema you create and evolve outside of it. ```sql -- Postgres ALTER TABLE authkestra.oauth_refresh_tokens ADD COLUMN IF NOT EXISTS jkt VARCHAR(255); ALTER TABLE authkestra.oauth_clients ADD COLUMN IF NOT EXISTS token_endpoint_auth_method JSONB; ALTER TABLE authkestra.oauth_clients ADD COLUMN IF NOT EXISTS jwks JSONB; ``` ```sql -- SQLite (no ADD COLUMN IF NOT EXISTS — skip a statement if the column is already there) ALTER TABLE authkestra_oauth_refresh_tokens ADD COLUMN jkt TEXT; ALTER TABLE authkestra_oauth_clients ADD COLUMN token_endpoint_auth_method TEXT; ALTER TABLE authkestra_oauth_clients ADD COLUMN jwks TEXT; ``` The replay table has no foreign key to `oauth_clients`: a DPoP `jti` is client-generated and checked before the grant is dispatched, so it is not owned by a client row and must not be cascade-deleted with one. ```sql -- Postgres CREATE TABLE IF NOT EXISTS authkestra.oauth_dpop_jti ( jti VARCHAR(255) PRIMARY KEY, expires_at TIMESTAMPTZ NOT NULL ); -- SQLite CREATE TABLE IF NOT EXISTS authkestra_oauth_dpop_jti ( jti TEXT PRIMARY KEY, expires_at DATETIME NOT NULL ); -- MySQL: DATETIME(3), not DATETIME. MySQL rounds a plain DATETIME to whole -- seconds, which would both hold a jti past its window and make the -- expired-row reclaim compare against a rounded value. CREATE TABLE IF NOT EXISTS authkestra_oauth_dpop_jti ( jti VARCHAR(255) PRIMARY KEY, expires_at DATETIME(3) NOT NULL ); ``` Rows are self-expiring in the logical sense — a `jti` whose `expires_at` has passed is reclaimed in place by the next proof that happens to reuse it — but nothing sweeps the table, so a busy OP accumulates one row per distinct proof. Prune it on whatever schedule suits you: `DELETE FROM ... WHERE expires_at <= now()`. Deleting a not-yet-expired row re-opens the replay window for that proof's remaining lifetime. ```sql -- MySQL (no ADD COLUMN IF NOT EXISTS across commonly-deployed versions — same caveat as SQLite) ALTER TABLE authkestra_oauth_refresh_tokens ADD COLUMN jkt VARCHAR(255); ALTER TABLE authkestra_oauth_clients ADD COLUMN token_endpoint_auth_method JSON; ALTER TABLE authkestra_oauth_clients ADD COLUMN jwks JSON; ``` All three columns are nullable and additive: existing rows simply have `NULL` in them, read back as `jkt: None` / `token_endpoint_auth_method: None` / `jwks: None`, exactly as if the client or refresh token predates this release. **`token_endpoint_auth_method` and `jwks` hold JSON, not bare text.** `find_client` decodes both through `sqlx`'s `Json` type and — deliberately — returns a storage error rather than silently falling back to `None` when a non-`NULL` value fails to decode, because `None` means "no client authentication configured". On Postgres and MySQL the `JSONB`/`JSON` column type rejects a malformed value at `INSERT` time; on SQLite the column is plain `TEXT` and will accept anything, so the mistake only surfaces later, as a `500` on every authorize and token request for that client. `token_endpoint_auth_method` is a JSON **string**, so it must carry its quotes, and its value is one of `"client_secret_basic"`, `"client_secret_post"`, `"private_key_jwt"`, or `"none"`: ```sql -- correct UPDATE authkestra_oauth_clients SET token_endpoint_auth_method = '"private_key_jwt"' WHERE client_id = 'my-client'; -- WRONG: not valid JSON — every lookup of this client now fails with a storage error UPDATE authkestra_oauth_clients SET token_endpoint_auth_method = 'private_key_jwt' WHERE client_id = 'my-client'; -- WRONG: valid JSON, but not a method this release models — also a storage error, by design UPDATE authkestra_oauth_clients SET token_endpoint_auth_method = '"client_secret_jwt"' WHERE client_id = 'my-client'; ``` `jwks` is the client's JWK Set **object**, exactly as RFC 7517 §5 defines it — `{"keys": [ ... ]}`, not a bare array and not a URL: ```sql -- correct UPDATE authkestra_oauth_clients SET jwks = '{"keys":[{"kty":"EC","crv":"P-256","x":"...","y":"...","kid":"k1"}]}' WHERE client_id = 'my-client'; -- WRONG: bare array, missing the "keys" wrapper UPDATE authkestra_oauth_clients SET jwks = '[{"kty":"EC","crv":"P-256","x":"...","y":"..."}]' WHERE client_id = 'my-client'; ``` ### Initialization & Migration Once connected to your pool, initialize the store and run the built-in migration: ```rust use authkestra_store_sqlx::SqlxOpStore; use sqlx::postgres::PgPoolOptions; let pool = PgPoolOptions::new().connect("postgres://user:pass@localhost/db").await?; // Initialize the store let op_store = SqlxOpStore::new(pool); // Create the OP tables if they do not already exist. Idempotent. op_store.migrate().await?; ``` You then pass this `op_store` to `Op::builder().store(...)` (see [`authkestra_op::OpBuilder`](/advanced/op-server)) for client, code, and token storage. A runnable version lives in the repository: ```bash cargo run -p authkestra --example axum_op_server_sqlx --all-features ``` --- # Implementing Stores > How to write a custom store for Authkestra and prevent TOCTOU races. URL: https://docs.authkestra.com/storage/implementing-stores/ Implementing a custom store means implementing the `KvStore` trait and its critical atomicity variants. ## The KvStore Interface Authkestra relies heavily on the generic `KvStore` interface for mapping keys to values. ```rust use async_trait::async_trait; use authkestra_engine::store::{KvStore, StoreError}; use std::time::Duration; pub struct MyRedisStore { client: redis::Client, } #[async_trait] impl KvStore for MyRedisStore { async fn get(&self, key: &str) -> Result, StoreError> { // Fetch from redis, deserialize, and return Ok(None) } async fn set(&self, key: &str, value: V, ttl: Duration) -> Result<(), StoreError> { // Serialize and save to redis with TTL Ok(()) } async fn delete(&self, key: &str) -> Result<(), StoreError> { // Delete from redis Ok(()) } } ``` :::caution[Atomicity Requirements] While `KvStore` handles basic storage, many operations in OAuth2 require strict atomicity to prevent Time-of-Check to Time-of-Use (TOCTOU) race conditions. For example, Authorization Codes and Device Codes MUST be single-use. If two parallel requests try to exchange the same code, only one must succeed. ::: To enforce this, you MUST also implement `AtomicConsume` and `IndexedKvStore` to provide atomic operations at the database layer. ## AtomicConsume Any store used for one-time-use items (like Authorization Codes or PKCE states) must implement `AtomicConsume`. The `consume()` method must atomically fetch the value and delete it in a single transaction or atomic command. ```rust use authkestra_engine::store::AtomicConsume; #[async_trait] impl AtomicConsume for MyRedisStore { async fn consume(&self, key: &str) -> Result, StoreError> { // MUST BE ATOMIC. E.g., in Redis, use a Lua script or GETDEL. // In SQL, use `DELETE FROM ... RETURNING *`. // DO NOT implement this as a manual get() followed by a delete()! Ok(None) } } ``` ## IndexedKvStore For items that need to be looked up by a secondary index (like Refresh Tokens which are looked up by token string but stored under a primary session ID), you must implement `IndexedKvStore`. ```rust use authkestra_engine::store::IndexedKvStore; #[async_trait] impl IndexedKvStore for MyRedisStore { async fn set_indexed( &self, primary_key: &str, secondary_key: &str, value: V, ttl: Duration, ) -> Result<(), StoreError> { // Atomically set the primary key AND the secondary index Ok(()) } async fn get_by_index(&self, secondary_key: &str) -> Result, StoreError> { // Lookup via secondary index Ok(None) } } ``` ## Performance: Trait Objects For I/O bound paths (like talking to a database), Authkestra uses dynamic dispatch (e.g. `Arc`) rather than generics. This prevents code bloat, dramatically speeds up compile times, and ensures your engine type remains clean and simple.