Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

71 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

bymax-auth / @bymax-one/rust-auth

bymax-auth · @bymax-one/rust-auth

Full-stack authentication for Rust (Axum), React & Next.js
JWT · MFA · OAuth · Sessions · Multi-Tenant · Pure-Rust Crypto · WebAssembly Edge Verification

crates.io version crates.io downloads npm version docs.rs CI status coverage mutation gate OpenSSF Scorecard RustSec audit build provenance license Rust edition 2024

GitHub · Issues · Quick Start · docs.rs · API Reference · Examples


Important

🚧 Early development. This repository is under active construction. The crates are not yet published to crates.io or npm, and the API described below is the target surface the project is building toward — not all of it exists yet. Badges, version numbers, and install commands reflect the intended 1.0 release. See the development plan for current status.

✨ Overview

bymax-auth is a complete authentication and authorization solution shipped as a lean Cargo workspace (crates.io) plus a matching npm package — covering everything from an Axum backend to React hooks, Next.js route handlers, and an edge JWT verifier compiled to WebAssembly.

Instead of stitching together a dozen crates and packages for JWT, MFA, OAuth, sessions, password reset, and brute-force protection, you add one library and get a production-ready auth system that spans your entire stack — with the frontend types generated from the Rust source, so the server and the client can never drift.

Why bymax-auth?

  • 🎯 One workspace, full stack — A backend crate (bymax-auth) and an npm package (@bymax-one/rust-auth) in lockstep. Shared types and constants are generated from Rust via ts-rs and re-checked in CI — zero manual synchronization, zero drift.
  • 🔌 Your database, your rules — The library defines async traits (UserRepository, EmailProvider). You implement them with sqlx, SeaORM, Diesel, or anything else. No ORM dependency, no driver baked in.
  • 🦀 Pure-Rust crypto only — Password hashing, MFA encryption, TOTP, and token generation run entirely on RustCrypto (scrypt, argon2, aes-gcm, hmac, sha2, subtle). No ring, no OpenSSL, no C bindings, and #![forbid(unsafe_code)] on every first-party crate.
  • ⚡ Pay for what you use — A tiny always-compiled core; every heavy integration (Redis, Axum, reqwest, MFA) is a Cargo feature or a trait you plug. A per-feature dependency budget is enforced in CI, so a minimal build pulls a minimal tree.
  • 🏢 Multi-tenant ready — Every operation is scoped by tenant_id, taken from a resolver and never the request body. A separate platform-admin identity domain is isolated from tenant users from day one.
  • 🌐 Edge-native — The exact same HS256 verifier that runs on the server compiles to wasm32-unknown-unknown and runs in the Next.js Edge runtime with no network call — one implementation, server and edge, proven by tests.
cargo add bymax-auth --features "argon2,sessions,mfa,oauth,oauth-reqwest,redis,axum"
pnpm add @bymax-one/rust-auth

Note

Production status. Bymax Live — a Rust-backend + React/Next.js application — is the project's first production consumer (the dogfood target). It will run the library as its authentication and authorization layer (sessions, MFA, OAuth, platform admin, Redis), so the wire contract, the security invariants, and server/edge JWT parity are validated against real traffic rather than synthetic tests alone.


🔥 Features

🔐 Core Authentication

  • Registration & Login — Email/password hashed with scrypt and Argon2id (configurable, rehash-on-verify, self-describing PHC strings)
  • HS256 Access + Opaque Refresh Tokens — Atomic rotation with a grace window for concurrent requests; refresh tokens are opaque and never JWTs
  • Multi-Factor Authentication — TOTP with otpauth:// URI + QR, hashed recovery codes, and a temp-token challenge flow
  • OAuth 2.0 — Google built-in, PKCE + single-use state, account create/link/reject decisioning, MFA branch
  • Password Reset — Token-link or OTP, configurable per deployment, uniformly anti-enumerating
  • Email Verification — OTP-based with atomic resend cooldown

🛡️ Security

  • Pure-Rust Crypto — RustCrypto only (scrypt/Argon2id, AES-256-GCM, HMAC-SHA1 TOTP, HS256); no ring/OpenSSL/C on any path
  • Brute-Force Protection — Fixed-window Redis counters keyed on HMAC(tenant:email) — no PII in keys
  • Session Management — Active-session tracking with FIFO eviction, device/IP metadata, and new-session alerts
  • HttpOnly CookiesSecure-by-default, refresh cookie path-scoped with SameSite=Strict, plus a non-HttpOnly has_session signal
  • Constant-Time Comparisons — Every secret/token/OTP/recovery-code compare goes through subtle — never == on secret bytes
  • JWT Revocation — Instant access-token revocation via a Redis jti blacklist
  • Anti-Enumeration — Identical status, body, and timing for known vs. unknown accounts, with an always-run sentinel hash
  • Refresh-Token Reuse Detection — Replaying a consumed token revokes that login's whole lineage, and only that lineage
  • Bulk Access-Token Revocation — A password reset advances a per-user token epoch, invalidating every outstanding access token in one write
  • Absolute Session Lifetime — Optional hard cap on how long one login can be extended by rotation
  • Cross-Site Request Refusal — Cookie-authenticated writes from an untrusted origin are rejected (matters under SameSite=None)
  • Breached-Password Refusal — Optional Have I Been Pwned check by k-anonymity range, over the crate's own HttpClient seam
  • Per-Route Rate Limiting — A governor layer per route, values pinned to the shared cross-implementation contract

🏢 Multi-Tenant & Platform

  • Tenant Isolation — All operations scoped by tenant_id via a configurable TenantIdResolver (anti-spoofing: the body is ignored)
  • Platform Admin Auth — A separate identity domain with its own claims, sessions, and role hierarchy — fully isolated from tenant users
  • User Invitations — Single-use tokenized invites with role + tenant assignment and re-validation on accept
  • Role-Based Access Control — Hierarchical roles enforced by the RequireRole<R> extractor

🧩 Developer Experience

  • Full-Stack Typed — Rust domain types → TypeScript via ts-rs, drift-gated in CI
  • Two Published Artifactsbymax-auth on crates.io, @bymax-one/rust-auth on npm, versioned in lockstep
  • Builder + Validated Config — Assemble the engine with AuthEngineBuilder; build() fails fast with a typed ConfigError
  • Trait-Pluggable — Bring your own database, email transport, HTTP client, and stores
  • Axum-Native ExtractorsFromRequestParts guards (AuthUser, RequireRole<R>, …) — no middleware soup, no Passport equivalent

📦 Two Packages, One Workspace

The backend ships on crates.io; the frontend ships on npm. The WebAssembly edge verifier is bundled inside the npm package (never published as a standalone crate).

Backend — bymax-auth (crates.io)

A thin facade over a set of focused internal crates. You enable only the capabilities you need; the always-compiled core stays tiny.

Feature Enables
scrypt (default) Default password KDF (parity baseline) — drop-in scrypt
argon2 Argon2id KDF + the hardened AuthConfig::secure_defaults() profile
sessions Redis-backed session tracking + FIFO eviction
mfa TOTP setup/verify/challenge, recovery codes (AES-256-GCM secret at rest)
oauth OAuth 2.0 + PKCE orchestration (transport injected — zero HTTP deps)
oauth-reqwest Bundled reqwest-backed HttpClient (opt-in; omit to supply your own)
platform Platform-admin identity domain (login/MFA/sessions)
invitations Tokenized user invitations
redis Canonical Redis stores (redis + deadpool-redis) with atomic Lua
axum HTTP router, extractors, DTO validation, cookie delivery, per-route rate limiting, WebSocket tickets
client Native Rust typed auth client
full Every backend feature above

Note

There is no core feature — the engine is always compiled and individual flows are runtime toggles. When a feature is disabled, its crates and dependencies are never linked (a no-MFA build pulls in none of aes-gcm/sha1).

Frontend — @bymax-one/rust-auth (npm)

One package, four entry points — import only what your app needs:

Subpath Import Purpose Peer deps
Shared @bymax-one/rust-auth/shared Types, constants, error codes — generated from Rust None
Client @bymax-one/rust-auth/client Native-fetch client with single-flight refresh None
React @bymax-one/rust-auth/react AuthProvider + hooks React 19
Next.js @bymax-one/rust-auth/nextjs Proxy, route handlers, WASM-backed edge JWT verify Next.js 16
shared (generated, zero deps)
   ↗        ↖
client      (Rust crate: bymax-auth-client)
   ↑
 react
   ↑
nextjs ──→ bymax-auth-wasm (edge HS256 verifier)

Tip

Prefer to learn from working code? The examples/ directory ships runnable apps — axum-minimal, axum-mfa, axum-oauth-google, react-vite, and nextjs — each built and linted in CI so they never rot.

🚀 Quick Start

1. Add the crate

# Cargo.toml
[dependencies]
bymax-auth = { version = "1", features = ["argon2", "sessions", "mfa", "oauth", "oauth-reqwest", "redis", "axum"] }

2. Implement the repository trait

The library defines what it needs — your app provides how. Map the abstract AuthUser contract onto your own schema; the only invariant is that password_hash is persisted exactly as the library produced it (a self-describing PHC string).

use async_trait::async_trait;
use bymax_auth::{
    AuthUser, CreateUserData, CreateWithOAuthData, RepositoryError, UpdateMfaData, UserRepository,
};

pub struct PgUserRepository {
    pool: sqlx::PgPool,
}

#[async_trait]
impl UserRepository for PgUserRepository {
    async fn find_by_email(
        &self,
        email: &str,
        tenant_id: &str,
    ) -> Result<Option<AuthUser>, RepositoryError> {
        sqlx::query_as::<_, AuthUser>(
            "SELECT * FROM users WHERE email = $1 AND tenant_id = $2",
        )
        .bind(email.to_lowercase())
        .bind(tenant_id)
        .fetch_optional(&self.pool)
        .await
        .map_err(RepositoryError::backend)
    }

    async fn create(&self, data: CreateUserData) -> Result<AuthUser, RepositoryError> {
        // INSERT … RETURNING *, storing data.password_hash verbatim.
        todo!()
    }

    async fn update_password(&self, id: &str, password_hash: &str) -> Result<(), RepositoryError> {
        todo!()
    }

    async fn update_mfa(&self, id: &str, data: UpdateMfaData) -> Result<(), RepositoryError> {
        todo!()
    }

    async fn find_by_oauth_id(
        &self,
        provider: &str,
        provider_id: &str,
        tenant_id: &str,
    ) -> Result<Option<AuthUser>, RepositoryError> {
        todo!()
    }

    async fn create_with_oauth(
        &self,
        data: CreateWithOAuthData,
    ) -> Result<AuthUser, RepositoryError> {
        todo!()
    }

    // … link_oauth, update_status, update_email_verified, etc.
}

3. Implement the email provider trait

Email delivery is fully delegated — the library never imports a mailer SDK. It passes structured data (token, OTP, session info, invite data), never rendered HTML.

use async_trait::async_trait;
use bymax_auth::{EmailProvider, InviteData, SessionInfo};

pub struct ResendEmailProvider { /* client, from, app_url */ }

#[async_trait]
impl EmailProvider for ResendEmailProvider {
    async fn send_password_reset_token(&self, email: &str, token: &str, locale: Option<&str>) {
        // Render and send with your transport of choice (Resend/SES/SMTP).
    }

    async fn send_email_verification_otp(&self, email: &str, otp: &str, locale: Option<&str>) {
        // …
    }

    async fn send_new_session_alert(&self, email: &str, info: &SessionInfo, locale: Option<&str>) {
        // …
    }

    async fn send_invitation(&self, email: &str, data: &InviteData, locale: Option<&str>) {
        // …
    }

    // … password-reset OTP, MFA enabled/disabled notifications, etc.
}

Warning

Any consumer-supplied value (display name, tenant name, inviter name, device string) interpolated into an HTML email body MUST be escaped to prevent stored XSS. Tokens and OTPs are library-generated and safe; the placeholders you fill in are not.

4. Build the engine and mount the router

build() runs full startup validation and returns Result<AuthEngine, ConfigError> — a misconfiguration fails fast at boot, never at the first request.

use std::sync::Arc;
use bymax_auth::{
    auth_router, AuthConfig, AuthEngine, AxumAuthConfig, Environment, RedisStores,
};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let engine = AuthEngine::builder()
        .config(AuthConfig::secure_defaults()) // or AuthConfig::nest_compat_defaults()
        .environment(Environment::Production)   // resolves Secure cookies + prod redirect checks
        .user_repository(Arc::new(PgUserRepository { pool }))
        .email_provider(Arc::new(ResendEmailProvider { /* … */ }))
        .redis_stores(Arc::new(RedisStores::connect("redis://127.0.0.1").await?))
        .oauth_provider(Arc::new(bymax_auth::GoogleProvider::new(/* client id/secret */)))
        .http_client(Arc::new(bymax_auth::ReqwestHttpClient::default()))
        .build()?;

    let app = auth_router(AxumAuthConfig::new(Arc::new(engine)));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

5. Protect your own routes with extractors

Guards are Axum FromRequestParts extractors — compose them straight into a handler signature. They read the token from a cookie or Authorization: Bearer header — never from the query string.

use axum::Json;
use bymax_auth::{AuthUser, RequireRole, SafeAuthUser, SelfOrAdmin};

// Any authenticated dashboard user.
async fn profile(user: AuthUser) -> Json<SafeAuthUser> {
    Json(user.into_safe())
}

// Admins (and anything above them in the hierarchy) only.
async fn list_users(_: RequireRole<{ "admin" }>) -> &'static str {
    "ok"
}

// The resource owner, or an admin.
async fn update_user(_: SelfOrAdmin) -> &'static str {
    "ok"
}

6. Frontend integration (React)

Build an AuthClient once, hand it to AuthProvider; the hooks read the context it populates — exactly as in @bymax-one/nest-auth, so existing consumers change only the import specifier.

// app/providers.tsx
'use client'
import { AuthProvider } from '@bymax-one/rust-auth/react'
import { createAuthClient } from '@bymax-one/rust-auth/client'

const authClient = createAuthClient({
  // Same-origin calls flow through the Next.js proxy under `/api/auth/*`.
  // Set `baseUrl` only for a cross-origin API.
})

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <AuthProvider client={authClient} onSessionExpired={() => (location.href = '/login')}>
      {children}
    </AuthProvider>
  )
}
// app/(dashboard)/profile.tsx
'use client'
import { useAuth, useSession } from '@bymax-one/rust-auth/react'

export function Profile() {
  const { user, status } = useSession()
  const { logout } = useAuth()

  if (status === 'loading') return <div>Loading…</div>
  if (status === 'unauthenticated') return <div>Please log in</div>

  return (
    <div>
      <p>Welcome, {user.name}!</p>
      <button onClick={() => logout()}>Sign out</button>
    </div>
  )
}

7. Frontend integration (Next.js 16, edge JWT via WASM)

Mount the auth proxy at the project root and expose the /api/auth/* route handlers. The proxy verifies the access token at the edge using the WebAssembly build of the same HS256 verifier the backend uses — no network round-trip.

// proxy.ts — Next.js 16 Edge middleware
import { createAuthProxy } from '@bymax-one/rust-auth/nextjs'

export const { proxy } = createAuthProxy({
  publicRoutes: ['/', '/auth/login', '/auth/register'],
  protectedRoutes: [
    { pattern: '/dashboard/:path*', allowedRoles: ['admin', 'member'] },
    { pattern: '/admin/:path*', allowedRoles: ['admin'] },
  ],
  loginPath: '/auth/login',
  apiBase: process.env.API_BASE_URL!,
  jwtSecret: process.env.JWT_SECRET, // verified at the edge via WASM
  cookieNames: { access: 'access_token', refresh: 'refresh_token', hasSession: 'has_session' },
  blockedUserStatuses: ['BANNED', 'INACTIVE', 'SUSPENDED'],
})

export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }

Important

The WASM module and verifyJwtToken are server/edge only — never import them into a Client Component. The HS256 secret must never reach the browser; an accidental client import is a security defect (a server-only guard catches it at build time).

// app/api/auth/logout/route.ts
import { createLogoutHandler } from '@bymax-one/rust-auth/nextjs'

export const POST = createLogoutHandler({
  apiBase: process.env.API_BASE_URL!,
  mode: 'redirect',
  loginPath: '/auth/login',
  cookieNames: { access: 'access_token', refresh: 'refresh_token', hasSession: 'has_session' },
})

⚙️ Configuration

Everything is configured through AuthConfig. Two ready-made profiles bundle sensible choices; Default equals nest_compat_defaults().

Profile Posture
AuthConfig::nest_compat_defaults() Behavioral parity with @bymax-one/nest-auth out of the box (scrypt, email verification required, brute-force max_attempts = 5) — the Default
AuthConfig::secure_defaults() Hardened opt-in profile (Argon2id, stricter cookies) — available under the argon2 feature
Group Key options nest-compat default
jwt secret (required, ≥ 32 chars), previous_secrets, access_ttl, refresh_expires_in_days, absolute_session_lifetime_days, issuer, audience 15m, 7d, off, HS256 (pinned), both off
password active_algorithm, scrypt cost_factor / Argon2id memory_kib scrypt N=2¹⁷, r=8, p=1
token_delivery Cookie | Bearer | Both Cookie
cookies names, refresh_cookie_path, same_site, trusted_origins, resolve_domains HttpOnly, Secure, Strict, []
mfa encryption_key (32 bytes), previous_encryption_keys, issuer, totp_window, recovery_code_count
sessions enabled, default_max_sessions, max_sessions_resolver false, 5
brute_force max_attempts, window_seconds 5, 900
rate limiting AxumAuthConfig::rate_limits — per-route governor limits, pinned to the shared contract on, per-route
password_reset method (Token | Otp), otp_length, token_ttl Token, 600 s
platform enabled (requires roles.platform_hierarchy) false
invitations enabled, token_ttl false, 48 h
roles hierarchy (required), platform_hierarchy
oauth google, redirect_allowlist, *_redirect_url
controllers per-group route toggles feature-driven

Note

build() validates every cross-field invariant (secret length/entropy, role referential integrity, parameter floors, SameSite=None ⇒ Secure, trusted_origins reachable under the cookie posture, OAuth redirect allow-listing, required stores) and rejects an invalid config with a precise ConfigError.

Tip

Binding tokens to an issuer and an audience. jwt.issuer and jwt.audience are None by default. Set either and its value is stamped on every token this backend mints — dashboard, platform and MFA challenge alike — and required on every token it verifies: one carrying a different value, or none at all, is rejected. That matters with HS256, where the verifier can also sign: every service holding the secret to check a token can mint one, so audience binding is what stops a token minted for one service being replayed at another that trusts the same secret. The check sits at the single verification chokepoint, so a retired signing key does not waive it.

Two things to know before switching it on. Both backends of a shared deployment must carry the same pair, or they stop accepting each other's tokens. And enabling it invalidates the access tokens already in flight — a window of one access-token lifetime, which clients close by refreshing. An empty string reads as unconfigured rather than as "require the empty issuer".

Rotating the signing secret. jwt.previous_secrets lists secrets retired by a rotation, accepted for verification only. Without it, changing jwt.secret signs every user out the moment the new configuration rolls out and invalidates every stored recovery-code digest — those are keyed by an HMAC derived from the secret, so users lose the codes they printed and filed. With it, both keep working while tokens issued under the old secret drain, and a rotation becomes a rollout. Remove the entry once the longest-lived token signed under it has expired: every entry is a key that still opens the door. mfa.encryption_key rotates the same way, through its own list — see below.

Tip

Rotating the MFA encryption key. mfa.previous_encryption_keys lists AES-256 keys retired by a rotation of mfa.encryption_key. The stored ciphertext carries no key identifier, so without the list a change of key makes every enrolled user's TOTP secret undecryptable at once, with no way back — their authenticator simply stops matching. With it, a stored secret that opened under a retired key is re-encrypted under the current one on the next successful challenge, so the rotation drains on its own instead of requiring the retired key to stay configured forever. build() holds each entry to the same bar as the current key (base64, exactly 32 bytes, and never equal to the current key or to another entry), because a malformed one would otherwise surface at a user's first challenge rather than at boot. Drop the entry once your enrolled users have had time to authenticate at least once. nest-auth exposes the identical option as mfa.previousEncryptionKeys.

Important

The parameters that carry a control's strength are bounded by build(). mfa.totp_window must be 0..=10 (TotpWindowRange): the window counts 30-second steps on either side of now, so 2n + 1 codes are valid at once — three at 1, but 121 at 60, which makes a six-digit code a hundred times easier to guess while the configuration still reads as "MFA enabled". mfa.recovery_code_count must be 1..=50 (RecoveryCodeCountRange), because zero enrols an account with no way back if the authenticator is lost. password.scrypt.block_size must be at least 8 (ScryptBlockSize) and password.scrypt.parallelization at least 1 (ScryptParallelization): the memory cost is 128 * N * r, so a smaller block size divides the hardness that the cost-factor floor exists to guarantee — invisibly, since the bounded parameter is still intact. nest-auth enforces the identical ranges.

Important

jwt.access_expires_in must not exceed 30 days, the window a store keeps a bumped token epoch readable. The epoch is what makes a stateless access token revocable: a password reset advances it and every token stamped below it stops verifying — but only while the bumped value is still there. A longer-lived access token would outlive it, current_epoch would fall back to 0, and a token the reset revoked would verify again. build() refuses the configuration (ConfigError::AccessLifetimeExceedsEpochRetention) rather than letting it fail open, and nest-auth enforces the identical bound.

Two options are deliberately off by default, because switching either on changes behaviour for sessions and origins that already exist:

  • jwt.absolute_session_lifetime_days caps how long one login can be extended by rotation. Without it, a client refreshing every fifteen minutes keeps a session alive forever.
  • cookies.trusted_origins is required as soon as same_site is None — that is the setting where the browser sends the session cookie cross-site, so an origin has to be authorized. It is also accepted under Lax/Strict when cookies.resolve_domains is configured: those withhold the cookie cross-site, not cross-origin, so a deployment serving app.example.com and api.example.com from one .example.com cookie is same-site and the browser does send it. Without a shared cookie domain the list can never be consulted, and is still refused.

The breached-password check is opt-in for a different reason: it is the only part of the credential path that reaches the network, and a library should not start talking to a third party because it was upgraded. It rides the crate's own HttpClient seam, so a deployment supplies the transport it already has:

// Cargo.toml: bymax-auth = { version = "…", features = ["breach"] }
use bymax_auth::HibpBreachChecker;

let engine = AuthEngine::builder()
    .config(config)
    .user_repository(users)
    .redis_stores(stores)
    .breach_checker(Arc::new(HibpBreachChecker::new(http_client)))
    .build()?;

The checker fails open by contract: an unreachable corpus must never stop someone changing their password — least of all during an incident, when changing it is the urgent thing.


🏗️ Architecture

The library is a set of crates you embed in your Axum service — a framework-agnostic core wrapped by thin adapters.

┌──────────────────────────────────────────────────────────┐
│                  Your Axum Application                     │
│                                                            │
│   bymax-auth-axum   →  router · extractors · rate-limit    │
│        │                                                   │
│   bymax-auth-core   →  AuthEngine · services · 14 hooks    │
│      ╱   │   ╲                                             │
│  -crypto -jwt -types     (pure-Rust, wasm-safe)            │
│        │                                                   │
│   ┌────▼─────┐   ┌──────────────┐   ┌──────────────┐       │
│   │ User     │   │ Email        │   │ SessionStore │       │
│   │ Repository│  │ Provider     │   │ (bymax-auth- │       │
│   │ (yours)  │   │ (yours)      │   │  redis)      │       │
│   └──────────┘   └──────────────┘   └──────────────┘       │
└──────────────────────────────────────────────────────────┘
         bindings/bymax-auth-wasm  →  edge HS256 verifier (npm)

Design Principles

Principle Description
🔌 Trait-Driven Define contracts, inject implementations — works with sqlx, SeaORM, Diesel, or any store
🔒 Secure by Default scrypt/Argon2id, HttpOnly cookies, jti blacklist, brute-force, anti-enumeration — all on out of the box
🪶 Tiny Mandatory Tree A small always-compiled core; Redis, Axum, reqwest, and MFA are feature-gated, with a CI dependency budget
🦀 One Crypto, Everywhere A single pure-Rust HS256 primitive runs on the server and at the edge (WASM) — never two implementations
🌳 wasm-clean boundary bymax-auth-wasm depends only on -crypto/-jwt/-types; a CI job builds wasm32 to prove it
⚡ Fast & measured No GC and no FFI boundary; hot paths run in ns–µs while memory-hard KDFs stay tunable — tracked with Criterion benches (details)

🔐 Security Model

The security architecture is codified as a set of inviolable invariants that the quality gates exist to protect — relaxing one is treated as a vulnerability, not a feature.

JWT Token Type Discrimination

Every token carries a type claim that extractors validate before acceptance:

Token type Issued when Accepted by
dashboard Successful login or MFA challenge AuthUser
platform Platform-admin login or MFA challenge PlatformUser
mfa_challenge Login with MFA enabled (pre-verification) MFA challenge endpoint

This prevents token-type-confusion attacks. A dashboard token presented to a platform route is rejected with PlatformAuthRequired (not the generic TokenInvalid), so clients can distinguish wrong-context from expired.

Algorithm Pinning & Opaque Refresh

Verification pins header.alg == "HS256" before any signature math — none, RS256, ES256, and every asymmetric algorithm are rejected, and only a symmetric key exists. Refresh tokens are opaque CSPRNG strings, inert without their server-side Redis record, persisted only as sha256(token) — never signed or parsed as JWTs.

Separate Auth Contexts for Multi-Tenant SaaS

Platform admins and tenant users are fully isolated — separate repositories, claims, extractors, and routes. tenant_id is always taken from the configured TenantIdResolver, never the request body, preventing tenant spoofing at the architecture level.

Atomic State on Shared Redis

Every read→decide→write on contended state — refresh rotation + grace, ownership-checked revoke, brute-force increment, OTP verify+consume, single-use WebSocket ticket — runs as a single atomic Lua script, closing the concurrency races those flows would otherwise expose.

Security Checklist

When integrating bymax-auth in production, verify each of the following:

  • cookies.resolve_domains validates against an allow-list — the Host header is never used to derive a parent domain
  • oauth.redirect_allowlist is set so no redirect/callback URL is request-derived (no open redirect)
  • The MFA encryption_key is 32 bytes from a secret manager; the JWT secret is 32 random bytes, high-entropy
  • HS256 pinning, constant-time comparisons (subtle), and CSPRNG (OsRng) are never bypassed
  • HttpOnly + Secure (outside development); refresh cookie path-scoped with SameSite=Strict

🛡️ Security Table

Layer Implementation
Password Hashing RustCrypto scrypt (N=2¹⁷, r=8, p=1 — OWASP's recommended minimum) or argon2 Argon2id (PHC)
MFA Encryption aes-gcm AES-256-GCM with a fresh 12-byte CSPRNG IV per call
TOTP hmac + sha1 per RFC 4226/6238, ±1 step window, anti-replay marked
Recovery Codes Keyed HMAC-SHA-256 digests (never plaintext, never reversible)
Token Generation getrandom/OsRng CSPRNG — 256 bits of entropy
Secret Comparison subtle constant-time — never == on secret bytes
JWT Hand-rolled HS256 (hmac + sha2), jti blacklist via Redis
Cookies HttpOnly, Secure-by-default, SameSite=Strict, path-scoped refresh
Brute-Force Redis atomic fixed-window counters per HMAC(tenant:email)
CSRF (OAuth) 64-hex single-use state (GETDEL) + PKCE code_verifier (S256)
Refresh Rotation Single-use tokens with a grace window; a replay past it revokes that login's whole family lineage
Cross-Site Writes Origin / Sec-Fetch-Site check on cookie-authenticated writes — the gap SameSite=None leaves open
Breached Passwords Optional Have I Been Pwned range check by k-anonymity; only a 5-char SHA-1 prefix leaves the process
Rate Limiting Per-route tower_governor layer, in-process; the limits themselves are pinned to the shared contract
Session Lifetime Optional absolute cap on how long one login can be extended by rotation
Edge Verify Same HS256 primitive compiled to WebAssembly — no network call

Important

All cryptography uses RustCrypto — no ring, no OpenSSL, no C/C++ bindings on any target — and every first-party crate is #![forbid(unsafe_code)] (the sole exception being the wasm-bindgen glue in the edge binding).


🧱 Tech Stack

Rust Axum RustCrypto WebAssembly Redis React Next.js TypeScript


⚡ Performance & Footprint

Rust's advantage over a managed runtime (Node, the JVM) is per-operation cost with no garbage collector and no FFI marshalling. bymax-auth is built to spend that advantage deliberately — and to prove it with benchmarks rather than assert it.

Built to be cheap where it should be

Lever What it buys
No runtime, no GC Auth work is synchronous CPU over owned bytes — no allocator pauses, no event-loop hops, predictable tail latency under load
Pure-Rust RustCrypto Every primitive is inlined Rust — no C bindings and no per-call FFI boundary to cross (the cost a native Node addon pays on every call)
Static where it's hot Internal services are monomorphized; dynamic dispatch (Arc<dyn _>) is paid only at the host-pluggable trait boundary, never in the inner loop
Allocation-aware Digests return a stack [u8; 32]; fixed-size randomness uses a stack [u8; N] instead of a Vec; transient key material lives in Zeroizing buffers wiped on drop
Pay for what you use A bare build is a tiny core — Redis, Axum, OAuth, and MFA are feature-gated behind a CI dependency budget, so unused capabilities cost zero binary and zero attack surface
Tiny edge The Next.js middleware verifies JWTs through a wasm-bindgen module — the same HS256 code as the server, with no network round-trip

Memory-hard by design — not by accident

Password hashing is deliberately expensive: scrypt and Argon2id are memory-hard so an attacker who exfiltrates the hash store cannot brute-force it cheaply. Their cost is a security knob (cost_factor / memory_kib), not a bottleneck — and it is the one operation the engine hands to tokio::task::spawn_blocking, so a burst of logins never stalls the async runtime. Everything around the KDF stays in the nanosecond–microsecond range.

Measured, not asserted

Tracked with Criterion so a regression surfaces as a number — the same discipline applied to the coverage gate.

Primitive (per call) Median Role
SHA-256 (mac::sha256) ~110 ns high-entropy token → Redis key suffix
Keyed HMAC-SHA-256 ~430 ns low-entropy identifier / recovery-code hashing
Secure token (32 B → hex) ~870 ns dominated by the OS CSPRNG syscall, not allocation
AES-256-GCM encrypt / decrypt ~2.1 µs / ~1.3 µs TOTP secret encrypted at rest
TOTP generate / verify (±1 window) ~200 ns / ~710 ns RFC 6238, constant-time
scrypt hash / verify (N=2¹⁷) ~150 ms memory-hard — tunable security cost, at OWASP's recommended minimum
Argon2id hash / verify (19 MiB) ~10 ms memory-hard — tunable security cost

Indicative medians on an Apple M4 Max, release profile, Rust 1.96. Reproduce with cargo bench -p bymax-auth-crypto --bench crypto --all-features. Absolute figures are hardware-dependent — the point is the order of magnitude and that the numbers are tracked, not hand-waved.

Note

The cheap operations cost nanoseconds to microseconds; the only deliberately slow step is the memory-hard KDF, which is a security control you dial in — not a hot loop. Optimisation is a standing project premise, but it never outranks a constant-time, zeroize, or no-oracle guarantee.


🧪 Testing & Quality

Authentication is critical infrastructure, so the suite is held to a bar beyond "it compiles" — every behavior is pinned so a regression fails a test.

  • 100% line and function coverage — 19,598 lines and 2,309 functions, enforced as a release gate via cargo-llvm-cov --fail-under-lines 100 across the full cargo-hack feature matrix. Regions — finer than lines, one per branch inside an expression — sit at 96.71% and are deliberately not a gate
  • Mutation gatecargo-mutants seeds faults into the source and the suite must catch them. The last full sweep (9 h, 2026-07-28): 1,652 mutants — 1,269 caught, 89 detected by timeout, 293 unviable, 1 survivor, which is closed on the branch that follows it. The timeouts are all container-backed Redis stores, where a mutation is detected by the test hanging rather than failing: real detection, but weaker than an assertion, so the two are reported apart rather than summed into one number. Mutants no test can kill are recorded in .cargo/mutants.toml with the reason each is equivalent. The sweep runs post-merge on main, never on a PR — it takes ~9 hours
  • Property tests + fuzzingproptest round-trips and cargo-fuzz smoke runs over the trust-boundary parsers (JWT, PHC, base32)
  • Real-Redis E2E — atomic Lua, rotation/grace, and revocation proven against redis:8 via testcontainers
  • Edge paritywasm-bindgen-test confirms the WASM verifier accepts a token signed by the backend
  • Supply chaincargo-deny (advisories/licenses/bans/sources), cargo-vet, a dependency budget, and cargo-public-api + cargo-semver-checks on every PR
cargo test --workspace --all-features      # unit + integration
cargo llvm-cov --workspace --all-features  # 100% line/region gate
cargo mutants                              # mutation testing
cargo deny check                           # supply-chain policy
wasm-pack test --node bindings/bymax-auth-wasm

Note

Line coverage proves a line executed; mutation testing proves a test would fail if that line were wrong. Both run in CI, alongside a wasm32-unknown-unknown build that guarantees the edge crate stays free of server dependencies.


📖 API Reference

HTTP Endpoints

Route groups mount only when their feature and runtime toggle are enabled, so an unconfigured group contributes zero routes. Paths are shown under the default auth prefix.

Method Path Guard / Auth Description
POST /auth/register Public Register a dashboard user and issue tokens
POST /auth/login Public Authenticate (may return an MFA challenge)
POST /auth/logout AuthUser Revoke the access jti and the refresh session
POST /auth/refresh Public (refresh cookie) Rotate the refresh token, issue a new access token
GET /auth/me AuthUser Current dashboard user
POST /auth/verify-email Public Verify email with an OTP
POST /auth/resend-verification Public Resend the email-verification OTP
POST /auth/password/forgot-password Public Request a password reset (token or OTP)
POST /auth/password/reset-password Public Submit a new password
POST /auth/password/verify-otp Public Verify a password-reset OTP
POST /auth/password/resend-otp Public Resend the password-reset OTP
POST /auth/password/change AuthUser + UserStatus Change the password, proving the current one
POST /auth/mfa/setup AuthUser Generate the TOTP secret + recovery codes
POST /auth/mfa/verify-enable AuthUser Confirm setup and enable MFA
POST /auth/mfa/challenge Public (MFA temp token) Submit a TOTP / recovery code after login
POST /auth/mfa/disable AuthUser Disable MFA
POST /auth/mfa/recovery-codes AuthUser Regenerate recovery codes (TOTP-gated)
GET /auth/sessions AuthUser, UserStatus List active sessions
DELETE /auth/sessions/all AuthUser, UserStatus Revoke all sessions
DELETE /auth/sessions/:id AuthUser, UserStatus Revoke a specific session (ownership-checked)
POST /auth/invitations AuthUser Create a tenant invitation
POST /auth/invitations/accept Public Accept an invitation and create the user
POST /auth/invitations/revoke AuthUser Withdraw a pending invitation
POST /auth/email/change AuthUser Request an address change (re-proves the password)
POST /auth/email/change/confirm Public Confirm it with the token sent to the new address
POST /auth/platform/login Public Platform-admin login (separate context)
POST /auth/platform/mfa/challenge Public Platform-admin MFA challenge
GET /auth/platform/me PlatformUser Current platform admin
POST /auth/platform/logout PlatformUser Revoke platform tokens
POST /auth/platform/refresh Public (platform refresh cookie) Rotate the platform refresh token
DELETE /auth/platform/sessions PlatformUser Revoke all platform sessions
GET /auth/oauth/:provider Public Initiate the OAuth authorize redirect
GET /auth/oauth/:provider/callback Public Handle the callback, exchange the code, issue tokens
POST /auth/ws-ticket AuthUser, UserStatus, MfaSatisfied Mint a single-use WebSocket upgrade ticket

GET /auth/oauth/:provider plants an HttpOnly oauth_state cookie carrying the flow's state, and the callback refuses any request that does not send it back — the binding RFC 6749 §10.12 requires, without which an attacker can hand a victim a callback URL and have the victim's browser complete the attacker's login. The router's cookie layer handles this; a custom mount must keep CookieManagerLayer in place.

Extractors (Axum FromRequestParts)

Extractor Purpose
AuthUser Validates the dashboard JWT (cookie or Authorization header)
OptionalAuthUser Routes that differ for anonymous vs. authenticated users
RequireRole<R> Hierarchical role check
PlatformUser Platform-admin JWT validation
RequirePlatformRole<R> Platform role hierarchy enforcement
CurrentUser Extracts the validated claims
SelfOrAdmin Ownership-or-admin access
UserStatus Blocks inactive/banned users (Redis-cached status)
MfaSatisfied Enforces a completed MFA verification on the request

React Hooks

Hook Returns
useSession() { user, status, isLoading, refresh(), lastValidation } — session state + revalidate
useAuth() { login(), logout(), register(), forgotPassword(), resetPassword() } — auth actions
useAuthStatus() { isAuthenticated, isLoading } — derived state

Next.js Factories

Factory Type Purpose
createAuthProxy() Proxy config Auth-aware edge proxy for proxy.ts
createSilentRefreshHandler() GET handler iframe-based token refresh
createClientRefreshHandler() POST handler Client-triggered token refresh
createLogoutHandler() POST handler Clear tokens and session
verifyJwtToken() Edge helper WASM-backed HS256 verification (server/edge only)

🗺️ Roadmap

The items below are on deck for future releases. None ship today — the list exists so contributors can see where the workspace is headed and where help is most useful. Open an issue to discuss priorities or propose a design.

Area Item Status
Registry publishing OIDC trusted publishing, the release workflow, SBOM/attestation publishing, and the tag ↔ version gate (P12's second half) Deferred
OAuth providers A provider trait implementation set beyond Google — GitHub, Microsoft, Apple — behind the existing OAuthProvider seam Planned
Error-message i18n Locale presets for AuthError's user-facing messages (defaults are English) Planned
Passwordless / magic link Single-use link flow reusing generate_secure_token and the EmailProvider seam Exploring
Passkeys / WebAuthn WebAuthn as an MFA method, and eventually a first factor, behind its own feature Exploring
Per-tenant configuration Per-tenant overrides for session limits, MFA enforcement, and password policy, resolved per request Exploring
Pluggable password policy A PasswordPolicy seam for complexity classes and per-tenant rules (the breach check already ships as PasswordBreachChecker) Planned
Additional adapters An actix-web adapter alongside bymax-auth-axum, sharing the same engine and wire contract Exploring

Track progress and discuss proposals on the issues board. Phase-level status for the work already delivered lives in docs/development_plan.md.


🤝 Contributing

Contributions are welcome! Please read the contributing guidelines before opening a pull request.

# Clone the repository
git clone https://github.com/bymaxone/rust-auth.git
cd rust-auth

# Build the workspace
cargo build --workspace --all-features

# Run the test suite
cargo test --workspace --all-features

# Lint + format
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo fmt --all -- --check

# Frontend package
cd packages/rust-auth && npm ci && npm run build:wasm && npm run build

Runnable apps live under examples/ (three Axum services, a React + Vite SPA, a Next.js edge app, and a dogfood integration). The release and publishing process — and the one-time OIDC / protected-environment setup it requires — is documented in docs/RELEASE.md.


🔒 Security Policy

If you discover a security vulnerability, please do not open a public issue. Email support@bymax.one with the details. We take security seriously and will respond promptly. See SECURITY.md.


📄 License

MIT © Bymax One


Built with ❤️ and 🦀 by Bymax One

About

Full-stack authentication library for Rust (Axum 0.8), React 19 & Next.js 16 — JWT, MFA, OAuth, sessions, multi-tenant SaaS ready. Pure-Rust crypto with WebAssembly edge verification.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages