Middleware & Auth
Token Generation
Part of the Auth Module
This guide covers token generation. See the Authentication Overview for all auth capabilities, or jump to Password Hashing, API Keys, or OAuth/OIDC.
Introduction
Token generation in acton-service creates cryptographic tokens for stateless authentication. The module supports two token formats: PASETO V4 (the secure default) and JWT (feature-gated for compatibility). Refresh tokens enable long-lived sessions with automatic rotation and reuse detection.
The TokenGenerator trait abstracts token creation, with PasetoGenerator and JwtGenerator implementations. Claims include standard fields (subject, expiration, issuer), built-in fields (roles, permissions, email), and arbitrary custom claims for application-specific data. Storage backends (Redis, PostgreSQL, Turso) handle refresh token persistence with built-in security features.
Key characteristics:
- PASETO by default: Eliminates JWT algorithm confusion attacks
- Refresh token rotation: New token issued on each refresh, old token revoked
- Reuse detection: Detects stolen refresh tokens by tracking token families
- Flexible storage: Choose Redis, PostgreSQL, or Turso based on your needs
Quick Start
[dependencies]
acton-service = { version = "
use acton_service::auth::{PasetoGenerator, TokenGenerator, ClaimsBuilder};
use acton_service::auth::config::{PasetoGenerationConfig, TokenGenerationConfig};
// Create generator from configuration
let generator = PasetoGenerator::new(&paseto_config, &token_config)?;
// Build claims
let claims = ClaimsBuilder::new()
.user("123")
.email("user@example.com")
.role("user")
.build()?;
// Generate token
let token = generator.generate_token(&claims)?;
// Returns: v4.local.eyJzdWIiOiJ1c2VyOjEyMyIsLi4u...
Token Formats
PASETO (Default)
PASETO V4 tokens use modern cryptography and eliminate algorithm confusion attacks. Two modes are available:
| Mode | Cryptography | Use Case |
|---|---|---|
| V4.local | XChaCha20-Poly1305 (symmetric) | Single service, shared secret |
| V4.public | Ed25519 (asymmetric) | Distributed services, public verification |
use acton_service::auth::PasetoGenerator;
// V4.local with symmetric key
let generator = PasetoGenerator::with_symmetric_key(key_bytes, config);
// V4.public with Ed25519 private key
let generator = PasetoGenerator::with_private_key(private_key_bytes, config);
// From configuration file
let generator = PasetoGenerator::new(&paseto_config, &token_config)?;
Key generation:
# V4.local: 32-byte symmetric key
head -c 32 /dev/urandom > keys/paseto.key
# V4.public: Ed25519 keypair (use openssl or a key generation tool)
JWT (Feature-Gated)
JWT support requires the jwt feature flag. Use only when integrating with systems that require JWT.
[dependencies]
acton-service = { version = "
use acton_service::auth::JwtGenerator;
let generator = JwtGenerator::new(&jwt_config, &token_config)?;
let token = generator.generate_token(&claims)?;
// Returns: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Supported algorithms: RS256, RS384, RS512, ES256, ES384, HS256, HS384, HS512.
Building Claims
The ClaimsBuilder provides an ergonomic API for creating token claims.
use acton_service::auth::ClaimsBuilder;
// User token
let claims = ClaimsBuilder::new()
.user("123") // sub: "user:123"
.email("user@example.com")
.username("alice")
.roles(["user", "admin"])
.permissions(["read:docs", "write:docs"])
.issuer("my-auth-service")
.audience("my-api")
.build()?;
// Service/client token
let claims = ClaimsBuilder::new()
.client("api-service-abc") // sub: "client:api-service-abc"
.roles(["service"])
.build()?;
// Direct subject
let claims = ClaimsBuilder::new()
.subject("custom:identifier") // sub: "custom:identifier"
.build()?;
// With custom claims
let claims = ClaimsBuilder::new()
.user("123")
.custom_claim("tenant_id", serde_json::json!("org-42"))
.custom_claim("level", serde_json::json!(5))
.custom_claim("features", serde_json::json!(["beta", "dark_mode"]))
.build()?;
Claims structure:
| Field | Type | Description |
|---|---|---|
sub | String | Subject (required) |
email | Option | User email |
username | Option | Display name |
roles | Vec | Role identifiers |
perms | Vec | Permission identifiers |
exp | i64 | Expiration (set by generator) |
iat | Option | Issued at (set by generator) |
jti | Option | Token ID (set by generator if configured) |
iss | Option | Issuer |
aud | Option | Audience |
custom | HashMap | Arbitrary custom claims (via #[serde(flatten)]) |
Custom Claims
Custom claims allow you to embed arbitrary application-specific data in tokens. Any JSON-serializable value is supported — strings, numbers, booleans, arrays, and objects. Custom claims work identically with both PASETO and JWT tokens.
use acton_service::auth::ClaimsBuilder;
let claims = ClaimsBuilder::new()
.user("123")
.custom_claim("tenant_id", serde_json::json!("org-42"))
.custom_claim("subscription_tier", serde_json::json!("enterprise"))
.custom_claim("feature_flags", serde_json::json!(["beta", "dark_mode"]))
.custom_claims([
("region".to_string(), serde_json::json!("us-east-1")),
("max_requests".to_string(), serde_json::json!(10000)),
])
.build()?;
Custom claims are stored in a HashMap<String, serde_json::Value> and serialized flat alongside the standard claims using #[serde(flatten)]. This means a token payload looks like:
{
"sub": "user:123",
"exp": "2024-12-31T23:59:59+00:00",
"tenant_id": "org-42",
"subscription_tier": "enterprise",
"feature_flags": ["beta", "dark_mode"],
"region": "us-east-1",
"max_requests": 10000
}
Retrieving custom claims after token validation:
// Get as raw JSON value
if let Some(value) = claims.custom_claim("tenant_id") {
println!("Tenant: {}", value);
}
// Get as a typed value (returns None if missing or wrong type)
let tenant: Option<String> = claims.custom_claim_as("tenant_id");
let max_req: Option<i64> = claims.custom_claim_as("max_requests");
let flags: Option<Vec<String>> = claims.custom_claim_as("feature_flags");
Reserved Claim Keys
Do not use standard claim keys (sub, exp, iat, jti, iss, aud) or built-in field names (email, username, roles, perms) as custom claim keys. PASETO will reject reserved keys, and built-in fields are handled by their dedicated ClaimsBuilder methods.
Token Expiration
Tokens expire based on configuration. Use custom expiration for special cases.
use std::time::Duration;
use acton_service::auth::TokenGenerator;
// Default expiration (from config, typically 15 minutes)
let token = generator.generate_token(&claims)?;
// Custom expiration
let token = generator.generate_token_with_expiry(
&claims,
Duration::from_secs(3600), // 1 hour
)?;
// Get default lifetime
let lifetime = generator.default_lifetime();
Refresh Tokens
Refresh tokens enable long-lived sessions without storing long-lived access tokens. The framework implements automatic rotation and reuse detection.
How Rotation Works
1. User logs in → Access token (15 min) + Refresh token A (7 days)
2. Access token expires → Client sends Refresh token A
3. Server validates A → Revokes A → Issues Access + Refresh token B
4. Access token expires → Client sends Refresh token B
5. ... and so on
Reuse Detection
If an attacker steals Refresh token A and tries to use it after rotation:
1. Attacker sends stolen token A (already revoked)
2. Server detects reuse → Revokes entire token family
3. Legitimate user's token B is also revoked
4. User must re-authenticate
This limits the damage from stolen refresh tokens.
Storage Backends
[dependencies]
# Redis (fast, TTL-based expiration)
acton-service = { version = "
use acton_service::auth::{RedisRefreshStorage, RefreshTokenStorage};
// Redis storage
let storage = RedisRefreshStorage::new(redis_pool);
// PostgreSQL storage
use acton_service::auth::PgRefreshStorage;
let storage = PgRefreshStorage::new(pg_pool);
// Turso storage
use acton_service::auth::TursoRefreshStorage;
let storage = TursoRefreshStorage::new(turso_conn);
Storage API
use acton_service::auth::{RefreshTokenStorage, RefreshTokenMetadata};
use chrono::{Utc, Duration};
// Store a new refresh token
let metadata = RefreshTokenMetadata {
user_agent: Some("Mozilla/5.0...".to_string()),
ip_address: Some("192.168.1.1".to_string()),
device_id: None,
created_at: Utc::now(),
};
storage.store(
"token_id",
"user_123",
"family_abc",
Utc::now() + Duration::days(7),
&metadata,
).await?;
// Get token data
let data = storage.get("token_id").await?;
// Rotate: revoke old, create new atomically
storage.rotate(
"old_token_id",
"new_token_id",
"user_123",
"family_abc",
Utc::now() + Duration::days(7),
&metadata,
).await?;
// Revoke single token
storage.revoke("token_id").await?;
// Revoke all tokens in family (reuse detection)
let count = storage.revoke_family("family_abc").await?;
// Revoke all user tokens (logout everywhere)
let count = storage.revoke_all_for_user("user_123").await?;
// Cleanup expired (PostgreSQL/Turso only; Redis uses TTL)
let count = storage.cleanup_expired().await?;
Database Schema (PostgreSQL)
CREATE TABLE refresh_tokens (
id VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
family_id VARCHAR(255) NOT NULL,
is_revoked BOOLEAN NOT NULL DEFAULT FALSE,
expires_at TIMESTAMPTZ NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_family_id ON refresh_tokens(family_id);
CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);
Configuration
TokenGenerationConfig
pub struct TokenGenerationConfig {
/// Access token lifetime in seconds (default: 900 = 15 min)
pub access_token_lifetime_secs: i64,
/// Issuer claim
pub issuer: Option<String>,
/// Audience claim
pub audience: Option<String>,
/// Include jti (token ID) for revocation support (default: true)
pub include_jti: bool,
}
PasetoGenerationConfig
pub struct PasetoGenerationConfig {
/// PASETO version (default: "v4")
pub version: String,
/// Token purpose: "local" (symmetric) or "public" (asymmetric)
pub purpose: String,
/// Path to key file
pub key_path: PathBuf,
/// Issuer (overrides TokenGenerationConfig.issuer)
pub issuer: Option<String>,
/// Audience (overrides TokenGenerationConfig.audience)
pub audience: Option<String>,
}
RefreshTokenConfig
pub struct RefreshTokenConfig {
/// Enable refresh tokens (default: true)
pub enabled: bool,
/// Refresh token lifetime in seconds (default: 604800 = 7 days)
pub lifetime_secs: i64,
/// Enable token rotation on refresh (default: true)
pub rotate_on_refresh: bool,
/// Detect reuse of rotated tokens (default: true)
pub detect_reuse: bool,
/// Storage backend: "redis", "postgres", or "turso"
pub storage: String,
}
TOML Configuration
[auth.tokens]
access_token_lifetime_secs = 900
issuer = "my-auth-service"
audience = "my-api"
include_jti = true
[auth.paseto]
version = "v4"
purpose = "local"
key_path = "keys/paseto.key"
[auth.refresh_tokens]
enabled = true
lifetime_secs = 604800
rotate_on_refresh = true
detect_reuse = true
storage = "redis"
Complete Login Flow
use acton_service::auth::{
PasswordHasher, PasetoGenerator, TokenGenerator, ClaimsBuilder,
RedisRefreshStorage, RefreshTokenStorage, RefreshTokenMetadata,
TokenPair,
};
use chrono::{Utc, Duration};
use uuid::Uuid;
async fn login(
credentials: LoginRequest,
hasher: &PasswordHasher,
generator: &PasetoGenerator,
storage: &RedisRefreshStorage,
) -> Result<TokenPair, Error> {
// 1. Verify password
let user = find_user(&credentials.email).await?;
if !hasher.verify(&credentials.password, &user.password_hash)? {
return Err(Error::Auth("Invalid credentials".into()));
}
// 2. Build claims (including custom claims for multi-tenancy)
let claims = ClaimsBuilder::new()
.user(&user.id)
.email(&user.email)
.roles(user.roles.clone())
.custom_claim("tenant_id", serde_json::json!(&user.tenant_id))
.build()?;
// 3. Generate access token
let access_token = generator.generate_token(&claims)?;
// 4. Generate and store refresh token
let refresh_token_id = Uuid::new_v4().to_string();
let family_id = Uuid::new_v4().to_string();
let metadata = RefreshTokenMetadata::default();
storage.store(
&refresh_token_id,
&user.id,
&family_id,
Utc::now() + Duration::days(7),
&metadata,
).await?;
Ok(TokenPair::new(
access_token,
refresh_token_id,
900, // 15 min
604800, // 7 days
))
}
async fn refresh(
refresh_token_id: &str,
generator: &PasetoGenerator,
storage: &RedisRefreshStorage,
) -> Result<TokenPair, Error> {
// 1. Get and validate refresh token
let token_data = storage.get(refresh_token_id).await?
.ok_or(Error::Auth("Invalid refresh token".into()))?;
if token_data.is_revoked {
// Potential token reuse - revoke entire family
storage.revoke_family(&token_data.family_id).await?;
return Err(Error::Auth("Token reuse detected".into()));
}
// 2. Load user and build new claims
let user = find_user_by_id(&token_data.user_id).await?;
let claims = ClaimsBuilder::new()
.user(&user.id)
.email(&user.email)
.roles(user.roles.clone())
.build()?;
// 3. Generate new access token
let access_token = generator.generate_token(&claims)?;
// 4. Rotate refresh token
let new_refresh_token_id = Uuid::new_v4().to_string();
storage.rotate(
refresh_token_id,
&new_refresh_token_id,
&user.id,
&token_data.family_id,
Utc::now() + Duration::days(7),
&RefreshTokenMetadata::default(),
).await?;
Ok(TokenPair::new(
access_token,
new_refresh_token_id,
900,
604800,
))
}
API Reference
TokenGenerator Trait
pub trait TokenGenerator: Send + Sync + Clone {
/// Generate token with default expiration
fn generate_token(&self, claims: &Claims) -> Result<String, Error>;
/// Generate token with custom expiration
fn generate_token_with_expiry(
&self,
claims: &Claims,
expires_in: Duration,
) -> Result<String, Error>;
/// Get default token lifetime
fn default_lifetime(&self) -> Duration;
}
RefreshTokenStorage Trait
#[async_trait]
pub trait RefreshTokenStorage: Send + Sync {
async fn store(...) -> Result<(), Error>;
async fn get(&self, token_id: &str) -> Result<Option<RefreshTokenData>, Error>;
async fn revoke(&self, token_id: &str) -> Result<(), Error>;
async fn revoke_family(&self, family_id: &str) -> Result<u64, Error>;
async fn revoke_all_for_user(&self, user_id: &str) -> Result<u64, Error>;
async fn rotate(...) -> Result<(), Error>;
async fn cleanup_expired(&self) -> Result<u64, Error>;
}
Next Steps
- Token Authentication - Middleware for validating incoming tokens
- Password Hashing - Hash passwords before generating tokens
- Authentication Overview - All auth capabilities