jwt-verify

Crates.iojwt-verify
lib.rsjwt-verify
version0.1.3
created_at2025-11-25 07:04:25.186707+00
updated_at2025-11-27 04:42:53.601215+00
descriptionJWT verification library for AWS Cognito tokens and any OIDC-compatible IDP
homepage
repository
max_upload_size
id1949269
size486,035
Minh Ho (minhho-itr)

documentation

README

JWT Verify

Rust library for verifying JWTs signed by Amazon Cognito, and any OIDC-compatible IDP.

Inspired by awslabs/aws-jwt-verify.

Features

  • Comprehensive validation of JWT tokens (ID tokens and Access tokens)
  • Support for both AWS Cognito and generic OIDC providers
  • Efficient JWK key management with automatic caching
  • Multiple user pools/providers with automatic issuer matching
  • Multiple client IDs per pool/provider
  • Configurable clock skew and cache duration
  • JWK prefetching (hydration) for cold start optimization
  • Detailed error handling and reporting
  • Thread-safe for use in async contexts
  • Example Axum web servers demonstrating integration patterns

Installation

Add this to your Cargo.toml:

[dependencies]
jwt-verify = "0.1.2"

Quick Start

Verifying AWS Cognito Tokens

use jwt_verify::{CognitoJwtVerifier, JwtError, JwtVerifier};

#[tokio::main]
async fn main() -> Result<(), JwtError> {
    // Create a verifier with a single user pool
    let verifier = CognitoJwtVerifier::new_single_pool(
        "us-east-1",              // AWS region
        "us-east-1_example",      // Cognito user pool ID
        &["client1".to_string()], // Allowed client IDs
    )?;

    // Verify an ID token
    let id_token = "your_jwt_id_token_here";
    let claims = verifier.verify_id_token(id_token).await?;

    // Access standard fields via trait methods
    println!("Subject: {}", claims.get_sub());
    println!("Email: {}", claims.get_email().unwrap_or("N/A"));

    // Verify an access token
    let access_token = "your_jwt_access_token_here";
    let access_claims = verifier.verify_access_token(access_token).await?;

    // Access standard fields via trait methods
    println!("Scopes: {:?}", access_claims.get_scopes());
    println!("Has 'read' scope: {}", access_claims.has_scope("read"));

    Ok(())
}

Need provider-specific fields? The examples above use trait methods that work across all providers. To access Cognito-specific fields like cognito_groups, cognito_username, or custom claims, see the Downcasting section.

Verifying OIDC Tokens

use jwt_verify::{JwtError, JwtVerifier, OidcJwtVerifier, OidcProviderConfig};

#[tokio::main]
async fn main() -> Result<(), JwtError> {
    // Create a configuration for an OIDC provider
    let config = OidcProviderConfig::new(
        "https://accounts.example.com",                              // Issuer URL
        Some("https://accounts.example.com/.well-known/jwks.json"),  // JWKS URL
        &["client1".to_string()],                                    // Allowed client IDs
        None,                                                        // Optional additional config
    )?;

    // Create an OIDC verifier
    let verifier = OidcJwtVerifier::new(vec![config])?;

    // Verify an ID token
    let id_token = "your_jwt_id_token_here";
    let claims = verifier.verify_id_token(id_token).await?;

    // Access standard fields via trait methods
    println!("Subject: {}", claims.get_sub());
    println!("Email: {}", claims.get_email().unwrap_or("N/A"));

    Ok(())
}

Need provider-specific fields? The example above uses trait methods that work across all providers. To access OIDC-specific fields like preferred_username, picture, locale, or custom claims, see the Downcasting section.

Advanced Usage

Single Pool with Multiple Client IDs

A common use case is having one user pool with multiple client IDs (e.g., web app, mobile app):

use jwt_verify::{CognitoJwtVerifier, JwtError, VerifierConfig};
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), JwtError> {
    // Single user pool with multiple client IDs
    let config = VerifierConfig::new(
        "us-east-1",
        "us-east-1_example",
        &["web-client-id".to_string(), "mobile-client-id".to_string()],
        None,
    )?
    .with_clock_skew(Duration::from_secs(120))           // 2 minutes clock skew
    .with_cache_duration(Duration::from_secs(3600 * 12)); // 12 hours cache

    let verifier = CognitoJwtVerifier::new(vec![config])?;

    // Tokens from either client ID will be accepted
    let token = "your_jwt_token_here";
    let claims = verifier.verify_id_token(token).await?;

    Ok(())
}

Multiple User Pools

The verifier automatically selects the correct user pool based on the token's issuer claim:

use jwt_verify::{CognitoJwtVerifier, JwtError, VerifierConfig};

#[tokio::main]
async fn main() -> Result<(), JwtError> {
    // Create configurations for multiple user pools
    let config1 = VerifierConfig::new(
        "us-east-1",
        "us-east-1_pool1",
        &["client1".to_string()],
        None,
    )?;

    let config2 = VerifierConfig::new(
        "us-west-2",
        "us-west-2_pool2",
        &["client2".to_string()],
        None,
    )?;

    // Create a verifier with multiple user pools
    let verifier = CognitoJwtVerifier::new(vec![config1, config2])?;

    // The verifier automatically matches the token to the correct pool
    let token = "your_jwt_token_here";
    let claims = verifier.verify_id_token(token).await?;

    Ok(())
}

JWK Prefetching (Hydration)

Prefetch JWKs to avoid cold start latency:

use jwt_verify::{CognitoJwtVerifier, JwtError, JwtVerifier};

#[tokio::main]
async fn main() -> Result<(), JwtError> {
    let verifier = CognitoJwtVerifier::new_single_pool(
        "us-east-1",
        "us-east-1_example",
        &["client1".to_string()],
    )?;

    // Prefetch JWKs to warm up the cache
    let hydration_results = verifier.hydrate().await;
    for (pool_id, result) in hydration_results {
        match result {
            Ok(_) => println!("✅ Prefetched JWKs for pool {}", pool_id),
            Err(e) => println!("❌ Failed to prefetch for pool {}: {}", pool_id, e),
        }
    }

    // Now token verification will be faster
    let token = "your_jwt_token_here";
    let claims = verifier.verify_id_token(token).await?;

    Ok(())
}

Multiple OIDC Providers

use jwt_verify::{JwtError, OidcJwtVerifier, OidcProviderConfig};

#[tokio::main]
async fn main() -> Result<(), JwtError> {
    let provider1 = OidcProviderConfig::new(
        "https://accounts.example.com",
        Some("https://accounts.example.com/.well-known/jwks.json"),
        &["client1".to_string()],
        None,
    )?;

    let provider2 = OidcProviderConfig::new(
        "https://auth.example2.com",
        Some("https://auth.example2.com/.well-known/jwks.json"),
        &["client2".to_string()],
        None,
    )?;

    // Create a verifier with multiple providers
    let verifier = OidcJwtVerifier::new(vec![provider1, provider2])?;

    // The verifier automatically matches the token to the correct provider
    let token = "your_jwt_token_here";
    let claims = verifier.verify_id_token(token).await?;

    Ok(())
}

Examples

The library includes comprehensive examples demonstrating various use cases:

Basic Examples (CLI)

  • cognito_basic.rs: AWS Cognito JWT verification including:
    • Single user pool with single client ID
    • Multiple user pools with different client IDs
    • Single user pool with multiple client IDs (web/mobile apps)
    • Negative test cases (wrong token types, expired tokens, etc.)
  • oidc_basic.rs: OIDC JWT verification including:
    • Single provider with single client ID
    • Multiple providers with different client IDs
    • Single provider with multiple client IDs
    • Negative test cases

Axum Integration Examples (Web Server)

  • cognito_axum.rs: Full-featured Axum web server with Cognito JWT authentication:
    • Public and protected endpoints
    • ID token and access token verification
    • Role-based access control (scope checking)
    • Proper HTTP error responses
    • JWK prefetching on startup
  • oidc_axum.rs: Same features as cognito_axum.rs but for OIDC providers

Running Examples

  1. Set up configuration using a .env file:

    cd examples
    cp .env.example .env
    # Edit .env with your actual configuration
    
  2. Run the examples:

    # Basic CLI examples
    cargo run --example cognito_basic
    cargo run --example oidc_basic
    
    # Axum web server examples
    cargo run --example cognito_axum
    cargo run --example oidc_axum
    
  3. Test the Axum server endpoints:

    # Public endpoint (no auth)
    curl http://localhost:3000/
    
    # Protected endpoint with ID token
    curl -H "Authorization: Bearer <ID_TOKEN>" \
         http://localhost:3000/protected/id-token
    
    # Protected endpoint with access token
    curl -H "Authorization: Bearer <ACCESS_TOKEN>" \
         http://localhost:3000/protected/access-token
    
    # Admin endpoint (requires 'admin' scope)
    curl -H "Authorization: Bearer <ACCESS_TOKEN>" \
         http://localhost:3000/admin
    

Example Configuration

The examples support various configurations through environment variables:

# Single user pool with multiple client IDs
AWS_REGION=us-east-1
COGNITO_USER_POOL_ID=us-east-1_example
COGNITO_CLIENT_ID=web-app-client-id
COGNITO_CLIENT_ID_2=mobile-app-client-id

# Your test tokens
COGNITO_ID_TOKEN=your-id-token
COGNITO_ACCESS_TOKEN=your-access-token

See examples/README.md for detailed configuration instructions and more examples.

Common Use Cases

ID Token vs Access Token

  • ID Tokens: Used for authentication - contains user identity information (email, name, etc.)
  • Access Tokens: Used for authorization - contains scopes and permissions
// Verify ID token for authentication
let id_claims = verifier.verify_id_token(id_token).await?;
println!("User: {}", id_claims.get_email().unwrap_or("N/A"));

// Verify access token for authorization
let access_claims = verifier.verify_access_token(access_token).await?;
if access_claims.has_scope("admin") {
    // Allow admin operations
}

Downcasting to Access Provider-Specific Fields

The verify_id_token() and verify_access_token() methods return trait objects (Box<dyn IdTokenClaims> and Box<dyn AccessTokenClaims>). While trait methods provide access to common fields, you can downcast to concrete types to access provider-specific fields.

Why Downcast?

  • Trait methods provide access to standard fields (sub, email, scopes, etc.)
  • Downcasting gives you access to provider-specific fields (Cognito groups, OIDC picture, custom claims, etc.)

Cognito Example

use jwt_verify::{CognitoJwtVerifier, CognitoIdTokenClaims, CognitoAccessTokenClaims, JwtVerifier};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let verifier = CognitoJwtVerifier::new_single_pool(
        "us-east-1",
        "us-east-1_example",
        &["client1".to_string()],
    )?;

    // Verify and downcast ID token
    let id_token = "your_id_token";
    let claims = verifier.verify_id_token(id_token).await?;

    if let Some(cognito_claims) = claims.downcast_ref::<CognitoIdTokenClaims>() {
        // Access Cognito-specific fields
        println!("Subject: {}", cognito_claims.base.sub);
        println!("Email: {:?}", cognito_claims.email);
        println!("Cognito username: {:?}", cognito_claims.cognito_username);
        println!("Cognito groups: {:?}", cognito_claims.cognito_groups);
        println!("Cognito roles: {:?}", cognito_claims.cognito_roles);

        // Check group membership
        if cognito_claims.cognito_groups.as_ref()
            .map(|g| g.contains(&"admins".to_string()))
            .unwrap_or(false) {
            println!("User is an admin!");
        }
    }

    // Verify and downcast access token
    let access_token = "your_access_token";
    let access_claims = verifier.verify_access_token(access_token).await?;

    if let Some(cognito_claims) = access_claims.downcast_ref::<CognitoAccessTokenClaims>() {
        // Access Cognito-specific fields
        println!("Username: {:?}", cognito_claims.base.username);
        println!("Token use: {}", cognito_claims.base.token_use);
        println!("Version: {:?}", cognito_claims.version);

        // Access custom claims
        if let Some(dept) = cognito_claims.base.get_custom_claim_string("department") {
            println!("Department: {}", dept);
        }
    }

    Ok(())
}

OIDC Example

use jwt_verify::{OidcJwtVerifier, OidcIdTokenClaims, OidcAccessTokenClaims, JwtVerifier, OidcProviderConfig};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = OidcProviderConfig::new(
        "https://accounts.example.com",
        Some("https://accounts.example.com/.well-known/jwks.json"),
        &["client1".to_string()],
        None,
    )?;
    let verifier = OidcJwtVerifier::new(vec![config])?;

    // Verify and downcast ID token
    let id_token = "your_id_token";
    let claims = verifier.verify_id_token(id_token).await?;

    if let Some(oidc_claims) = claims.downcast_ref::<OidcIdTokenClaims>() {
        // Access OIDC-specific fields
        println!("Subject: {}", oidc_claims.base.sub);
        println!("Email: {:?}", oidc_claims.email);
        println!("Preferred username: {:?}", oidc_claims.preferred_username);
        println!("Picture: {:?}", oidc_claims.picture);
        println!("Locale: {:?}", oidc_claims.locale);
        println!("Given name: {:?}", oidc_claims.given_name);
        println!("Family name: {:?}", oidc_claims.family_name);
    }

    // Verify and downcast access token
    let access_token = "your_access_token";
    let access_claims = verifier.verify_access_token(access_token).await?;

    if let Some(oidc_claims) = access_claims.downcast_ref::<OidcAccessTokenClaims>() {
        // Access OIDC-specific fields
        println!("Audience: {}", oidc_claims.base.aud);
        println!("Authorized party: {:?}", oidc_claims.base.azp);
        println!("Client ID: {:?}", oidc_claims.client_id);

        // Access custom claims
        if let Some(username) = oidc_claims.base.get_custom_claim_string("username") {
            println!("Username: {}", username);
        }
    }

    Ok(())
}

Important: Match Token Type to Claims Type

Always downcast to the correct type that matches your token:

Token Type Verifier Method Downcast To
Cognito ID Token verify_id_token() CognitoIdTokenClaims
Cognito Access Token verify_access_token() CognitoAccessTokenClaims
OIDC ID Token verify_id_token() OidcIdTokenClaims
OIDC Access Token verify_access_token() OidcAccessTokenClaims

Common Mistake:

// ❌ WRONG: Trying to downcast ID token to AccessTokenClaims
let claims = verifier.verify_id_token(token).await?;
let wrong = claims.downcast_ref::<CognitoAccessTokenClaims>(); // Won't compile!

// ✅ CORRECT: Downcast ID token to IdTokenClaims
let claims = verifier.verify_id_token(token).await?;
let correct = claims.downcast_ref::<CognitoIdTokenClaims>(); // Works!

When to Use Downcasting

Approach Use When
Trait methods only You only need standard fields (sub, email, scopes, exp, etc.)
Downcasting You need provider-specific fields (groups, custom claims, etc.)
Concrete verifier type You only use one provider and want to avoid trait objects entirely

Alternative: Use Concrete Verifier Type

If you only use one provider and want to avoid trait objects, use the concrete verifier type directly:

use jwt_verify::{CognitoJwtVerifier, CognitoAccessTokenClaims};

let verifier = CognitoJwtVerifier::new_single_pool(
    "us-east-1",
    "us-east-1_example",
    &["client1".to_string()],
)?;

// Use the generic verify method directly (no trait object)
let claims: CognitoAccessTokenClaims = verifier.verify(token).await?;

// Direct access to all fields, no downcasting needed
println!("Username: {:?}", claims.base.username);

Error Handling

The library provides detailed error information for debugging:

match verifier.verify_id_token(token).await {
    Ok(claims) => {
        // Token is valid
        println!("User: {}", claims.get_sub());
    }
    Err(e) => {
        // Handle specific error cases
        eprintln!("Token verification failed: {}", e);
        // Don't expose detailed errors to clients in production
    }
}

Best Practices

  1. Reuse verifier instances: Create a single verifier instance and reuse it for all verifications (thread-safe)
  2. Set appropriate clock skew: Use 1-2 minutes to account for time differences between systems
  3. Configure cache duration: Match your IdP's key rotation policy (default: 12 hours)
  4. Prefetch JWKs: Use hydrate() to warm up the cache and avoid cold start latency
  5. Use correct token types: ID tokens for authentication, access tokens for authorization
  6. Validate scopes: Always check scopes in access tokens for authorization decisions
  7. Handle errors gracefully: Don't expose detailed error information to clients in production
  8. Multiple client IDs: Use a single pool/provider config with multiple client IDs for different apps (web, mobile)

License

This project is licensed under the MIT License.

Commit count: 0

cargo fmt