arete-auth 0.7.0

Authentication and authorization utilities for Arete
Documentation

arete-auth

Authentication and authorization utilities for Arete, providing JWT token handling, claims validation, and Ed25519-based key management for secure WebSocket and HTTP authentication.

Overview

arete-auth provides a robust, production-ready authentication system designed specifically for Arete deployments. It uses Ed25519 (EdDSA) for asymmetric cryptographic signing, offering superior security compared to traditional HMAC-based approaches.

Key Features

  • Ed25519 Signatures: Asymmetric signing using the EdDSA algorithm for enhanced security
  • JWT Token Support: Full JWT implementation with customizable session claims
  • Key Rotation: JWKS (JSON Web Key Set) support for seamless key rotation
  • Origin Binding: Optional defense-in-depth with origin validation
  • IP Binding: Client IP validation for high-security scenarios
  • Resource Limits: Built-in metering and rate limiting support
  • Token Revocation: Support for token revocation lists
  • Security Auditing: Structured audit logging for authentication events
  • Multi-key Verification: Verify tokens against multiple signing keys

Installation

Add to your Cargo.toml:

[dependencies]
arete-auth = "0.2"

Feature Flags

  • jwks (default): Enables JWKS fetching from URLs via reqwest
[dependencies]
arete-auth = { version = "0.2", default-features = false }  # Without JWKS

Usage

Basic Token Signing and Verification

use arete_auth::{SigningKey, TokenSigner, TokenVerifier, SessionClaims};

// Generate a new Ed25519 key pair
let signing_key = SigningKey::generate();
let verifying_key = signing_key.verifying_key();

// Create a signer with your issuer
let signer = TokenSigner::new(signing_key, "my-service");

// Build session claims
let claims = SessionClaims::builder("my-service", "user-123", "api-gateway")
    .with_ttl(300)  // 5 minute session
    .with_scope("read write")
    .with_metering_key("meter-456")
    .build();

// Sign the token
let token = signer.sign(claims).unwrap();

// Verify the token
let verifier = TokenVerifier::new(verifying_key, "my-service", "api-gateway");
let auth_context = verifier.verify(&token, None, None).unwrap();

println!("Authenticated user: {}", auth_context.subject);

Typed Solana Gateway Claims

Regional gateways use the stable audience arete:solana-gateway and target kind solana-gateway-binding. Build and validate those claims with the typed helpers rather than duplicating wire strings:

use arete_auth::{
    SessionClaims, SolanaGatewayAuthorization, SolanaGatewayScope,
};

let claims = SessionClaims::solana_gateway_builder(
    "my-service",
    "user-123",
    "gateway-us-east-1",
)
.with_scope("read transaction:inspect")
.build();

let authorization = SolanaGatewayAuthorization::try_from_context(
    &auth_context,
    "gateway-us-east-1",
    SolanaGatewayScope::TransactionInspect,
)?;

The only gateway scopes are the exact whitespace-delimited values read, transaction:inspect, transaction:send, and fork:read; no scope implies another. fork:read authorizes only the read-only fork JSON-RPC endpoint (POST /rpc/v1/fork). Legacy deployment claims and their audience remain unchanged.

Key Management

use arete_auth::{SigningKey, VerifyingKey, KeyLoader};
use std::path::Path;

// Generate and save keys to files
let (signing_key, verifying_key) = KeyLoader::generate_and_save_keys(
    "signing.key",
    "verifying.key"
).unwrap();

// Load keys from files
let signing_key = KeyLoader::signing_key_from_file("signing.key").unwrap();
let verifying_key = KeyLoader::verifying_key_from_file("verifying.key").unwrap();

// Load from environment variables (base64-encoded)
std::env::set_var("SIGNING_KEY", "base64-encoded-key-here");
let signing_key = KeyLoader::signing_key_from_env("SIGNING_KEY").unwrap();

Origin and IP Binding

Add defense-in-depth by binding tokens to specific origins or client IPs:

use arete_auth::{SessionClaims, TokenSigner, TokenVerifier, SigningKey};

let signing_key = SigningKey::generate();

// Create origin-bound token
let claims = SessionClaims::builder("issuer", "user-123", "audience")
    .with_origin("https://example.com")
    .with_client_ip("192.168.1.1")
    .build();

let signer = TokenSigner::new(signing_key.clone(), "issuer");
let token = signer.sign(claims).unwrap();

// Verify with origin validation
let verifier = TokenVerifier::new(
    signing_key.verifying_key(),
    "issuer",
    "audience"
)
.with_origin_validation()
.with_client_ip_validation();

// Must provide matching origin and IP
let context = verifier.verify(
    &token,
    Some("https://example.com"),
    Some("192.168.1.1")
).unwrap();

Resource Limits and Metering

use arete_auth::{SessionClaims, Limits, KeyClass};

let limits = Limits {
    max_connections: Some(10),
    max_subscriptions: Some(100),
    max_snapshot_rows: Some(1000),
    max_messages_per_minute: Some(1000),
    max_bytes_per_minute: Some(10_000_000),
    ..Limits::default()
};

let claims = SessionClaims::builder("issuer", "user-123", "audience")
    .with_limits(limits)
    .with_key_class(KeyClass::Publishable)  // or KeyClass::Secret
    .with_plan("pro")
    .build();

Account Policy Identities (v2)

Signed sessions can carry explicit actor/account/consumer identities plus a monotonic policy version and aggregate account limits. All five fields are optional on the wire, so old tokens keep verifying unchanged, but partial subsets are rejected at verification time:

  • an authenticated v2 token carries actor_key, account_key, consumer_key, policy_version, and account_limits together;
  • an anonymous v2 token carries actor_key, consumer_key, and policy_version with plan = "anonymous" and no account fields;
  • a legacy token carries none of the new fields.

limits stays consumer/connection-scoped; account_limits is the aggregate budget shared by every consumer of one billing account. Identities are opaque keys of 1-512 bytes drawn from [A-Za-z0-9._:@/-].

use arete_auth::{SessionClaims, Limits};

let claims = SessionClaims::builder("issuer", "user:123", "audience")
    .with_metering_key("account:42")
    .with_plan("pro")
    .with_actor_key("user:123")
    .with_account_key("account:42")
    .with_consumer_key("consumer:5f2c9a")
    .with_policy_version(7)
    .with_limits(Limits { max_connections: Some(4), ..Limits::default() })
    .with_account_limits(Limits { max_connections: Some(50), ..Limits::default() })
    .build();

Readers resolve identities once through AuthContext (and the propagated ProgramReadAuthorization / SolanaGatewayAuthorization):

let actor = context.actor_key();       // actor_key, falling back to sub
let consumer = context.consumer_key(); // consumer_key, falling back to sub
let account = context.account_key();   // account_key, falling back to metering_key
let legacy = context.is_legacy_policy();

Never repurpose metering_key as a consumer limiter for new tokens; it is an account attribution key. Keep fallback logic in these helpers rather than duplicating it in runtimes.

Sealed Claims for Browser Sessions

A session token is signed, not encrypted, so whoever holds it can read every claim. A token handed to a browser should not tell its visitor about the account behind it. TokenSigner::sign_sealed moves the account claims (ACCOUNT_CLAIMS: metering_key, limits, plan, actor_key, account_key, consumer_key, policy_version, account_limits) into one sealed claim, encrypted with XChaCha20-Poly1305 under a key the issuer and the verifying services share. The ciphertext is bound to the token's iss, aud and jti.

Verifiers given the keys open the sealed claim after the signature check, so AuthContext and every limit built from it are the same as for an unsealed token. A verifier without the keys refuses a sealed token.

use arete_auth::{ClaimSealKey, ClaimSealKeys, TokenSigner, TokenVerifier};

let seal = ClaimSealKey::from_base64("2026-10", &key_base64)?;
let token = TokenSigner::new(signing_key, "issuer").sign_sealed(claims, &seal)?;

// `id=base64[,id=base64...]`; the first key seals, all of them open.
let keys = ClaimSealKeys::parse(&configured_keys)?;
let context = TokenVerifier::new(verifying_key, "issuer", "audience")
    .with_claim_seal_keys(keys)
    .verify(&token, None, None)?;

Issuers that build claims as JSON can call seal_claims and unseal_claims directly on the claims object. To rotate, add the new key behind the current one on every verifier, then put it first on the issuer, then drop the old key once the tokens it sealed have expired.

JWKS Key Rotation

use arete_auth::token::JwksVerifier;

// Fetch JWKS from a URL
let jwks = JwksVerifier::fetch_jwks("https://auth.example.com/.well-known/jwks.json")
    .await
    .unwrap();

// Create verifier with JWKS support
let verifier = JwksVerifier::new(jwks, "auth.example.com", "my-api");

// Tokens signed with any key in the JWKS can be verified
let context = verifier.verify(token, None, None).unwrap();

Multi-key Verification

Verify tokens against multiple keys (useful for zero-downtime rotation):

use arete_auth::{MultiKeyVerifier, RotationKey};

let verifier = MultiKeyVerifier::builder("issuer", "audience")
    .with_key(RotationKey::Primary(primary_key))
    .with_key(RotationKey::Secondary(secondary_key))
    .build();

let context = verifier.verify(token, None, None).unwrap();

Security Audit Logging

use arete_auth::{SecurityAuditLogger, AuditEvent, AuditSeverity};

// Log authentication events
let logger = ChannelAuditLogger::new(tx);
logger.log_event(AuditEvent::AuthSuccess {
    subject: "user-123".to_string(),
    jti: "token-id".to_string(),
    ip: Some("192.168.1.1".to_string()),
}).await;

Security Considerations

  1. Key Storage: Store signing keys securely (e.g., in environment variables, AWS KMS, or HashiCorp Vault)
  2. Token TTL: Use short-lived tokens (default: 5 minutes) with refresh mechanisms
  3. Origin Binding: Enable origin validation for browser-based clients
  4. Rate Limiting: Implement rate limiting using the built-in metering support

License

This project is licensed under the terms specified in the LICENSE file.

See Also