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.
[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"] }┌──────────────────────────────────────────┐
│ 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.
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?;- Signature (HMAC-SHA256, constant-time via
ring) - Expiration (
expclaim) - Issuer (
issmust match configured issuer) - Audience (
audmust match configured audience) - Algorithm (only HS256 accepted —
alg: noneis always rejected)
Every authentication failure emits an EventKind::AuthnFailure security event, allowing your security team to detect credential stuffing, token replay, and brute force attacks.
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?;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.
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.
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),
);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(())
# }| 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. |
-
Supply your own
ReplayStoreif you run more than one replica.InMemoryReplayStoreis 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. -
Make
consumeatomic. Check-then-write races admit two winners under concurrency, which is exactly the case a replay guard exists to stop. -
Do not reorder verification in a wrapper. Signature, claims and request binding are all checked before the
jtiis 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. -
Use explicit key IDs for rotation. Configure the issuer with
with_key_id, construct current and previousCapabilityVerificationKeyvalues, and pass them toCapabilityVerifier::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.
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.
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::ConstantTimeEqfor comparison — no timing leaks - Handles length mismatch without early exit
- Emits
AuthnFailuresecurity event on invalid keys
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| 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 |
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!()
}
}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 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
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)?);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",
);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 tokenAll 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 |
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;| 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) |