Skip to content

feat(auth): retire x-user-id dev shim — jwt is the only auth path - #17

Merged
InstaZDLL merged 2 commits into
mainfrom
feat/1-d-2-retire-shim
May 31, 2026
Merged

feat(auth): retire x-user-id dev shim — jwt is the only auth path#17
InstaZDLL merged 2 commits into
mainfrom
feat/1-d-2-retire-shim

Conversation

@InstaZDLL

@InstaZDLL InstaZDLL commented May 31, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 1.d.2. Better Auth is deployed (waveflow-web 1.c.2c) and lazy auto-provisioning landed (#16 + waveflow-web 1.c.3b), so the legacy X-User-Id header shim has no remaining job. This commit deletes it end-to-end.

Net diff: −678 LOC.

Production code

Change Why
config: drop dev_auth_enabled + auth_disabled_at_boot. JWT triple required at boot. A partial / missing config now fails fast at startup instead of silently 503-ing every request.
AppState: jwt_verifier becomes Arc<JwtVerifier> (no Option). Reflects the boot guarantee — the middleware can't be reached without one.
middleware: drop require_user_id, parse_x_user_id_header, X_USER_ID_HEADER + the shim-fallback branch in authenticate. Single flow now: missing bearer → 401, bad token → 401, valid token → lazy-provision + UserId extension.
src/api/users.rs deleted entirely. POST /api/v1/users only existed to seed the shim. Lazy provision lands the row on first authenticated request.
api/mod.rs: drop users module + reject_dev_auth_disabled helper. Every /api/v1/* gets the single authenticate layer — no forks.
api/{libraries,playlists,profiles,tracks}.rs: openapi headers switch from x-user-id to Authorization Bearer. Spec matches reality.
db::users: drop the now-unused create helper. Only caller was the deleted POST /users handler.

Migration

  • 20260531000000_users_external_id_not_null.sqlDELETE FROM users WHERE external_id IS NULL (dev artifacts that no JWT could ever authenticate against), then ALTER COLUMN external_id SET NOT NULL. Safe because the server hasn't deployed yet — no production rows to backfill.

Tests

File Change
tests/support.rs Rebuilt around JwksHarness. spawn_app stays as a no-auth-needed helper for probe tests. New spawn_authenticated + spawn_two_authenticated bundle harness + bearer mint + lazy-provision warm-up + users.id lookup, so a test bootstrap is one line.
tests/jwt_middleware.rs Drop the *_with_shim variants + the prod-gate 503 test (the boot fail-fast covers that case now).
tests/profiles.rs / libraries.rs / tracks.rs / playlists.rs Every .header("x-user-id", …) + mint_user(POST /users) rewrote to .bearer_auth(&auth.token) + spawn_authenticated. Tenancy tests share one app + one JWKS harness for both callers.
tests/openapi.rs Assert the spec no longer advertises /api/v1/users.

Docs

  • README.md + CLAUDE.md — bullets rewritten to describe JWT-only.
  • .env.example — drop WAVEFLOW_DEV_AUTH; add the required WAVEFLOW_JWT_JWKS_URL / _ISSUER / _AUDIENCE triple with the Better Auth dev URLs.

Test plan

  • cargo check --workspace --all-targets — clean
  • cargo fmt --all --check — clean
  • cargo test — integration tests need a live Postgres so CI validates

Manual smoke after merge:

  • WAVEFLOW_JWT_* unset → cargo run panics with the missing-knob message
  • WAVEFLOW_JWT_* set → boot OK, /api/v1/profiles returns 401 with no Bearer, 200 + empty list with a valid one minted off the matching Better Auth instance

What this is NOT

  • ❌ Does NOT touch the actual JWT verification (auth::JwtVerifier etc.). Those tests are unchanged.
  • ❌ Does NOT remove jsonwebtoken or any JWKS code — that's the only auth code path now.
  • ❌ Does NOT change /health or /ready (still unauthenticated probes).

Summary by CodeRabbit

  • Authentification

    • JWT (Authorization: Bearer) désormais obligatoire ; ancien header de contournement supprimé.
    • Auto‑provisionnement des utilisateurs au premier authentifié.
  • Changements de rupture

    • Suppression du endpoint de bootstrap utilisateur (POST /api/v1/users).
    • Variables d’environnement JWT désormais requises au démarrage.
  • Base de données

    • Migration pour rendre external_id non‑NULL et supprimer les entrées orphelines.
  • Documentation

    • README et spec OpenAPI alignés sur le mode « JWT‑only ».
  • Tests

    • Suites d’intégration adaptées pour l’authentification Bearer.

Phase 1.d.2. Better Auth is deployed (waveflow-web 1.c.2c) and
lazy auto-provisioning landed (PR #16), so the legacy
x-user-id header shim has no remaining job. This commit deletes
it end-to-end.

Production code:
- config: drop dev_auth_enabled + auth_disabled_at_boot. The
  WAVEFLOW_JWT_* triple is now required at boot — a partial /
  missing config fails fast instead of silently 503-ing every
  request.
- AppState: jwt_verifier becomes Arc<JwtVerifier> (no Option),
  reflecting the boot guarantee.
- middleware: drop require_user_id, parse_x_user_id_header,
  X_USER_ID_HEADER + the shim-fallback branch in authenticate.
  Single flow: missing bearer → 401, bad token → 401, valid
  token → lazy-provision + UserId extension.
- api/users.rs: deleted entirely. POST /api/v1/users had only
  ever existed to seed the shim; lazy provision now lands the
  row on first authenticated request.
- api/mod.rs: drop users module + reject_dev_auth_disabled
  helper. Every /api/v1/* gets the single authenticate layer.
- api/{libraries,playlists,profiles,tracks}.rs: openapi header
  params switch from x-user-id to Authorization Bearer.
- db::users: drop the now-unused create helper.

Migration:
- 20260531000000_users_external_id_not_null.sql: deletes any
  rows with NULL external_id (dev artifacts that no JWT could
  ever authenticate against) then ALTER COLUMN ... SET NOT NULL.

Tests:
- support.rs: rebuilt around JwksHarness. spawn_app stays as a
  no-auth-needed helper for probe tests; new spawn_authenticated
  +  spawn_two_authenticated bundle harness + bearer mint +
  lazy-provision warm-up + users.id lookup.
- jwt_middleware.rs: drop the *_with_shim variants + the
  prod-gate 503 test (the boot fail-fast covers that case now).
- profiles / libraries / tracks / playlists: every test that
  used .header("x-user-id", ...) + mint_user(POST /users) now
  uses spawn_authenticated + .bearer_auth. Tenancy tests share
  one app instance + one JWKS harness for both callers.
- openapi.rs: assert the spec no longer advertises /api/v1/users.

Docs:
- README + CLAUDE.md: bullet rewritten to describe JWT-only.
- .env.example: drop WAVEFLOW_DEV_AUTH; add the JWT triple with
  the default Better Auth dev URLs.

Net -678 LOC. cargo check + cargo fmt clean locally; integration
tests need a live Postgres so CI will validate them.

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

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown

Review Change Stack

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: 4a0254e9-9d4c-43a6-a32c-dd53541ebc4a

📥 Commits

Reviewing files that changed from the base of the PR and between d68f90f and a7cc43a.

📒 Files selected for processing (2)
  • src/api/profiles.rs
  • tests/ready.rs

📝 Walkthrough

Walkthrough

Le PR implémente Phase 1.d.2 : suppression du shim dev X-User-Id, passage à JWT-only (WAVEFLOW_JWT_* requis), JwtVerifier créé au démarrage, middleware exige Authorization: Bearer et provisionne paresseusement les users; endpoint bootstrap POST /api/v1/users supprimé; tests et OpenAPI mis à jour.

Changes

JWT-Only Authentication Migration

Layer / File(s) Summary
Configuration JWT obligatoire et initialisation au boot
.env.example, CLAUDE.md, README.md, migrations/20260531000000_users_external_id_not_null.sql, src/config.rs, src/main.rs, tests/ready.rs
Variables d'environnement JWT requises (JWKS/ISSUER/AUDIENCE). Migration supprime users sans external_id et rend la colonne NOT NULL. Le serveur crée un JwtVerifier et échoue au boot si absent.
Middleware Bearer-only et AppState rendu strict
src/middleware.rs, src/lib.rs
middleware::authenticate exige Authorization: Bearer, vérifie via AppState::jwt_verifier, résout sub puis appelle db::users::find_or_provision_by_external_id et injecte UserId. Suppression du shim X_USER_ID_HEADER et du middleware require_user_id. AppState.jwt_verifier devient non-optionnel.
Suppression de l'API users et refactorisation du routeur
src/api/mod.rs, src/api/users.rs, src/db.rs
Suppression de src/api/users.rs (POST /api/v1/users) et de users::create dans db.rs. api::router retire le paramètre &Config et le montage du sous-routeur users.
Annotations OpenAPI mises à jour pour Bearer
src/api/profiles.rs, src/api/libraries.rs, src/api/playlists.rs, src/api/tracks.rs
Toutes les annotations utoipa::path remplacent la doc x-user-id par authorization (Bearer) et clarifient les réponses 401 liées au token.
Infrastructure de tests JWT et helpers authentifiés
tests/support.rs
Suppression des variantes spawn legacy; ajout de JwksHarness wiring, Authenticated, TwoAuthenticated, spawn_authenticated et spawn_two_authenticated qui mintent tokens, warm-up via GET /api/v1/profiles et lisent user_id en BDD.
Migration des tests : Bearer auth sur tous les endpoints
tests/jwt_middleware.rs, tests/profiles.rs, tests/libraries.rs, tests/playlists.rs, tests/tracks.rs, tests/openapi.rs
Toutes les suites migrées pour utiliser spawn_authenticated/spawn_two_authenticated et .bearer_auth(&token). Suppression des tests liés au shim/dev-gate; OpenAPI test vérifie l'absence de /api/v1/users.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🔐 Adieu les shims, bonjour les JWKS,
Un header Bearer pour tous les accès,
Le serveur lève ou bien il casse,
Lazy-provision pour chaque sub en place,
Phase 1.d.2, droit dans ses bottes ✨

🚥 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 le changement principal : retrait du shim dev x-user-id et JWT comme unique chemin d'authentification.
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-2-retire-shim

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

@InstaZDLL InstaZDLL self-assigned this May 31, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
tests/support.rs (1)

73-83: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Ajouter une barrière de disponibilité avant de retourner l’URL

Line 73 lance le serveur dans une tâche, puis Line 82 renvoie l’URL immédiatement. Les appels faits juste après peuvent partir avant que le listener accepte des connexions, ce qui introduit de la flakiness (connection refused) dans les tests d’intégration.

💡 Correctif proposé
     tokio::spawn(async move {
         axum::serve(
             listener,
             app(config, state).into_make_service_with_connect_info::<SocketAddr>(),
         )
         .await
         .unwrap();
     });
 
-    format!("http://{addr}")
+    let base = format!("http://{addr}");
+    let client = reqwest::Client::new();
+    for _ in 0..50 {
+        match client.get(format!("{base}/health")).send().await {
+            Ok(resp) if resp.status().is_success() => return base,
+            _ => tokio::time::sleep(std::time::Duration::from_millis(20)).await,
+        }
+    }
+    panic!("test server not ready: {base}");
 }
🤖 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 `@tests/support.rs` around lines 73 - 83, Le serveur est lancé de façon
asynchrone avec tokio::spawn en appelant axum::serve(app(config, state)...),
puis l’URL est retournée immédiatement via format!("http://{addr}"), ce qui
provoque des erreurs de connexion racey; fixez-le en ajoutant une disponibilité
(readiness) : créez un signal (oneshot channel ou tokio::sync::Notify) que la
tâche spawnée émet une fois que le listener/serveur est prêt (par ex. après le
bind/accept ou juste avant d’appeler .await sur axum::serve), et attendez ce
signal dans la fonction appelante avant de retourner format!("http://{addr}"),
en modifiant la closure où axum::serve est appelée et le point qui retourne
l’URL pour utiliser ce signal.
🤖 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.

Inline comments:
In `@src/api/profiles.rs`:
- Around line 129-130: La réponse 409 dans create_profile contient encore la
mention obsolète "X-User-Id" dans sa description et/ou dans le body retourné;
remplacez toute occurrence par une formulation correcte relative à
l'authentification JWT (p.ex. "user id from JWT / authenticated user id") ou
supprimez la référence si inutile, et mettez à jour la description et le payload
retourné dans le décorateur/annotation de response (la déclaration 409 autour de
create_profile et les blocs similaires autour des lignes 149-159) pour qu'ils
reflètent le contrat JWT-only.

---

Outside diff comments:
In `@tests/support.rs`:
- Around line 73-83: Le serveur est lancé de façon asynchrone avec tokio::spawn
en appelant axum::serve(app(config, state)...), puis l’URL est retournée
immédiatement via format!("http://{addr}"), ce qui provoque des erreurs de
connexion racey; fixez-le en ajoutant une disponibilité (readiness) : créez un
signal (oneshot channel ou tokio::sync::Notify) que la tâche spawnée émet une
fois que le listener/serveur est prêt (par ex. après le bind/accept ou juste
avant d’appeler .await sur axum::serve), et attendez ce signal dans la fonction
appelante avant de retourner format!("http://{addr}"), en modifiant la closure
où axum::serve est appelée et le point qui retourne l’URL pour utiliser ce
signal.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1552f142-6655-4b68-8a63-c96c82c0f901

📥 Commits

Reviewing files that changed from the base of the PR and between 5ce4b10 and d68f90f.

📒 Files selected for processing (22)
  • .env.example
  • CLAUDE.md
  • README.md
  • migrations/20260531000000_users_external_id_not_null.sql
  • src/api/libraries.rs
  • src/api/mod.rs
  • src/api/playlists.rs
  • src/api/profiles.rs
  • src/api/tracks.rs
  • src/api/users.rs
  • src/config.rs
  • src/db.rs
  • src/lib.rs
  • src/main.rs
  • src/middleware.rs
  • tests/jwt_middleware.rs
  • tests/libraries.rs
  • tests/openapi.rs
  • tests/playlists.rs
  • tests/profiles.rs
  • tests/support.rs
  • tests/tracks.rs
💤 Files with no reviewable changes (1)
  • src/api/users.rs

Comment thread src/api/profiles.rs Outdated
… handler

CI surfaced two leftovers from PR #17:

- tests/ready.rs::users_external_id_check_rejects_blank asserted
  NULL external_id is allowed, but the 1.d.2 migration set the
  column NOT NULL. Rename to users_external_id_rejects_null_and_blank
  and assert NULL trips SQLSTATE 23502 (not_null_violation) on
  top of the existing blank-string CHECK case.
- src/api/profiles.rs create_profile: openapi 409 description +
  inline comment + response body all still referenced 'X-User-Id'
  by name. Rewrite to mention the authenticated user id resolved
  by the middleware. 409 stays defensive (race between the
  middleware's lazy-provision and a hypothetical concurrent users
  row delete; unreachable today but cheap to keep).

Skipped: CR's outside-diff finding on tests/support.rs about
spawning axum::serve without a readiness signal. TcpListener::bind
returns once the socket is in LISTEN state, so the kernel queues
incoming SYN packets in the accept backlog — a client connect()
succeeds even before axum::serve reaches accept(). No race-induced
failure (just a few ms of latency); CI run on PR #17 was clean on
the integration suite, and a oneshot signal would add code for a
problem TCP semantics already solve.

Signed-off-by: InstaZDLL <github.105mh@8shield.net>
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