Skip to content

Latest commit

 

History

History
663 lines (517 loc) · 21.8 KB

File metadata and controls

663 lines (517 loc) · 21.8 KB

secure_identity — Developer Guide

OWASP C6: Pluggable authentication — JWT validation, API keys, sessions, and MFA.

secure_identity is one of many possible implementations of the security_core::IdentitySource trait. You can use it as-is, replace it entirely with your own identity provider (Keycloak, Auth0, custom OIDC), or mix and match.


Quick Start

[dependencies]
secure_identity = "0.1.10"

# For development/testing only:
secure_identity = { version = "0.1.10", features = ["dev"] }

# Projected Kubernetes workload JWTs:
secure_identity = { version = "0.1.10", features = ["jwks"] }

Architecture Decision: Identity-Agnostic Authorization

┌──────────────────────────────────────────┐
│  YOUR IDENTITY PROVIDER                  │
│  (secure_identity, Keycloak, Auth0, ...) │
│                                          │
│  Implements: IdentitySource              │
│  Returns:    AuthenticatedIdentity       │
└────────────────┬─────────────────────────┘
                 │
                 ▼
┌──────────────────────────────────────────┐
│  secure_authz                            │
│                                          │
│  Accepts: AuthenticatedIdentity          │
│  Via:     SubjectResolver                │
│  Returns: Decision::Allow / Deny         │
└──────────────────────────────────────────┘

secure_authz depends on security_core::IdentitySource, never on secure_identity. You can swap identity providers without touching authorization code.


JWT Authentication (HS256)

Symmetric Token Validation

use secure_identity::{
    TokenValidator, TokenValidatorConfig,
    AuthenticationRequest, TokenKind,
};
use security_core::identity::IdentitySource;

// 1. Configure the validator
let config = TokenValidatorConfig {
    issuer: "https://auth.example.com".to_string(),
    audience: "my-api".to_string(),
    secret: b"your-256-bit-secret-key-here-min32".to_vec(),
};

let validator = TokenValidator::new(config);

// 2a. Via the Authenticator trait (detailed control)
let request = AuthenticationRequest {
    token: "eyJhbGciOiJIUzI1NiJ9...".to_string(),
    token_kind: TokenKind::BearerJwt,
};
let identity = validator.authenticate(&request).await?;
// identity.actor_id, identity.roles, identity.tenant_id, etc.

// 2b. Via the IdentitySource trait (simpler, works with secure_authz)
let identity = validator.resolve("eyJhbGciOiJIUzI1NiJ9...").await?;

What Gets Validated

  • Signature (HMAC-SHA256, constant-time via ring)
  • Expiration (exp claim)
  • Issuer (iss must match configured issuer)
  • Audience (aud must match configured audience)
  • Algorithm (only HS256 accepted — alg: none is always rejected)

What Gets Emitted on Failure

Every authentication failure emits an EventKind::AuthnFailure security event, allowing your security team to detect credential stuffing, token replay, and brute force attacks.


JWT Authentication (RS256 / ES256)

Asymmetric Token Validation

use secure_identity::{
    AsymmetricTokenValidator, AsymmetricTokenValidatorConfig,
    AlgorithmConfig,
};
use jsonwebtoken::DecodingKey;

// RS256 — RSA 2048+ public key
let config = AsymmetricTokenValidatorConfig {
    issuer: "https://auth.example.com".to_string(),
    audience: "my-api".to_string(),
    algorithm: AlgorithmConfig::RS256 {
        decoding_key: DecodingKey::from_rsa_pem(include_bytes!("public.pem")).unwrap(),
    },
};
let validator = AsymmetricTokenValidator::new(config);

// ES256 — ECDSA P-256 public key
let config = AsymmetricTokenValidatorConfig {
    issuer: "https://auth.example.com".to_string(),
    audience: "my-api".to_string(),
    algorithm: AlgorithmConfig::ES256 {
        decoding_key: DecodingKey::from_ec_pem(include_bytes!("ec-public.pem")).unwrap(),
    },
};
let validator = AsymmetricTokenValidator::new(config);

// Both implement IdentitySource and Authenticator
let identity = validator.resolve("eyJhbGciOiJSUzI1NiJ9...").await?;

JWKS Key Store

Fetch and cache public keys from your identity provider's JWKS endpoint:

use secure_identity::jwks::JwksKeyStore;
use std::time::Duration;

// Create a key store with 5-minute TTL cache
let store = JwksKeyStore::new(
    "https://auth.example.com/.well-known/jwks.json",
    Duration::from_secs(300),
);

// Fetch keys (caches automatically)
store.fetch().await?;

// Look up a key by Key ID (kid)
if let Some(decoding_key) = store.get_key("my-key-id").await {
    // Use with AsymmetricTokenValidator
}

// Check algorithm for a key
if let Some(alg) = store.get_algorithm("my-key-id").await {
    println!("Algorithm: {alg}"); // "RS256", "ES256", etc.
}

// Cache is thread-safe (Arc<RwLock>) and auto-refreshes when TTL expires
assert!(store.is_cache_valid().await);

A lookup for an unknown kid also performs one immediate, single-flight refresh even while the cache is fresh. This is the signing-key rotation path: a newly issued token does not need to wait for the ordinary TTL to expire. Because JWT headers are attacker-controlled, further unknown-key refreshes are globally limited to one attempt per 30 seconds, including failed attempts.


Projected Kubernetes Workload JWTs

WorkloadJwtValidator validates projected service-account JWTs and returns only a bounded system:serviceaccount:<namespace>:<serviceaccount> subject. Use WorkloadJwtValidator::new for one exact HTTPS JWKS URL. For an egress-free issuer, use WorkloadJwtValidator::from_static_jwks(issuer, audience, jwks_json) with a bounded inline public JWKS document.

Both paths pin RS256 and enforce exact issuer, single audience, signature, exp, nbf, and bounded unique kid checks. The inline path rejects private or symmetric key material, duplicate key IDs, unsupported algorithms, more than 64 keys, or documents larger than 1 MiB. It performs no network refresh; operators must restart or roll the workload to rotate the pinned document.

This API authenticates the workload only. The consumer must map the returned subject to tenant and operation authority through a separate deny-by-default registry.


OpenSSH Public-Key Validation

Use validate_openssh_public_key after your own line parser has isolated the algorithm and Base64 fields. Do not include authorized_keys options or a comment:

use secure_identity::validate_openssh_public_key;

validate_openssh_public_key(
    "ssh-ed25519",
    "AAAAC3NzaC1lZDI1NTE5AAAAILz0w2FOvLZuM/rmJyqsXLDcJeq+AJJCyQVmm5SUbus1",
)?;
# Ok::<(), secure_identity::OpenSshPublicKeyError>(())

The API accepts only ssh-ed25519, ssh-rsa, and ECDSA NIST P-256/P-384/P-521. It decodes into one fixed 16 KiB buffer and parses borrowed SSH fields, so attacker-controlled lengths cannot amplify parser allocation. It rejects noncanonical or trailing SSH wire data and byte-level outer/embedded algorithm confusion, then uses RustCrypto primitives to validate public parameters and actual curve membership. A successful call returns no key data, comment, or fingerprint.

The compatibility entry point accepts RSA moduli from 1,024 through 16,384 bits. Enforce a stronger minimum in the same operation rather than trying to infer key size from Ok(()):

use secure_identity::{
    validate_openssh_public_key_with_rsa_minimum_bits, OpenSshPublicKeyError,
};

# let payload = "AAAAB3NzaC1yc2EAAAADAQABAAAAgQDYGXqEnGVQBMQ64KGDcIfeeNZO+lbh7dtlTHL3toYGQdO1uiXGsF843TkmIeEj5sd/z2d4cUTqFpRNBWDYU0AfjFrT7jx3iW2haFtk8skB5DIMeSa4KZiJiqgYI0g0cJ4ntueauXvc2Nluq4QT0SVJTu1/VDyHxri9Jzf27L8Rqw==";
assert_eq!(
    validate_openssh_public_key_with_rsa_minimum_bits("ssh-rsa", payload, 2_048),
    Err(OpenSshPublicKeyError::InvalidKeyParameters),
);

Single-Use Tenant+Operation Capabilities

For brokered access — where the calling process holds no database credential and no network path to the data store — capability issues a narrow bearer statement a broker can act on: this subject may perform this operation, for this tenant, against this exact request, once, within at most 60 seconds.

use secure_identity::capability::{
    CapabilityIssuer, CapabilityRequest, CapabilityVerificationKey,
    CapabilityVerifier, Expected, InMemoryReplayStore, Operation,
    RsaCapabilitySigner,
};

# async fn example() -> Result<(), Box<dyn std::error::Error>> {
let request = CapabilityRequest::new("acct-42", Operation::Read, b"SELECT 1");

let token = CapabilityIssuer::new(
    "https://auth.example.com".to_string(),
    "broker".to_string(),
    RsaCapabilitySigner::from_pkcs8_pem(&std::fs::read("signing.pem")?)?,
)
.with_key_id("current".to_string())?
.issue("svc-api", &request, 30)
.await?;

let verifier = CapabilityVerifier::from_keyset(
    "https://auth.example.com".to_string(),
    "broker".to_string(),
    vec![
        CapabilityVerificationKey::from_rsa_pem(
            "current".to_string(),
            &std::fs::read("public.pem")?,
        )?,
    ],
)?;
let store = InMemoryReplayStore::default();
let expected = Expected::new("svc-api", "acct-42", Operation::Read, b"SELECT 1");

let verified = verifier.verify(&token, &expected, &store).await?; // first use: Ok
assert!(verifier.verify(&token, &expected, &store).await.is_err()); // replay: Err
# Ok(())
# }

What is enforced

Property How
Algorithm RS256 fixed in code. A non-RS256 protected alg is rejected before key selection, so alg: none and HMAC confusion do not apply.
Key selection Rotation-safe issuers emit a configured protected kid; key-set verifiers require one exact trusted match and reject missing, unknown, duplicate, or malformed IDs without fallback.
Lifetime MAX_TTL_SECONDS = 60, refused by the issuer and independently by the verifier — it does not assume a conforming issuer.
Request binding Length-framed SHA-256 over (tenant, operation, body), so authority cannot be moved to a different statement.
Single use The jti is consumed through your [ReplayStore] as the final step of verification.
Redaction No token, claim, request body or jti appears in Debug or error output.

Three things to get right in your integration

  1. Supply your own ReplayStore if you run more than one replica. InMemoryReplayStore is single-process; it cannot see uses made by another instance, so with multiple replicas the single-use guarantee silently weakens to per-process. Back it with shared state — a conditional write is the usual shape.

  2. Make consume atomic. Check-then-write races admit two winners under concurrency, which is exactly the case a replay guard exists to stop.

  3. Do not reorder verification in a wrapper. Signature, claims and request binding are all checked before the jti is consumed, so a rejected capability is not spent. If that order is inverted, an attacker can burn a victim's capability by presenting it against the wrong expectation.

  4. Use explicit key IDs for rotation. Configure the issuer with with_key_id, construct current and previous CapabilityVerificationKey values, and pass them to CapabilityVerifier::from_keyset. The legacy single-key constructors remain available only for existing kidless tokens; they reject tokens that carry a selector rather than silently ignoring it.

Choosing a signer

CapabilitySigner is a trait rather than a key so a KMS-backed signer — one that never exposes private material to the process — can replace RsaCapabilitySigner without changing callers.


API Key Authentication

Constant-time API key comparison to prevent timing side-channel attacks:

use secure_identity::api_key::ApiKeyAuthenticator;
use secure_identity::{AuthenticationRequest, TokenKind};
use security_core::types::ActorId;
use uuid::Uuid;

// Configure with the expected key
let auth = ApiKeyAuthenticator::new(
    "example-api-key".to_string(),
    ActorId::from(Uuid::new_v4()),
    vec!["api-user".into(), "read-only".into()],
);

// Authenticate a request
let request = AuthenticationRequest {
    token: "example-api-key".into(),
    token_kind: TokenKind::ApiKey,
};
let identity = auth.authenticate(&request).await?;
// identity.actor_id == configured actor_id
// identity.roles == ["api-user", "read-only"]

Security properties:

  • Uses subtle::ConstantTimeEq for comparison — no timing leaks
  • Handles length mismatch without early exit
  • Emits AuthnFailure security event on invalid keys

Session Management

use secure_identity::{InMemorySessionManager, SessionManager};
use security_core::identity::AuthenticatedIdentity;

let session_mgr = InMemorySessionManager::new();

// Create a session (1-hour lifetime)
let session = session_mgr.create_session(&identity, 3600).await?;
// session.id is 128-bit cryptographically random (ring::SystemRandom), hex-encoded

// Validate a session
match session_mgr.validate_session(&session.id).await {
    Ok(session) => {
        println!("Actor: {:?}", session.actor_id);
        println!("Roles: {:?}", session.roles);
        println!("Expires: {:?}", session.expires_at);
    }
    Err(e) => {
        // IdentityError::SessionExpired
    }
}

// Extend session lifetime (sliding window — add 30 minutes)
let refreshed = session_mgr.refresh_session(&session.id, 1800).await?;

// Revoke a session (logout)
session_mgr.revoke_session(&session.id).await?;
// Subsequent validate_session calls will return SessionExpired

Session Fields

Field Type Description
id String 128-bit random hex (cryptographically secure)
actor_id ActorId Who owns this session
tenant_id Option<TenantId> Tenant context
roles Vec<String> Session roles
created_at OffsetDateTime When session was created
expires_at OffsetDateTime When session expires
last_accessed OffsetDateTime Last validation/refresh time

Implementing a Custom Session Store

use secure_identity::SessionManager;
use secure_identity::Session;
use secure_identity::IdentityError;
use security_core::identity::AuthenticatedIdentity;

struct RedisSessionManager {
    // your Redis connection
}

impl SessionManager for RedisSessionManager {
    async fn create_session(
        &self,
        identity: &AuthenticatedIdentity,
        lifetime_secs: u64,
    ) -> Result<Session, IdentityError> {
        // Store in Redis with TTL
        todo!()
    }

    async fn validate_session(&self, id: &str) -> Result<Session, IdentityError> {
        // Look up in Redis; return SessionExpired if not found/expired
        todo!()
    }

    async fn refresh_session(&self, id: &str, extend_secs: u64) -> Result<Session, IdentityError> {
        // Extend TTL in Redis
        todo!()
    }

    async fn revoke_session(&self, id: &str) -> Result<(), IdentityError> {
        // Delete from Redis
        todo!()
    }
}

Redis Session Store (feature: session-redis)

secure_identity now includes a Redis-backed implementation of SessionManager.

[dependencies]
secure_identity = { version = "0.1.10", features = ["session-redis"] }
use secure_identity::session_redis::RedisSessionManager;
use secure_identity::SessionManager;

let store = RedisSessionManager::new("redis://127.0.0.1:6379/")?;
// Same SessionManager API as in-memory implementation.

OIDC Discovery (feature: oidc)

OIDC integration is intentionally a thin wrapper over the openidconnect crate with secure defaults.

[dependencies]
secure_identity = { version = "0.1.10", features = ["oidc"] }
use secure_identity::oidc::OidcClient;

let oidc = OidcClient::new(300);
let provider = oidc.discover("https://accounts.example.com").await?;
let auth = oidc
    .auth_url(
        "https://accounts.example.com",
        "client-id",
        "https://app.example.com/callback",
    )
    .await?;

assert!(auth.authorization_url.contains("code_challenge="));

Security defaults applied by OidcClient:

  • HTTPS issuer enforcement (unless explicit test override is enabled)
  • Redirects disabled for discovery HTTP client (SSRF hardening)
  • Metadata cache with TTL
  • PKCE challenge included in generated authorization URLs

MFA (Multi-Factor Authentication)

The MFA module includes a concrete RFC 6238 implementation via totp::TotpProvider:

use secure_identity::totp::TotpProvider;

let provider = TotpProvider::new("SunLit", 1);
let enrollment = provider.generate_secret("alice@example.com")?;
let code = provider.generate_current_code(&enrollment.secret)?;
assert!(provider.verify_code(&enrollment.secret, &code)?);

Authentication Event Auditing

Successful and failed authentication attempts can be emitted as structured security events.

use secure_identity::auth_events::{AuthEventContext, AuthEventEmitter};
use security_events::sink::InMemorySink;

let sink = InMemorySink::new();
let emitter = AuthEventEmitter::new(sink.clone());

emitter.emit_success(AuthEventContext {
    user_id: "user-123".to_string(),
    method: "jwt".to_string(),
    source_ip: Some("127.0.0.1".parse().unwrap()),
    user_agent: Some("Mozilla/5.0".to_string()),
});

emitter.emit_failure(
    AuthEventContext {
        user_id: "user-123".to_string(),
        method: "jwt".to_string(),
        source_ip: Some("127.0.0.1".parse().unwrap()),
        user_agent: Some("Mozilla/5.0".to_string()),
    },
    "invalid_credentials",
);

Development Authenticator

For development and testing only. Accepts any token and returns a configurable identity:

// Cargo.toml: secure_identity = { ..., features = ["dev"] }

#[cfg(feature = "dev")]
use secure_identity::dev::DevAuthenticator;
use security_core::types::{ActorId, TenantId};
use uuid::Uuid;

let dev_auth = DevAuthenticator::new(
    ActorId::from(Uuid::nil()),
    Some(TenantId::from(Uuid::nil())),
    vec!["admin".into()],
);
// WARNING: Emits tracing::warn! on construction
// NEVER use in production — accepts ANY token

Error Handling

All identity errors map cleanly to AppError for HTTP responses:

use secure_identity::IdentityError;
use secure_errors::kind::AppError;

let err = IdentityError::InvalidCredentials;
let app_err: AppError = err.into();
// → AppError::Forbidden { policy: "authentication" }

let err = IdentityError::TokenExpired;
let app_err: AppError = err.into();
// → AppError::Forbidden { policy: "authentication" }

let err = IdentityError::ProviderUnavailable;
let app_err: AppError = err.into();
// → AppError::Dependency { dep: "identity_provider" }

All IdentityError variants:

Variant Maps to AppError HTTP Status
InvalidCredentials Forbidden 403
TokenExpired Forbidden 403
TokenMalformed Validation 400
MfaRequired Forbidden 403
SessionExpired Forbidden 403
ProviderUnavailable Dependency 503

Bringing Your Own Identity Provider

You don't need secure_identity at all. Implement IdentitySource directly:

use security_core::identity::{
    AuthenticatedIdentity, IdentityResolutionError, IdentitySource,
};
use security_core::types::ActorId;
use std::collections::HashMap;
use time::OffsetDateTime;

struct KeycloakAdapter {
    jwks_url: String,
    issuer: String,
}

impl IdentitySource for KeycloakAdapter {
    async fn resolve(
        &self,
        token: &str,
    ) -> Result<AuthenticatedIdentity, IdentityResolutionError> {
        // 1. Fetch/cache JWKS from Keycloak
        // 2. Validate JWT signature, expiration, issuer
        // 3. Extract claims
        let claims = validate_with_keycloak(token, &self.jwks_url, &self.issuer)
            .await
            .map_err(|_| IdentityResolutionError::InvalidToken)?;

        Ok(AuthenticatedIdentity {
            actor_id: ActorId::from(claims.sub),
            tenant_id: claims.tenant_id.map(Into::into),
            roles: claims.realm_access.roles,
            attributes: HashMap::new(),
            authenticated_at: OffsetDateTime::now_utc(),
        })
    }
}

// Use directly with secure_authz — no secure_identity dependency needed:
// let authorizer = DefaultAuthorizer::new(engine);
// let identity = keycloak_adapter.resolve(token).await?;
// let subject = DefaultSubjectResolver::resolve(&identity);
// let decision = authorizer.authorize(&subject, &action, &resource).await;

API Reference

Type Module Description
TokenValidator token HS256 JWT validator
TokenValidatorConfig token Config for HS256
AsymmetricTokenValidator token RS256/ES256 JWT validator
AsymmetricTokenValidatorConfig token Config for RS256/ES256
AlgorithmConfig token Algorithm + key material
ApiKeyAuthenticator api_key Constant-time API key auth
JwksKeyStore jwks JWKS key fetch + cache
WorkloadJwtValidator workload Projected Kubernetes JWT validation using exact HTTPS or bounded inline public JWKS
InMemorySessionManager session In-memory session store
Session session Session data struct
SessionManager session Open trait for session stores
Authenticator authenticator Sealed authentication trait
AuthenticationRequest authenticator Auth request input
TokenKind authenticator BearerJwt / ApiKey / SessionCookie
IdentityError error Authentication error enum
MfaProvider mfa Open trait for MFA backends
MfaChallenge mfa MFA challenge struct
MfaResponse mfa MFA response struct
DevAuthenticator dev Dev-only authenticator (feature dev)