Skip to content

feat(auth): wire JWT middleware alongside X-User-Id shim (Phase 1.d.1-PR3)#14

Merged
InstaZDLL merged 2 commits into
mainfrom
feat/1-d-1-jwt-middleware
May 30, 2026
Merged

feat(auth): wire JWT middleware alongside X-User-Id shim (Phase 1.d.1-PR3)#14
InstaZDLL merged 2 commits into
mainfrom
feat/1-d-1-jwt-middleware

Conversation

@InstaZDLL
Copy link
Copy Markdown
Owner

@InstaZDLL InstaZDLL commented May 30, 2026

Summary

Final sub-PR of Phase 1.d.1. The JWT verifier from #12 now sits in the request path: every /api/v1/profiles/* (and its nested resources) goes through a single authenticate middleware that tries Bearer JWT first, falls back to the dev X-User-Id shim if configured, and short-circuits to 503 when neither path is wired.

The dev shim stays alive on purpose for the 1.d.2 transition window so existing tests + dev workflows keep working while Better Auth gets stood up. 1.d.2 deletes the shim branch entirely.

Auth resolution matrix

dev_auth_enabled jwt_verifier Authorization header Result
None 503 (boot-time prod gate)
None shim (legacy)
Some Bearer (valid) JWT → 200
Some Bearer (invalid) 401
Some none 401 (verifier configured, request not authenticated)
Some Bearer (valid) JWT → 200
Some Bearer (invalid) 401 (no downgrade to shim) ⬅ critical
Some none, X-User-Id valid shim → 200

The "no downgrade" row is the load-bearing security property — an invalid Bearer alongside a forged X-User-Id MUST 401, otherwise an attacker could downgrade auth.

Changes

Config (src/config.rs)

  • New optional triple: WAVEFLOW_JWT_JWKS_URL / _ISSUER / _AUDIENCE. All three or none — a partial config fails boot fast rather than silently building a verifier that rejects every token.
  • Config::has_jwt_config() + auth_disabled_at_boot() helpers feed both the main.rs boot path and the api/mod.rs router wiring.

State (src/lib.rs + src/main.rs)

  • AppState gains jwt_verifier: Option<Arc<JwtVerifier>> and dev_auth_enabled: bool. Verifier built at boot — bad JWKS URL fails startup, not the first request.
  • AppState drops Debug since JwtVerifier opts out to keep raw key bytes off log sinks.
  • Boot loudly warns when auth_disabled_at_boot() — operators see misconfigured deploys without probing.

Middleware (src/middleware.rs)

  • New authenticate(State<AppState>, …):
    • JWT path: verify_bearerfind_by_external_id → attach UserId
    • JwksFetchFailed / EmptyJwks → 503; other AuthError → 401
    • Unknown sub → 401 (not 404, hides which sub values exist on the box)
  • Legacy require_user_id stays for now, factored to share parse_x_user_id_header with authenticate.

API wiring (src/api/mod.rs)

  • The per-resource fork between require_user_id and reject_dev_auth_disabled is replaced by a single middleware::from_fn_with_state(state, authenticate) layer shared across profiles / libraries / tracks / playlists.
  • /api/v1/users keeps the shim-gated open/503 toggle — JWT doesn't gate user creation since prod onboarding happens at Better Auth.

DB helper (src/db.rs)

  • New users::find_by_external_id(pool, external_id) — single indexed lookup, returns Option<i64>.

Tests

  • New tests/jwks_harness.rs — shared mock JWKS + signed-token factory. Hosted at top level via mod jwks_harness; from consumers; Cargo compiles it as an empty test binary too, hence #![allow(dead_code)].
  • New tests/jwt_middleware.rs8 integration tests:
    • valid_bearer_authenticates_request — full round-trip + proves the same user_id underlies Bearer + shim
    • missing_bearer_with_jwt_only_returns_401
    • bearer_with_unknown_sub_returns_401 — verifier passes, lookup misses
    • bearer_with_bad_signature_returns_401 — sign with harness A, verify against harness B
    • bearer_with_no_kid_returns_401
    • bearer_with_wrong_scheme_returns_401
    • invalid_bearer_does_not_downgrade_to_shimthe critical security property
    • no_auth_configured_returns_503
  • tests/support.rs gains SpawnOptions builder + spawn_app_with_jwt / spawn_app_with_jwt_and_shim wrappers.

Deps

No new runtime deps. No new dev deps. Everything reuses what landed in PR2.

What this is NOT

  • ❌ Better Auth itself — that runs separately (probably alongside waveflow-web, see 1.c).
  • ❌ Deletion of the X-User-Id shim — 1.d.2 retires it once Better Auth is deployed and the operator flips the kill switch.

Test plan

  • cargo check --all-targets
  • cargo fmt --all --check
  • cargo clippy --all-targets -- -D warnings
  • cargo test --all (CI runs against the Postgres service container; local check passed but no Postgres so the JWT middleware tests couldn't run locally)

Closes Phase 1.d.1. Refs: #11, #12, RFC-001 §6.6.

Summary by CodeRabbit

  • New Features

    • Authentification par jetons JWT (Bearer) pour les endpoints API sécurisés.
  • Refactor

    • Unification de la couche de contrôle d’accès pour les ressources protégées.
    • Renforcement du comportement d’amorçage lié aux modes d’authentification (validation complète de la config JWT, message d’avertissement/503 si aucune méthode active).
  • Tests

    • Ajout de tests et d’un harnais JWKS pour couvrir les scénarios JWT et shim dev.

Review Change Stack

…-PR3)

Final sub-PR of Phase 1.d.1. The JWT verifier from PR2 now sits in
the request path: every `/api/v1/profiles/*` (and its nested
resources) goes through a single `authenticate` middleware that
tries Bearer JWT first, falls back to the dev X-User-Id shim if
configured, and short-circuits to 503 when neither path is wired.

The dev shim stays alive on purpose for the 1.d.2 transition window
— so existing integration tests + dev workflows keep working while
Better Auth gets stood up. Phase 1.d.2 deletes the shim branch
entirely.

Config (src/config.rs):
- New optional triple: WAVEFLOW_JWT_JWKS_URL / _ISSUER / _AUDIENCE.
  All three or none — a partial config fails boot fast rather than
  silently building a verifier that rejects every token.
- Config::has_jwt_config() + auth_disabled_at_boot() helpers feed
  both the main.rs boot path and the api/mod.rs router wiring.

State (src/lib.rs + src/main.rs):
- AppState gains `jwt_verifier: Option<Arc<JwtVerifier>>` and
  `dev_auth_enabled: bool`. The verifier is built once at boot
  (bad JWKS URL fails startup, not the first request).
- AppState drops `Debug` since JwtVerifier opts out of Debug to
  keep raw key bytes off log sinks.
- Boot loudly warns when auth_disabled_at_boot() — operators who
  flipped a wrong env var see the issue without having to probe.

Middleware (src/middleware.rs):
- New `authenticate(State<AppState>, …)` middleware:
  - 503 when neither path is configured (production gate)
  - JWT-first when verifier present + Authorization header present
  - Shim fallback when dev_auth_enabled and no Bearer
  - 401 when JWT configured but the request has no Authorization
- JwksFetchFailed / EmptyJwks → 503; every other AuthError → 401.
  Response body stays opaque; the discriminated reason lands in
  tracing::warn so operators can correlate 401 spikes with bad
  client config.
- `sub` is resolved via db::users::find_by_external_id — token
  signed for an unknown sub → 401 (not 404), hides which sub
  values exist on the box.
- The legacy `require_user_id` middleware stays for back-compat
  but factored into a shared `parse_x_user_id_header` helper that
  `authenticate` reuses.

API wiring (src/api/mod.rs):
- The per-resource fork between require_user_id and
  reject_dev_auth_disabled is replaced by a single
  `middleware::from_fn_with_state(state, authenticate)` layer
  shared across profiles / libraries / tracks / playlists.
- `/api/v1/users` keeps the shim-gated open / 503 toggle — the
  JWT path doesn't gate user creation because production onboarding
  happens at Better Auth, not at this endpoint.

DB helper (src/db.rs):
- New `users::find_by_external_id(pool, external_id)` — single
  indexed lookup (UNIQUE constraint on external_id doubles as
  the index). Returns Option<i64> so the caller can map a miss to
  401.

Tests:
- New tests/jwks_harness.rs — shared mock JWKS + signed-token
  factory used by both tests/auth.rs (already inlined) and the
  new tests/jwt_middleware.rs. The harness lives at the top level
  so each consumer pulls it in via `mod jwks_harness;` — Cargo
  compiles it as an empty test binary too, hence the
  `#![allow(dead_code)]`.
- New tests/jwt_middleware.rs — 7 integration tests:
  - valid Bearer authenticates (and the same user_id underlies
    Bearer + shim, proving they target the same row)
  - missing Bearer with JWT-only mode → 401
  - Bearer with unknown sub → 401 (verifier passes, lookup misses)
  - Bearer with bad signature → 401 (two harnesses, sign with A,
    verify against B)
  - Bearer without kid header → 401
  - Bearer with wrong scheme (Basic) → 401
  - Invalid Bearer + valid X-User-Id together → 401 (proves the
    attacker can't downgrade auth by sending both headers)
  - No auth configured → 503 (generalised prod-gate)
- tests/support.rs gains SpawnOptions + spawn_app_with_jwt /
  spawn_app_with_jwt_and_shim wrappers so future test files can
  pick the auth mode they need.

Deps:
- No new runtime deps (jsonwebtoken + reqwest already landed in
  PR2).
- No new dev deps (the JWKS harness reuses PR2's rsa / rand /
  base64).

What this is NOT:
- Better Auth itself — that runs separately (probably alongside
  waveflow-web), this PR just speaks JWT at it.
- Deletion of the X-User-Id shim — 1.d.2 ships that once Better
  Auth is deployed and the operator flips the kill switch.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bd1a272b-dec4-42a3-af98-e1764acf0bf6

📥 Commits

Reviewing files that changed from the base of the PR and between d34f78c and 706bd61.

📒 Files selected for processing (1)
  • src/middleware.rs

📝 Walkthrough

Walkthrough

JWT Bearer authentication middleware unified across protected routes. Configuration validates JWT parameters (all-or-none contract), initializes JwtVerifier from JWKS during startup, middleware gates all /api/v1/* endpoints (503 if auth disabled, JWT verification with dev-shim fallback), and user resolution via external_id database lookup integrated throughout.

Changes

JWT Authentication & Middleware

Layer / File(s) Summary
Configuration & AppState Extension
src/config.rs, src/lib.rs, src/main.rs (imports)
Configuration struct gains jwt_jwks_url, jwt_issuer, jwt_audience fields with all-or-none environment validation; Config::auth_disabled_at_boot() and has_jwt_config() predicates added. AppState extended with Option<Arc<JwtVerifier>> and dev_auth_enabled boolean. Debug derive removed from AppState.
JWT Verifier Initialization
src/main.rs (startup logic)
Constructs JwtVerifier from JWT config, wraps in Arc, injects into AppState during app startup. Explicit warn! emitted when auth fully disabled (503 fallback behavior documented).
Unified Authentication Middleware
src/middleware.rs
New public authenticate middleware gates /api/v1/*: returns 503 if no auth configured, attempts JWT Bearer verification (JWKS errors → 503, invalid signature/missing kid/wrong sub → 401), falls back to X-User-Id shim if dev auth enabled. Resolves JWT sub to users.id via new db::users::find_by_external_id query; injects UserId into request extensions.
User Resolution by External ID
src/db.rs
Adds users::find_by_external_id(pool, external_id) returning Option<i64> for JWT sub-to-user-id mapping via parameterized SELECT query.
Route Protection & API Refactor
src/api/mod.rs
Applies unified auth_layer via middleware::from_fn_with_state to all tenant-scoped sub-routers (profiles, libraries, tracks, playlists); replaces previous per-resource conditional middleware branching. /api/v1/users remains gated by dev_auth_enabled directly.
Mock JWKS Test Harness
tests/jwks_harness.rs
New test utility: spawns ephemeral HTTP server exposing /.well-known/jwks.json with RSA-2048 keys, tracks JWKS fetch count, provides JwtVerifier configured for JWKS consumption and mint() helper for test token signing with valid iat/nbf/exp claims.
Test App Spawning Refactor
tests/support.rs
Centralizes app startup: new SpawnOptions struct drives spawn_app_with(pool, opts) factory. Exposes spawn_app_with_jwt (JWT-only) and spawn_app_with_jwt_and_shim (JWT + dev fallback) alongside updated spawn_app and spawn_app_prod_gate.
JWT Middleware Test Suite
tests/jwt_middleware.rs
Ten tests covering: valid Bearer JWT (profile CRUD via Bearer and shim comparison), missing Bearer with JWT-only (401), unknown sub (401), bad signature (401), missing kid header (401), non-Bearer scheme (401), invalid Bearer refusing shim downgrade (401 precedence), and auth-disabled gate returning 503 before parsing.

Sequence Diagram

sequenceDiagram
  participant Client
  participant Router
  participant authenticate as Middleware authenticate
  participant JwtVerifier
  participant Database
  participant Handler

  Client->>Router: GET /api/v1/profiles<br/>Authorization: Bearer JWT
  Router->>authenticate: invoke middleware
  
  alt auth_disabled_at_boot
    authenticate-->>Client: 503 Service Unavailable
  else has Authorization header
    authenticate->>JwtVerifier: verify(Bearer JWT)
    alt JWKS fetch/parse error
      JwtVerifier-->>authenticate: AuthError::JwksUnavailable
      authenticate-->>Client: 503 Service Unavailable
    else signature/kid/exp invalid
      JwtVerifier-->>authenticate: AuthError (signature/format)
      authenticate-->>Client: 401 Unauthorized
    else valid JWT
      JwtVerifier-->>authenticate: sub claim
      authenticate->>Database: find_by_external_id(sub)
      alt user not found
        Database-->>authenticate: None
        authenticate-->>Client: 401 Unauthorized
      else user found
        Database-->>authenticate: Some(user_id)
        authenticate->>Handler: extensions.insert(UserId)
        Handler-->>Client: 200 + response
      end
    end
  else no Authorization, dev_auth enabled
    authenticate->>authenticate: parse X-User-Id header
    authenticate->>Handler: extensions.insert(UserId)
    Handler-->>Client: 200 + response
  else no Authorization, dev_auth disabled
    authenticate-->>Client: 401 Unauthorized
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

  • InstaZDLL/waveflow-server#12: Ajoute auth::JwtVerifier / JwtVerifierConfig réutilisé par cette PR pour la vérification Bearer/JWKS.
  • InstaZDLL/waveflow-server#11: Introduit users.external_id que find_by_external_id résout pour mapper sub → utilisateur interne.
  • InstaZDLL/waveflow-server#6: Introduit le shim X-User-Id / require_user_id que cette PR intègre comme fallback dans la nouvelle middleware authenticate.

Poem

🔐 Des JWT venus du ciel, des JWKS qui brillent clair,
Une middleware qui trie, 503 ou 401 à faire,
Profiles et playlists gardés par le même gardien,
Token validé, sub→user trouvé, la requête passe enfin,
Tests et serveur factices font danser la clé demain.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Le titre décrit précisément la fonctionnalité principale : l'intégration du middleware JWT aux côtés du shim X-User-Id, avec suffisamment de contexte (Phase 1.d.1-PR3) pour situer le changement dans sa roadmap.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/1-d-1-jwt-middleware

Comment @coderabbitai help to get the list of available commands and usage tips.

@InstaZDLL InstaZDLL self-assigned this May 30, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/middleware.rs (1)

47-68: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

require_user_id duplique la logique au lieu de réutiliser parse_x_user_id_header.

Le commentaire (lignes 198-200) indique que require_user_id et authenticate partagent parse_x_user_id_header, mais require_user_id conserve sa propre implémentation inline. Si Phase 1.d.2 supprime le shim, ce n'est pas bloquant, mais la refacto promise n'est pas complète.

♻️ Refacto possible
 pub async fn require_user_id(mut request: Request, next: Next) -> Result<Response, StatusCode> {
-    let value: &HeaderValue = request
-        .headers()
-        .get(X_USER_ID_HEADER)
-        .ok_or(StatusCode::UNAUTHORIZED)?;
-
-    let user_id: i64 = value
-        .to_str()
-        .ok()
-        .and_then(|s| s.parse().ok())
-        .ok_or(StatusCode::UNAUTHORIZED)?;
-
-    // `0` and negative ids are reserved (0 is the desktop sentinel,
-    // negatives are conventionally invalid) — reject so a stray
-    // header from a confused dev client can't sneak through.
-    if user_id <= 0 {
-        return Err(StatusCode::UNAUTHORIZED);
-    }
-
-    request.extensions_mut().insert(UserId(user_id));
+    let user_id = parse_x_user_id_header(request.headers())?;
+    request.extensions_mut().insert(UserId(user_id));
     Ok(next.run(request).await)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/middleware.rs` around lines 47 - 68, require_user_id duplicates
header-parsing logic; replace the inline parsing in require_user_id with a call
to the shared helper parse_x_user_id_header (used by authenticate) so the
parsing/validation is centralized; call parse_x_user_id_header to obtain the i64
user id, keep the non-positive-id check (user_id <= 0) if that logic isn't
already in the helper (or move it into the helper), insert UserId(user_id) into
request.extensions_mut(), and then call next.run(request).await so behavior is
unchanged while removing the duplicated parsing code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/middleware.rs`:
- Around line 47-68: require_user_id duplicates header-parsing logic; replace
the inline parsing in require_user_id with a call to the shared helper
parse_x_user_id_header (used by authenticate) so the parsing/validation is
centralized; call parse_x_user_id_header to obtain the i64 user id, keep the
non-positive-id check (user_id <= 0) if that logic isn't already in the helper
(or move it into the helper), insert UserId(user_id) into
request.extensions_mut(), and then call next.run(request).await so behavior is
unchanged while removing the duplicated parsing code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 4bcfb30b-dee3-4ebe-92ea-395cc52f9d1a

📥 Commits

Reviewing files that changed from the base of the PR and between 6bb3aac and d34f78c.

📒 Files selected for processing (9)
  • src/api/mod.rs
  • src/config.rs
  • src/db.rs
  • src/lib.rs
  • src/main.rs
  • src/middleware.rs
  • tests/jwks_harness.rs
  • tests/jwt_middleware.rs
  • tests/support.rs

PR3's commit message said the legacy `require_user_id` middleware
was "factored into a shared `parse_x_user_id_header` helper that
`authenticate` reuses" — but the refactor on the require_user_id
side was forgotten, leaving the inline `header parse → i64 > 0
check → 401` triple duplicated.

Replace the inline body with a delegation to the same helper
`authenticate` calls. Single source of truth for the shim's
contract: when 1.d.2 retires the X-User-Id path, deleting
`parse_x_user_id_header` is enough to take both call sites with it.

No behaviour change: the helper carries the identical `parse` →
`<= 0` → 401 sequence the inline version did.

`require_user_id` itself is now only referenced from doc-comments
in `api/profiles.rs` and `api/libraries.rs` — none of the live
router branches use it after PR3 wired everything through
`authenticate`. Keeping it `pub` for the 1.d.2 sweep instead of
deleting in two passes.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
@InstaZDLL
Copy link
Copy Markdown
Owner Author

@coderabbitai bien vu, oversight de ma part. Le commit message de #14 promettait la refacto mais je n'avais factorisé que côté authenticate. Appliqué dans 706bd61 :

```rust
pub async fn require_user_id(mut request: Request, next: Next) -> Result<Response, StatusCode> {
let user_id = parse_x_user_id_header(request.headers())?;
request.extensions_mut().insert(UserId(user_id));
Ok(next.run(request).await)
}
```

Le helper porte déjà le check <= 0 + le mapping 401 — pas de behavior change. Single source of truth pour le shim contract : quand 1.d.2 retirera le X-User-Id path, supprimer `parse_x_user_id_header` emporte les deux call sites.

Note : `require_user_id` est maintenant only référencé depuis des doc-comments dans `api/profiles.rs` + `api/libraries.rs` — aucun router live ne l'utilise depuis PR3 a wire `authenticate` partout. Je le garde `pub` pour que 1.d.2 fasse la suppression en un sweep cohérent (`require_user_id` + `parse_x_user_id_header` + `X_USER_ID_HEADER` + le `dev_auth_enabled` field d'AppState), plutôt qu'en deux passes.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

Tip

For best results, initiate chat on the files or code changes.

@InstaZDLL : Nickel. La délégation vers parse_x_user_id_header est propre, aucune régression possible. Le plan Phase 1.d.2 de supprimer les quatre symboles en un seul sweep est la bonne approche — un seul commit, un seul diff à relire.

(ノ✿◕ᗜ◕)ノ

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant