Skip to content

feat(auth): lazy auto-provision users on first jwt request - #16

Merged
InstaZDLL merged 3 commits into
mainfrom
feat/1-c-3a-jwt-lazy-provision
May 31, 2026
Merged

feat(auth): lazy auto-provision users on first jwt request#16
InstaZDLL merged 3 commits into
mainfrom
feat/1-c-3a-jwt-lazy-provision

Conversation

@InstaZDLL

@InstaZDLL InstaZDLL commented May 31, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 1.c.3a — first half of the cross-repo bridge between waveflow-web (Better Auth, JWT-issuer) and waveflow-server (JWT-verifier). The web client opens PRs against this once 1.c.3b lands.

Problem. The 1.d.1 JWT middleware returned 401 on sub-unknown-in-users — forcing every fresh Better Auth signup to first hit POST /api/v1/users via the dev X-User-Id shim before the JWT path would accept it. That defeats the point of removing the shim in 1.d.2 and adds a second auth channel for bootstrap that has its own auth-of-the-bootstrap chicken-and-egg.

Fix. A signed JWT from the configured Better Auth issuer IS the authoritative statement that the sub is a real user. The middleware now UPSERTs users(external_id) the first time it sees a new sub, using INSERT … ON CONFLICT DO UPDATE … RETURNING id so the resolution is one round-trip whether the row is new or pre-existing.

Changes

File Change
src/db.rs New users::find_or_provision_by_external_id(pool, sub, now_ms). Drops find_by_external_id (now-unused).
src/middleware.rs::resolve_bearer Calls the new helper instead of find_by_external_id. No more 401 on unknown sub.
tests/jwt_middleware.rs bearer_with_unknown_sub_returns_401bearer_with_unknown_sub_lazy_provisions_user. Asserts 200, empty profile list (tenant-scoped), and COUNT(*)=1 after a second request (idempotence).
CLAUDE.md Auth bullet updated to describe the JWT-first + lazy-provision flow.

Why an UPSERT instead of read-then-insert

Two reasons:

  1. Atomicity under concurrent first requests. Better Auth + a refreshing tab can fire two JWT requests for the same fresh sub within milliseconds. A naive SELECT-then-INSERT races; an UPSERT against the UNIQUE(external_id) index doesn't.
  2. DO UPDATE SET external_id = EXCLUDED.external_id RETURNING id is the idiomatic Postgres trick for "always give me the id, insert-or-existing." DO NOTHING would skip the RETURNING clause on conflict and force a separate SELECT.

Test plan

  • cargo check --workspace --all-targets — clean
  • cargo test --test jwt_middleware — needs live Postgres, CI will validate
  • Existing JWT tests (8 total) cover: valid bearer happy path, bad signature, no kid, wrong scheme, missing bearer with JWT-only, no auth configured 503, invalid bearer no downgrade, and the rewritten lazy-provision case.
  • Shim tests (require_user_id, prod-gate 503) untouched — only the JWT branch behavior changed.

What this is NOT

  • ❌ Does NOT change shim behavior (require_user_id still 401s on missing/malformed X-User-Id). 1.d.2 deletes the shim entirely.
  • ❌ Does NOT change POST /api/v1/users — the endpoint stays as the explicit-onboarding path for admin tooling / scripts that don't want to mint a JWT first.
  • ❌ Does NOT touch the JWKS verifier, the kid matching, or the algorithm allowlist (all unchanged from 1.d.1).

Follow-up

After this lands → waveflow-web 1.c.3b ships the Authorization: Bearer fetch wrapper + the first wired UI page. Then 1.d.2 retires the shim everywhere.

Summary by CodeRabbit

  • New Features

    • L'authentification JWT provisionne automatiquement le profil utilisateur au premier accès, de façon idempotente même sous requêtes concurrentes. Si JWT non configuré et mode dev activé, le fallback utilise l'entête de développement ; sinon la plateforme renvoie 503.
  • Documentation

    • Mise à jour du guide d'architecture décrivant le flux d'authentification progressif (JWT + shim de développement) et le provisioning automatique.
  • Tests

    • Scénario de test mis à jour pour vérifier le provisionnement paresseux et l'idempotence lors de requêtes parallèles.

Previously a valid JWT for a sub with no matching users row got
401, forcing every fresh Better Auth signup to first POST
/api/v1/users via the dev shim before the JWT path would let it
through. That defeats the point of removing the shim in 1.d.2 and
adds a second auth channel for the bootstrap.

A signed JWT from the configured Better Auth issuer IS the
authoritative statement that the sub is a real user. The
middleware now does an UPSERT on users(external_id) the first
time a new sub shows up, using INSERT ... ON CONFLICT DO UPDATE
... RETURNING id so the resolution is one round-trip whether
the row is new or pre-existing. The UNIQUE constraint on
external_id keeps concurrent first requests from the same user
collapsed cleanly to one row.

Drops the now-unused find_by_external_id helper - the only
caller was the middleware path above. Easy to re-add when an
admin/read-only endpoint actually wants lookup without
provisioning.

Tests: bearer_with_unknown_sub_returns_401 becomes
bearer_with_unknown_sub_lazy_provisions_user - asserts both
the 200 status, the empty profile list (tenant-scoped to the
new user, not someone else's profiles), and the COUNT(*)=1
invariant on a second request to prove idempotence.

cargo check passes. Integration tests need a live Postgres so
CI will validate the runtime behavior.

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: b26d47eb-b119-4d62-a05f-084ba86086af

📥 Commits

Reviewing files that changed from the base of the PR and between 382a442 and 543516c.

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

📝 Walkthrough

Walkthrough

Le PR transforme l’authentification JWT en un flux "JWT-first" avec provisioning paresseux : claims.sub est résolu via une fonction find-or-provision (SELECT puis INSERT...ON CONFLICT), middleware adapté, doc mise à jour et test vérifiant l'idempotence sous concurrence.

Changes

JWT Lazy User Provisioning

Layer / File(s) Summary
DB find-or-provision via external_id
src/db.rs
Ajout de find_or_provision_by_external_id(pool, external_id, created_at_ms) : lecture optimiste (SELECT id) puis INSERT ... ON CONFLICT (external_id) DO UPDATE ... RETURNING id, renvoie toujours un i64.
Middleware JWT with lazy provisioning
src/middleware.rs
resolve_bearer appelle find_or_provision_by_external_id au lieu de find_by_external_id; un sub inconnu provoque un provisioning au lieu d'un 401, et les échecs de provision sont mappés sur 500 avec tracing::error!.
Architecture docs and lazy provisioning test
CLAUDE.md, tests/jwt_middleware.rs
Documentation mise à jour pour le flow "JWT-first + dev shim"; nouveau test bearer_with_unknown_sub_lazy_provisions_user envoie deux requêtes parallèles et vérifie que l'UPSERT crée une seule ligne users.

Sequence Diagram

sequenceDiagram
  participant Client
  participant JWTMiddleware as JWTMiddleware
  participant PostgreSQL as PostgreSQL
  participant ProfilesAPI as ProfilesAPI

  Client->>JWTMiddleware: GET /api/v1/profiles + Bearer JWT
  JWTMiddleware->>JWTMiddleware: Validate JWT signature & extract sub
  JWTMiddleware->>PostgreSQL: SELECT id FROM users WHERE external_id = sub
  PostgreSQL-->>JWTMiddleware: id OR empty
  alt id exists
    JWTMiddleware->>ProfilesAPI: Request with user_id
  else missing
    JWTMiddleware->>PostgreSQL: INSERT ... ON CONFLICT (external_id) DO UPDATE ... RETURNING id
    PostgreSQL-->>JWTMiddleware: user_id (provisioned)
    JWTMiddleware->>ProfilesAPI: Request with user_id
  end
  ProfilesAPI->>PostgreSQL: Query profiles for user_id
  PostgreSQL-->>ProfilesAPI: profiles (empty for new user)
  ProfilesAPI-->>Client: 200 OK, profiles list
Loading

Possibly Related PRs

  • InstaZDLL/waveflow-server#14: PR précédent ayant introduit le lookup sub→user utilisé auparavant par le middleware; ce PR remplace ce lookup par un find-or-provision.
  • InstaZDLL/waveflow-server#11: Introduit la colonne external_id et sa contrainte unique sur users, fondation du mécanisme d'UPSERT utilisé ici.

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~40 minutes

Poem

Un sub perdu frappe à la porte,
L'UPSERT répond, plante une sorte,
Deux requêtes courent, une seule pousse,
Middleware sourit, la DB la pousse —
Idempotence gagne, la nuit se recroqueville. 🚀

🚥 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 : l'ajout du provisionnement automatique et lazy des utilisateurs au premier appel JWT, qui correspond exactement aux modifications apportées.
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-c-3a-jwt-lazy-provision

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: 2

🤖 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/db.rs`:
- Around line 127-129: La requête SQL actuelle qui utilise "INSERT INTO users
... ON CONFLICT ... DO UPDATE SET external_id = EXCLUDED.external_id RETURNING
id" provoque un write-amplification sur chaque authentification ; remplace ce
comportement par un INSERT ... ON CONFLICT DO NOTHING RETURNING id and fallback
SELECT id in the same database call: try the INSERT with "DO NOTHING" and if it
returns no row, SELECT id FROM users WHERE external_id = $2; update the code
path that builds/executes this SQL (the SQL string used to return the user id in
src/db.rs) to perform that two-branch logic so conflicts do not produce writes.

In `@tests/jwt_middleware.rs`:
- Around line 128-177: Update the test
bearer_with_unknown_sub_lazy_provisions_user to exercise concurrent UPSERTs:
instead of sending the two GETs sequentially, issue two GET /api/v1/profiles
requests in parallel (e.g. spawn two async tasks or use
futures::join!/tokio::join!) both using the same token created by harness.mint,
await both responses, assert both statuses are StatusCode::OK, then run the sqlx
COUNT(*) query against users (as done currently) and assert it equals 1; keep
the existing setup helpers (JwksHarness::spawn, spawn_app_with_jwt_and_shim,
harness.mint) and all existing assertions for profile contents but change the
second-request section to perform the parallel requests and checks.
🪄 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: 76030502-c460-4089-9494-bb6b033ad9eb

📥 Commits

Reviewing files that changed from the base of the PR and between 470e896 and 07ad93f.

📒 Files selected for processing (4)
  • CLAUDE.md
  • src/db.rs
  • src/middleware.rs
  • tests/jwt_middleware.rs

Comment thread src/db.rs
Comment thread tests/jwt_middleware.rs
CR concerns on PR #16:

- src/db.rs::find_or_provision_by_external_id: switch from
  unconditional ON CONFLICT DO UPDATE to a read-first pattern.
  The hot path (every JWT request after the first) is now a
  SELECT only, zero writes, no heap-tuple churn. The miss path
  falls through to the same ON CONFLICT DO UPDATE UPSERT so two
  concurrent first-request races still collapse atomically to
  one row - the loser's UPDATE is a no-op that still fires
  RETURNING id.

  CR proposed the CTE pattern (INSERT ON CONFLICT DO NOTHING
  RETURNING id UNION ALL SELECT) but that has a documented race
  window in READ COMMITTED: both CTE branches share the
  statement's snapshot, so a concurrent commit between snapshot
  and INSERT can return zero rows. The two-query read-first
  pattern is race-safe via the UPSERT fallback and matches the
  same throughput profile (the existing-user case is one read,
  the new-user case is one read + one write).

- tests/jwt_middleware.rs::bearer_with_unknown_sub_lazy_provisions_user:
  fire two GET /api/v1/profiles requests in parallel via
  tokio::join! with the same fresh token. Asserts both 200,
  both empty profile lists (tenant-scoped to the new user),
  and COUNT(*)=1 in users - actually exercises the race the
  UPSERT fallback exists to handle, rather than the previous
  sequential check.

- cargo fmt --all noise that broke CI.

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

@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.

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)

78-80: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Doc obsolète : ce PR change ce comportement.

Ce bloc décrit toujours « sub doesn't resolve to a users row → 401 ». Or resolve_bearer provisionne désormais l'utilisateur à la volée (200, plus 401). Comme c'est de la doc de sécurité, mieux vaut la corriger pour éviter d'induire en erreur sur le flux d'auth réel.

📝 Correction suggérée
-///    - `sub` doesn't resolve to a `users` row → 401. Hides the
-///      existence of foreign sub values (same no-leak rationale as
-///      the resource-level 404s).
+///    - `sub` has no `users` row yet → lazy-provision it (first
+///      request from a fresh Better Auth signup), then attach the
+///      new [`UserId`]. A provisioning failure maps to 500.
🤖 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 78 - 80, La documentation dans le commentaire
décrivant le comportement d'authentification est obsolète : le texte qui dit «
`sub` doesn't resolve to a `users` row → 401 » doit être mis à jour pour
refléter que resolve_bearer provisionne désormais l'utilisateur à la volée et
retourne 200 (plutôt que 401). Ouvre le bloc de commentaire dans
src/middleware.rs près de la description du flux d'auth et remplace la phrase
par une formulation précisant que si le `sub` n'existe pas encore,
resolve_bearer crée/provisionne un utilisateur automatiquement et poursuit
l'authentification (200), en conservant la justification sécurité/no-leak
adaptée; mentionne la fonction resolve_bearer pour repérer l'endroit exact.
🤖 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 78-80: La documentation dans le commentaire décrivant le
comportement d'authentification est obsolète : le texte qui dit « `sub` doesn't
resolve to a `users` row → 401 » doit être mis à jour pour refléter que
resolve_bearer provisionne désormais l'utilisateur à la volée et retourne 200
(plutôt que 401). Ouvre le bloc de commentaire dans src/middleware.rs près de la
description du flux d'auth et remplace la phrase par une formulation précisant
que si le `sub` n'existe pas encore, resolve_bearer crée/provisionne un
utilisateur automatiquement et poursuit l'authentification (200), en conservant
la justification sécurité/no-leak adaptée; mentionne la fonction resolve_bearer
pour repérer l'endroit exact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f2f3a5e9-e558-47c2-bfd8-bd485b58e71d

📥 Commits

Reviewing files that changed from the base of the PR and between 07ad93f and 382a442.

📒 Files selected for processing (3)
  • src/db.rs
  • src/middleware.rs
  • tests/jwt_middleware.rs

The authenticate doc still claimed 'sub doesn't resolve to a users
row -> 401'. That was true through 1.d.1 but 1.c.3a inverted the
behavior: a verified JWT for an unknown sub now lazy-provisions
the row and returns 200. Update the flow bullet to match.

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

Copy link
Copy Markdown
Owner Author

@coderabbitai finding valide — la bullet flow #2 dans le doc-comment dauthenticate était restée sur le 401 davant. Fix dans 543516c : reformulée pour décrire le lazy-provision (200) + le 500 si la provision DB échoue. Rationale sécurité préservé (le commit conserve lidée "verified JWT IS the authoritative signal").

@coderabbitai

coderabbitai Bot commented May 31, 2026

Copy link
Copy Markdown

Tip

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

@InstaZDLL Parfait — la correction est cohérente avec le comportement réel post-PR : JWT vérifié → provision → 200, échec DB → 500. Le doc-comment reflète maintenant fidèlement le flux. Merci pour la mise à jour.

(ミ^ ^ミ)

@InstaZDLL
InstaZDLL merged commit 5ce4b10 into main May 31, 2026
8 checks passed
@InstaZDLL
InstaZDLL deleted the feat/1-c-3a-jwt-lazy-provision branch May 31, 2026 00:49
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