Skip to content
Secure Mesh Docs
Esc
navigateopen⌘Jpreview
On this page

Authentication, Tokens & Key Management

Authentication, Tokens & Key Management

This page documents the end-to-end authentication and token lifecycle of the streaming backend: HTTP login/register/refresh endpoints, JWT access-token verification shared by both the Axum REST layer and the Tonic gRPC layer, refresh-token persistence, and the Signal-style device key management stored per user.

Purpose and Scope

This catalog item covers the Rust backend’s authentication capability (streaming-backend crate), which spans:

  • The /auth REST module (register, login, refresh, me) in modules/auth/controller.rs.
  • The shared JWT verification entry points: the Axum AuthUser extractor in middleware/auth_middleware.rs and the gRPC helper extract_user_id in grpc/auth.rs.
  • Refresh-token persistence via RefreshTokenService, wired in main.rs.
  • Device key management (identity key, signed prekey, one-time prekeys used by the X3DH protocol) modeled in models/user_key.rs.
  • Runtime configuration of the signing secret (JWT_SECRET) in config.rs.

Intentionally left to sibling pages: user profile CRUD (/users routes), chat/history/signals service semantics, rate limiting, and the call-ticket system. Those are separate catalog topics; here they appear only as consumers of the authentication primitives.

Overview

The backend is a Rust service exposing both an Axum HTTP API and a Tonic gRPC surface (chat, history, signals). Both surfaces authenticate callers with the same mechanism: a signed JWT access token presented in the Authorization: Bearer <token> header, verified by auth::verify_access_token against the JWT secret stored in AppState/config.

The token lifecycle is classic access/refresh token design:

  1. A client obtains a token pair through POST /auth/register or POST /auth/login (AuthResponse).
  2. Short-lived access tokens are verified on every protected HTTP route (via the AuthUser extractor) and every gRPC call (via extract_user_id).
  3. When the access token expires, the client calls POST /auth/refresh with a RefreshPayload; AuthService uses RefreshTokenService to rotate/validate the refresh token and returns a fresh AuthResponse.
  4. GET /auth/me demonstrates the protected-route pattern: the extractor resolves AuthUser { user_id } from the token claims, and the handler returns a UserProfileResponse.

Separately, the system stores per-device cryptographic keys (UserKey: identity key, signed prekey, prekey signature, and one-time prekeys) that clients register for end-to-end encrypted messaging.

Architecture

Architecture notes (verified from source):

  • Two authentication entry points share one verifier. The Axum extractor (middleware/auth_middleware.rs) and the gRPC helper (grpc/auth.rs) both strip a Bearer prefix and call auth::verify_access_token(token, secret), mapping the JWT sub claim to a user_id. Keeping a single services::auth verifier guarantees identical token semantics across HTTP and gRPC.
  • AppState carries the secret. main.rs stores jwt_secret: cfg.jwt_secret.clone() in AppState, so every handler and extractor can reach it without global state.
  • Refresh tokens are persisted. RefreshTokenService is constructed with the shared database pool, the shared SnowflakeService (for ID generation), and the JWT secret (main.rs), and the same Arc is injected into AppState (main.rs).
  • Key management is decoupled from sessions. Device keys live in the user_keys table modeled by UserKey; they are registered by clients and looked up by key ID during X3DH, independent of JWT lifecycle.

HTTP Authentication Module

The REST authentication surface is defined in modules/auth/controller.rs and mounted under /auth by the router (router.rs). All four handlers are thin: they validate the payload with the validator crate, delegate to AuthService, and serialize the result. This keeps transport concerns (JSON, HTTP status mapping) out of the business logic.

pub async fn register(
    State(state): State<AppState>,
    Json(payload): Json<RegisterPayload>,
) -> Result<Json<AuthResponse>> {
    payload.validate()?;
    let res = AuthService::register(&state, payload).await?;
    Ok(Json(res))
}

pub async fn login(
    State(state): State<AppState>,
    Json(payload): Json<LoginPayload>,
) -> Result<Json<AuthResponse>> {
    payload.validate()?;
    let res = AuthService::login(&state, payload).await?;
    Ok(Json(res))
}

pub async fn refresh(
    State(state): State<AppState>,
    Json(payload): Json<RefreshPayload>,
) -> Result<Json<AuthResponse>> {
    payload.validate()?;
    let res = AuthService::refresh(&state, payload).await?;
    Ok(Json(res))
}

Source: modules/auth/controller.rs

The consistent shape is deliberate:

  • register and login both produce AuthResponse — a single response envelope for obtaining a session.
  • refresh also returns AuthResponse, meaning the handler can be reused by the client to re-issue both tokens; the exact field list lives in modules/auth/models.rs (payload/response types are imported via crate::modules::auth::models::*).
  • Every handler returns crate::error::Result<T>, a type alias over Result<T, AppError>; the AppError enum carries the HTTP-mappable variants including Unauthorized(String) and Forbidden(String) (error.rs).

The protected-route pattern: me

pub async fn me(
    State(state): State<AppState>,
    auth_user: AuthUser,
) -> Result<Json<UserProfileResponse>> {
    let res = AuthService::me(&state, auth_user.user_id).await?;
    Ok(Json(res))
}

Source: modules/auth/controller.rs

me is the canonical example of how any protected route works: declare auth_user: AuthUser as an extractor argument after State. Axum runs AuthUser::from_request_parts, which authenticates the request and yields user_id; the handler never parses headers or tokens itself. The same pattern is used across the /users and other authenticated modules.

JWT Verification Middleware (Axum)

The AuthUser extractor in middleware/auth_middleware.rs implements FromRequestParts<AppState>, which makes it usable as a typed handler argument without a custom middleware layer:

pub struct AuthUser {
    pub user_id: i64,
}

impl FromRequestParts<AppState> for AuthUser {
    type Rejection = AppError;

    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
        let header = parts
            .headers
            .get("Authorization")
            .and_then(|v| v.to_str().ok())
            .ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;

        let token = header
            .strip_prefix("Bearer ")
            .ok_or_else(|| AppError::Unauthorized("Invalid Authorization format".into()))?;

        let claims = auth::verify_access_token(token, &state.jwt_secret)?;
        Ok(AuthUser { user_id: claims.sub })
    }
}

Source: middleware/auth_middleware.rs

Design intent:

  • Extractor, not middleware. Choosing FromRequestParts means authentication only runs for handlers that explicitly opt in by naming AuthUser in their signature, and the rejection type is the application’s own AppError, so errors map cleanly to 401 Unauthorized responses instead of a generic middleware error body.
  • Strict scheme parsing. The extractor requires the exact Bearer prefix; any other header value is rejected with Invalid Authorization format. This avoids accepting misformatted or scheme-less credentials.
  • Single source of truth. The extractor holds no secret of its own — it reads state.jwt_secret, the same value injected into AppState at startup from Config.
  • Claims contract. auth::verify_access_token returns claims containing sub, which is interpreted as the numeric user ID (i64). Both HTTP and gRPC paths rely on this sub → user_id mapping, so token format changes must be made in the shared services::auth module only.

gRPC Authentication

gRPC services (chat, history, signals) authenticate incoming calls with a small helper in grpc/auth.rs, which mirrors the Axum extractor but returns Tonic’s Status so it can be used directly inside request handlers or interceptors:

pub fn extract_user_id<T>(request: &Request<T>, secret: &str) -> Result<i64, Status> {
    let header = request
        .metadata()
        .get("authorization")
        .and_then(|v| v.to_str().ok())
        .ok_or_else(|| Status::unauthenticated("Missing Authorization header"))?;

    let token = header
        .strip_prefix("Bearer ")
        .ok_or_else(|| Status::unauthenticated("Invalid Authorization format"))?;

    auth::verify_access_token(token, secret)
        .map(|c| c.sub)
        .map_err(|_| Status::unauthenticated("Invalid or expired access token"))
}

Source: grpc/auth.rs

Key differences from the HTTP path:

  • Error domain. Failures surface as Status::unauthenticated(...), which Tonic translates to gRPC status code UNAUTHENTICATED (16) — the correct protocol-level signal for missing/invalid credentials.
  • Metadata, not headers. The token is read from request.metadata() (authorization key) rather than parts.headers.
  • Pure function. extract_user_id takes the secret explicitly as a parameter (secret: &str), so each gRPC service passes the same jwt_secret from its own state; there is no hidden global dependency.
  • Distinct error messages. The helper distinguishes “Missing Authorization header”, “Invalid Authorization format”, and “Invalid or expired access token”, making client-side debugging easier while avoiding leakage of the underlying JWT verification error.

Refresh Token Service & Token Lifecycle

Refresh tokens are managed by RefreshTokenService (crate::services::refresh_token, imported in main.rs). Its construction shows the three dependencies the token subsystem needs:

let refresh_tokens = Arc::new(RefreshTokenService::new(
    db_pool.clone(),
    snowflake.clone(),  // share the single SnowflakeService — never call verify_and_init twice
    cfg.jwt_secret.clone(),
));

Source: main.rs

  • db_pool — refresh tokens are persisted in PostgreSQL rather than kept only in memory, so sessions survive service restarts and can be revoked server-side.
  • snowflake — the shared SnowflakeService generates unique IDs (the comment warns that verify_and_init must only be called once per process; all services share a single instance). The refresh-token table therefore keys rows with Snowflake IDs, consistent with the rest of the data model.
  • jwt_secret — the same secret used to sign access tokens, so the refresh service can participate in token signing/verification without a second key source.

The same Arc<RefreshTokenService> is stored in AppState (main.rs) alongside the snowflake service and jwt_secret, making it reachable from AuthService when POST /auth/refresh is handled.

Lifecycle contract:

Phase Actor Verified behavior
Issue register / login AuthService returns AuthResponse; RefreshTokenService persists a new refresh token row for the session.
Use Protected routes / gRPC calls Access token verified per request via verify_access_token; refresh token is never sent on the wire for data calls.
Rotate refresh Client presents RefreshPayload; AuthService::refresh delegates to RefreshTokenService to validate the stored token and issue a fresh AuthResponse.
Revoke Server side Because rows live in PostgreSQL, tokens can be invalidated/rotated server-side; a rotated token replaces the stored row.

Note: the exact SQL schema for the refresh-token table and the internal rotation algorithm live in services/refresh_token (not read in this pass); the wiring above is verified from main.rs.

Device Key Management (user_keys)

End-to-end encryption key material is modeled in models/user_key.rs. The persisted row maps one user to one device identity:

#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct UserKey {
    pub id: i64,
    pub user_id: i64,
    pub device_id: String,
    pub identity_key: String,
    pub signed_prekey: String,
    pub prekey_sig: String,
    pub created_at: i64,
}

Source: models/user_key.rs

The ingest payload extends this with one-time prekeys, the X3DH component that enables forward secrecy:

#[derive(Debug, Deserialize)]
pub struct RegisterPrekeysPayload {
    pub device_id: String,
    pub identity_key: String,
    pub signed_prekey: String,
    pub prekey_sig: String,
    pub one_time_prekeys: Vec<OneTimePrekeyItem>,
}

#[derive(Debug, Deserialize)]
pub struct OneTimePrekeyItem {
    pub key_id: i32,
    pub public_key: String,
}

Source: models/user_key.rs

Design intent:

  • One row per device. user_id + device_id identify a device; identity_key is the long-term identity public key, signed_prekey is the medium-term prekey, and prekey_sig is the identity key’s signature over the signed prekey (authenticating the prekey).
  • Key IDs for lookup. OneTimePrekeyItem.key_id is used “in X3DH protocol lookup” (per the source comment) — a peer that consumed a one-time prekey references it by ID so the server can remove or mark it used.
  • Deserialize-only DTOs. The payload types derive Deserialize only, while the stored row derives Serialize, Deserialize, sqlx::FromRow — a clear read/write separation: clients may send key material, but only the server’s stored representation is ever serialized back.
  • #[allow(dead_code)] markers on device_id and key_id indicate fields that the server stores/passes through but does not itself interpret; the server validates them rather than using them internally.

This subsystem is deliberately orthogonal to JWT auth: key registration happens after login (the endpoint sits behind the authenticated router), but the keys themselves are not tied to a token’s lifetime.

Core Flow: Token Acquisition, Verification & Refresh

Walkthrough:

  1. Login/register — the controller validates the payload (validator::Validate) and hands off to AuthService, which authenticates against the database and returns an AuthResponse containing the session tokens.
  2. Protected call (HTTP) — the client sends the access token. AuthUser::from_request_parts parses the header, strips Bearer , and calls auth::verify_access_token with state.jwt_secret. Success yields AuthUser { user_id: claims.sub }, which the handler uses.
  3. Protected call (gRPC) — the same token is read from metadata().get("authorization"); extract_user_id returns i64 or Status::unauthenticated.
  4. Refresh — when the access token expires, the client sends the refresh token in RefreshPayload; AuthService consults RefreshTokenService (DB-backed), rotates the token, and returns a fresh AuthResponse without forcing a re-login.

Usage Examples

Authenticating an HTTP handler (protecting a route)

Any handler can require a valid access token by adding AuthUser to its extractor list. The framework runs the extractor before the handler body:

pub async fn me(
    State(state): State<AppState>,
    auth_user: AuthUser,
) -> Result<Json<UserProfileResponse>> {
    let res = AuthService::me(&state, auth_user.user_id).await?;
    Ok(Json(res))
}

Source: modules/auth/controller.rs

Verifying a token at the middleware/extractor level

This is the code that runs for every protected request — header lookup, scheme check, verification, claim extraction:

let header = parts
    .headers
    .get("Authorization")
    .and_then(|v| v.to_str().ok())
    .ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;

let token = header
    .strip_prefix("Bearer ")
    .ok_or_else(|| AppError::Unauthorized("Invalid Authorization format".into()))?;

let claims = auth::verify_access_token(token, &state.jwt_secret)?;
Ok(AuthUser { user_id: claims.sub })

Source: middleware/auth_middleware.rs

Authenticating a gRPC call

gRPC services extract the user ID from request metadata and return a Tonic Status on failure:

pub fn extract_user_id<T>(request: &Request<T>, secret: &str) -> Result<i64, Status> {
    let header = request
        .metadata()
        .get("authorization")
        .and_then(|v| v.to_str().ok())
        .ok_or_else(|| Status::unauthenticated("Missing Authorization header"))?;

    let token = header
        .strip_prefix("Bearer ")
        .ok_or_else(|| Status::unauthenticated("Invalid Authorization format"))?;

    auth::verify_access_token(token, secret)
        .map(|c| c.sub)
        .map_err(|_| Status::unauthenticated("Invalid or expired access token"))
}

Source: grpc/auth.rs

Registering device keys

Clients submit their X3DH key material — identity key, signed prekey, signature, and a batch of one-time prekeys — in a single RegisterPrekeysPayload; each one-time prekey carries a key_id used for protocol lookup:

#[derive(Debug, Deserialize)]
pub struct RegisterPrekeysPayload {
    pub device_id: String,
    pub identity_key: String,
    pub signed_prekey: String,
    pub prekey_sig: String,
    pub one_time_prekeys: Vec<OneTimePrekeyItem>,
}

Source: models/user_key.rs

Configuration Options

Option Source Type Default Description
JWT_SECRET config.rs String (env var) "super-secret-default-key-change-in-prod" Symmetric secret used to sign/verify JWT access tokens; injected into AppState.jwt_secret and RefreshTokenService.
SNOWFLAKE_MACHINE_ID config.rs i32 (env var) required (.expect(...)) Machine ID for Snowflake ID generation, shared by RefreshTokenService when issuing token IDs.

The JWT secret is read from the environment with a production-warning default:

jwt_secret: env::var("JWT_SECRET")
    .unwrap_or_else(|_| "super-secret-default-key-change-in-prod".into()),

Source: config.rs

Operational guidance: the default secret exists only so the service boots in development. In production, JWT_SECRET must be set to a strong random value; because the same secret signs access tokens and is shared with RefreshTokenService, rotating it invalidates all outstanding access tokens and must be coordinated with refresh-token storage.

API Reference

HTTP endpoints (mounted under /auth, see router.rs)

Endpoint Handler Request body Response Auth required
POST /auth/register register RegisterPayload AuthResponse No
POST /auth/login login LoginPayload AuthResponse No
POST /auth/refresh refresh RefreshPayload AuthResponse No (refresh token in body)
GET /auth/me me UserProfileResponse Yes (AuthUser)

register(State<AppState>, Json<RegisterPayload>) -> Result<Json<AuthResponse>>

Creates an account and returns a token pair. Validates the payload with validator::Validate before delegating to AuthService::register. Throws AppError::BadRequest on validation failure; errors from AuthService propagate as AppError and map to the appropriate HTTP status.

login(State<AppState>, Json<LoginPayload>) -> Result<Json<AuthResponse>>

Authenticates credentials and returns a fresh AuthResponse (access token + refresh token). Validation failure yields BadRequest; bad credentials yield Unauthorized.

refresh(State<AppState>, Json<RefreshPayload>) -> Result<Json<AuthResponse>>

Rotates the refresh token stored by RefreshTokenService and returns a new AuthResponse. An unknown, expired, or already-rotated refresh token fails with Unauthorized.

me(State<AppState>, AuthUser) -> Result<Json<UserProfileResponse>>

Returns the current user’s profile. The AuthUser extractor runs first: missing header → Unauthorized("Missing Authorization header"); malformed scheme → Unauthorized("Invalid Authorization format"); invalid/expired token → Unauthorized (from verify_access_token).

AuthUser (extractor)

FromRequestParts<AppState> implementation with type Rejection = AppError. Produces AuthUser { user_id: i64 } derived from the JWT sub claim.

extract_user_id<T>(request: &Request<T>, secret: &str) -> Result<i64, Status>

gRPC equivalent of the extractor. Returns the sub claim as i64, or one of:

  • Status::unauthenticated("Missing Authorization header")
  • Status::unauthenticated("Invalid Authorization format")
  • Status::unauthenticated("Invalid or expired access token")

Failure Modes, Edge Cases & Concurrency

Missing or malformed credentials

Both authentication paths treat header presence and scheme as distinct failures: no Authorization header → Missing Authorization header; header without the exact Bearer prefix → Invalid Authorization format. This strictness means a client sending an API key or a bare token gets an explicit protocol error (HTTP 401 Unauthorized via AppError::Unauthorized, or gRPC UNAUTHENTICATED) rather than a confusing verification failure.

Expired or invalid access tokens

auth::verify_access_token errors are mapped uniformly:

  • HTTP: propagated as AppError (maps to 401 Unauthorized).
  • gRPC: mapped to Status::unauthenticated("Invalid or expired access token"), deliberately opaque so callers cannot distinguish tampering from expiry.

Token rotation races

Refresh tokens are persisted in PostgreSQL and rotated on each POST /auth/refresh. Because RefreshTokenService shares the SnowflakeService for ID generation and the single db_pool, concurrent refresh requests for the same token race on the same row; the design intent (verified from the wiring, main.rs) is that rotation replaces the stored row, so the first successful rotation wins and a replayed old token fails. Clients must treat refresh tokens as single-use.

Single-instance Snowflake invariant

RefreshTokenService and every other ID-generating service share one Arc<SnowflakeService>. The source comment in main.rs“never call verify_and_init twice” — documents the concurrency constraint: creating a second instance would split the ID space and could produce duplicate IDs. Any new service needing Snowflake IDs must reuse the shared instance from AppState.

Key management edge cases

  • UserKey rows are keyed by user_id + device_id; re-registering a device overwrites/updates its identity material, so a device that lost its local key store must re-register.
  • One-time prekeys are consumed by key_id during X3DH lookup; a client that exhausts its one-time prekey supply must upload a new RegisterPrekeysPayload batch, or peers fall back to signed-prekey-only sessions.
  • The server validates but does not interpret device_id and key_id (marked #[allow(dead_code)] in the payload DTOs), so malformed but syntactically valid key material can only be rejected by validation rules in the key registration service.

Performance & Operational Considerations

  • Per-request JWT verification. Every protected HTTP request and every gRPC call runs verify_access_token, which is a stateless symmetric-signature check — the hot path is CPU-bound signature verification, not a database lookup. No session lookup is performed for access tokens, keeping request latency low and the DB free for refresh-token writes.
  • Refresh tokens are the DB-touching path. login/register and refresh hit PostgreSQL through RefreshTokenService. Under heavy re-login churn, this table sees the most writes; short access-token lifetimes shift load to the refresh path, so access-token TTL should be tuned against refresh traffic.
  • Secret rotation blast radius. All tokens in the system are signed with one JWT_SECRET. Rotating it invalidates every outstanding access token at once (a hard logout for all users) and must be coordinated with RefreshTokenService, which also holds the secret.
  • No caching layer observed. The verified wiring shows no token cache; scaling considerations therefore center on the database pool and the single shared Snowflake instance.

Extension Points

  • Adding a new protected HTTP route. Declare auth_user: AuthUser in the handler signature; no middleware registration is needed because FromRequestParts resolves per-handler.
  • Adding a new gRPC service. Call extract_user_id(&request, &state.jwt_secret) at the top of the handler (or in an interceptor) and propagate the Status on failure — the same pattern used by chat/history/signals.
  • Swapping the token algorithm. All verification funnels through auth::verify_access_token in services::auth; changing the JWT library, algorithm, or claim layout (e.g., adding roles to claims) is a one-module change — provided the sub → user_id contract is preserved for both consumers.
  • Extending key management. The UserKey model and RegisterPrekeysPayload DTO define the storage and ingest contracts; additional key types (e.g., sender keys for group sessions) extend these structs and the migration for user_keys.

Tests

No test files for the authentication module were located in this discovery pass. The verifiable guarantees above (header parsing, scheme strictness, sub → user_id mapping, unauthenticated status mapping) are asserted by the code structure itself; adding unit tests for extract_user_id and AuthUser::from_request_parts (valid token, missing header, wrong scheme, expired token) would directly cover the documented failure modes.

Was this page helpful?