Files
madbase/_milestones/M3_auth_completeness.md
Vlad Durnea cffdf8af86
Some checks failed
CI/CD Pipeline / unit-tests (push) Failing after 1m16s
CI/CD Pipeline / integration-tests (push) Failing after 2m32s
CI/CD Pipeline / lint (push) Successful in 5m22s
CI/CD Pipeline / e2e-tests (push) Has been skipped
CI/CD Pipeline / build (push) Has been skipped
wip:milestone 0 fixes
2026-03-15 12:35:42 +02:00

399 lines
14 KiB
Markdown

# Milestone 3: Auth Completeness (supabase-js Compatibility)
**Goal:** `supabase.auth.*` works correctly for the core flows real apps need.
**Depends on:** M0 (Security), M1 (Foundation)
---
## 3.1 — Missing Core Endpoints
### 3.1.1 POST /auth/v1/logout
**File:** `auth/src/handlers.rs` (new function), `auth/src/lib.rs` (add route)
```rust
pub async fn logout(
State(state): State<AuthState>,
db: Option<Extension<PgPool>>,
Extension(auth_ctx): Extension<AuthContext>,
) -> Result<StatusCode, ApiError> {
let claims = auth_ctx.claims.ok_or(ApiError::Unauthorized("Not authenticated".into()))?;
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| ApiError::Unauthorized("Invalid user ID".into()))?;
let db = db.map(|Extension(p)| p).unwrap_or(state.db.clone());
// Revoke all active refresh tokens for this user's current session
sqlx::query("UPDATE refresh_tokens SET revoked = true WHERE user_id = $1 AND revoked = false")
.bind(user_id)
.execute(&db)
.await?;
// If Redis sessions are active, destroy them
// if let Some(session_manager) = &state.session_manager {
// session_manager.delete_all_user_sessions(user_id).await.ok();
// }
Ok(StatusCode::NO_CONTENT)
}
```
Add route in `auth/src/lib.rs`:
```rust
.route("/logout", post(handlers::logout))
```
**supabase-js behavior:** `signOut()` calls `POST /auth/v1/logout` with the access token in the Authorization header. Expects 204 No Content.
### 3.1.2 GET /auth/v1/settings
Returns auth configuration that supabase-js reads during initialization:
```rust
pub async fn settings(
State(state): State<AuthState>,
) -> Json<serde_json::Value> {
Json(serde_json::json!({
"external": {
"google": state.config.google_client_id.is_some(),
"github": state.config.github_client_id.is_some(),
"azure": state.config.azure_client_id.is_some(),
"gitlab": state.config.gitlab_client_id.is_some(),
"bitbucket": state.config.bitbucket_client_id.is_some(),
"discord": state.config.discord_client_id.is_some(),
},
"disable_signup": false,
"mailer_autoconfirm": std::env::var("AUTH_AUTO_CONFIRM").map(|v| v == "true").unwrap_or(false),
"sms_provider": "",
"mfa_enabled": true,
}))
}
```
### 3.1.3 POST /auth/v1/magiclink
Generates a one-time login token, sends it via email. When the user clicks the link, they hit `/auth/v1/verify?type=magiclink&token=...` which issues tokens.
```rust
pub async fn magiclink(
State(state): State<AuthState>,
db: Option<Extension<PgPool>>,
Json(payload): Json<RecoverRequest>, // Reuses email-only request
) -> Result<Json<serde_json::Value>, ApiError> {
let db = db.map(|Extension(p)| p).unwrap_or(state.db.clone());
let token = generate_confirmation_token();
sqlx::query("UPDATE users SET confirmation_token = $1 WHERE email = $2")
.bind(&token).bind(&payload.email)
.execute(&db).await?;
tracing::info!(email = %payload.email, "Magic link requested (token suppressed)");
// TODO: Send email with link: {SITE_URL}/auth/confirm?token={token}&type=magiclink
Ok(Json(serde_json::json!({ "message": "Magic link sent if email exists" })))
}
```
### 3.1.4 DELETE /auth/v1/user
Self-deletion for authenticated users:
```rust
pub async fn delete_user(
State(state): State<AuthState>,
db: Option<Extension<PgPool>>,
Extension(auth_ctx): Extension<AuthContext>,
) -> Result<StatusCode, ApiError> {
let claims = auth_ctx.claims.ok_or(ApiError::Unauthorized("Not authenticated".into()))?;
let user_id = Uuid::parse_str(&claims.sub)?;
let db = db.map(|Extension(p)| p).unwrap_or(state.db.clone());
// Soft delete: set a deleted_at timestamp
sqlx::query("UPDATE users SET deleted_at = now() WHERE id = $1")
.bind(user_id).execute(&db).await?;
// Revoke all tokens
sqlx::query("UPDATE refresh_tokens SET revoked = true WHERE user_id = $1")
.bind(user_id).execute(&db).await?;
Ok(StatusCode::NO_CONTENT)
}
```
**Migration needed:** `ALTER TABLE users ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;`
---
## 3.2 — Fix Existing Flows
### 3.2.1 Recovery flow must accept new password
**File:** `auth/src/handlers.rs``verify` function, recovery branch (line ~335)
```rust
"recovery" => {
let user = sqlx::query_as::<_, User>(
"UPDATE users SET recovery_token = NULL WHERE recovery_token = $1 RETURNING *"
)
.bind(&payload.token)
.fetch_optional(&db).await?
.ok_or(ApiError::BadRequest("Invalid token".into()))?;
// Apply new password if provided
if let Some(new_password) = &payload.password {
let hashed = hash_password(new_password)
.map_err(|e| ApiError::Internal(e.to_string()))?;
sqlx::query("UPDATE users SET encrypted_password = $1 WHERE id = $2")
.bind(&hashed).bind(user.id)
.execute(&db).await?;
}
user
}
```
### 3.2.2 Email change must require re-verification
**File:** `auth/src/handlers.rs``update_user` function (line ~392)
Instead of immediately updating the email:
```rust
if let Some(new_email) = &payload.email {
let token = generate_confirmation_token();
sqlx::query(
"UPDATE users SET email_change = $1, email_change_token_new = $2 WHERE id = $3"
)
.bind(new_email).bind(&token).bind(user_id)
.execute(&mut *tx).await?;
// TODO: Send confirmation email to new_email with token
tracing::info!(user_id = %user_id, new_email = %new_email, "Email change requested");
}
```
The actual email update happens when the user verifies via `/auth/v1/verify?type=email_change&token=...`.
### 3.2.3 OAuth callback must redirect
**File:** `auth/src/oauth.rs``callback` function (end)
```rust
// BEFORE — returns JSON
Ok(Json(AuthResponse { access_token, ... }))
// AFTER — redirect with tokens in fragment
let site_url = std::env::var("SITE_URL").unwrap_or_else(|_| "http://localhost:3000".into());
let redirect_url = format!(
"{}#access_token={}&token_type=bearer&expires_in={}&refresh_token={}",
site_url, access_token, expires_in, refresh_token
);
Ok(Redirect::to(&redirect_url))
```
### 3.2.4 MFA verify must issue aal2 token
**File:** `auth/src/mfa.rs``verify` function (line ~179)
After successful TOTP verification:
```rust
// Issue upgraded JWT with aal2
let jwt_secret = project_ctx.jwt_secret.as_str();
let (token, expires_in, _) = generate_token_with_aal(
user_id, &email, "authenticated", jwt_secret, "aal2"
)?;
let refresh_token = issue_refresh_token(&db, user_id, Uuid::new_v4(), None).await
.map_err(|e| error_response(StatusCode::INTERNAL_SERVER_ERROR, e.1))?;
Ok(Json(serde_json::json!({
"access_token": token,
"token_type": "bearer",
"expires_in": expires_in,
"refresh_token": refresh_token,
})))
```
**New function needed in `auth/src/utils.rs`:** `generate_token_with_aal()` that adds `aal` and `amr` claims to the JWT.
**Update Claims model** in `auth/src/models.rs`:
```rust
pub struct Claims {
pub sub: String,
pub email: Option<String>,
pub role: String,
pub exp: usize,
pub iss: String,
pub aud: Option<String>,
pub iat: usize,
pub session_id: Option<String>, // NEW
pub aal: Option<String>, // NEW: "aal1" or "aal2"
pub amr: Option<Vec<AmrEntry>>, // NEW
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AmrEntry {
pub method: String, // "password", "totp", "oauth"
pub timestamp: usize,
}
```
### 3.2.5 MFA challenge must validate ownership
**File:** `auth/src/mfa.rs``challenge` function (line ~186)
```rust
// BEFORE — no user check
let row = sqlx::query("SELECT factor_type FROM auth.mfa_factors WHERE id = $1 AND status = 'verified'")
.bind(factor_id)...
// AFTER — verify user owns the factor
let user_id = auth_ctx.claims.as_ref()
.and_then(|c| Uuid::parse_str(&c.sub).ok())
.ok_or(error_response(StatusCode::UNAUTHORIZED, "Invalid user".into()))?;
let row = sqlx::query("SELECT factor_type FROM auth.mfa_factors WHERE id = $1 AND user_id = $2 AND status = 'verified'")
.bind(factor_id)
.bind(user_id)...
```
### 3.2.6 Store and validate MFA challenges
**Migration:**
```sql
CREATE TABLE IF NOT EXISTS auth.mfa_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
factor_id UUID NOT NULL REFERENCES auth.mfa_factors(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
verified_at TIMESTAMPTZ,
ip_address TEXT
);
CREATE INDEX idx_mfa_challenges_factor ON auth.mfa_challenges(factor_id);
```
In `challenge` handler, insert a challenge row and return its ID. In `verify` handler, validate the challenge_id exists, is recent (< 5 minutes), and belongs to the correct factor.
---
## 3.3 — Session Management
### 3.3.1 Wire in SessionManager
**File:** `auth/src/handlers.rs``login` and `signup` functions
After generating tokens, create a session:
```rust
if let Some(session_manager) = &state.session_manager {
let session_token = session_manager.create_session(
user.id, user.email.clone(), "authenticated".into()
).await.ok();
// Include session_id in JWT claims (via generate_token)
}
```
**Add to AuthState:**
```rust
pub struct AuthState {
pub db: PgPool,
pub config: Config,
pub session_manager: Option<SessionManager>,
}
```
Initialize in `gateway/src/worker.rs` when Redis is available.
### 3.3.2 Add GET /auth/v1/sessions
List active sessions for "sign out other devices" UI.
---
## 3.4 — Token Quality
### 3.4.1 Configurable token expiry
**File:** `auth/src/utils.rs``generate_token` (line 65)
```rust
// BEFORE
Duration::seconds(3600)
// AFTER
let lifetime = std::env::var("ACCESS_TOKEN_LIFETIME")
.ok()
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(3600);
Duration::seconds(lifetime)
```
### 3.4.2 Hash confirmation/recovery tokens
**File:** `auth/src/handlers.rs``signup` function
```rust
let raw_token = generate_confirmation_token();
let hashed_token = hash_refresh_token(&raw_token); // Reuse SHA-256 hasher
// Store hashed version in DB
.bind(&hashed_token)
// Log/email the raw version
tracing::info!("Confirmation token generated for {}", user.email);
```
On verify, hash the incoming token before comparison:
```rust
let hashed_input = hash_refresh_token(&payload.token);
sqlx::query("... WHERE confirmation_token = $1").bind(&hashed_input)
```
---
## Completion Requirements
This milestone is **not complete** until every item below is satisfied.
### 1. Full Test Suite — All Green
- [ ] `cargo test --workspace` passes with **zero failures**
- [ ] All **pre-existing tests** still pass (no regressions)
- [ ] **New unit tests** are written for every feature/fix in this milestone:
| Test | Location | What it validates |
|------|----------|-------------------|
| `test_logout_revokes_refresh_tokens` | `auth/src/handlers.rs` | `POST /logout` sets `revoked = true` on all user refresh tokens |
| `test_logout_returns_204` | `auth/src/handlers.rs` | `POST /logout` returns 204 No Content |
| `test_logout_unauthenticated_401` | `auth/src/handlers.rs` | `POST /logout` without bearer token returns 401 |
| `test_settings_endpoint` | `auth/src/handlers.rs` | `GET /settings` returns provider availability and `autoconfirm` flag |
| `test_magiclink_creates_token` | `auth/src/handlers.rs` | `POST /magiclink` generates a one-time token (DB row exists) |
| `test_recovery_requires_new_password` | `auth/src/handlers.rs` | Verification without `password` in body returns 422 |
| `test_recovery_updates_password` | `auth/src/handlers.rs` | After verification with new password, `password_hash` is updated |
| `test_email_change_requires_verification` | `auth/src/handlers.rs` | `PUT /user` with email change sets `new_email` but doesn't update `email` directly |
| `test_oauth_callback_redirects` | `auth/src/sso.rs` | OAuth callback returns 302 to `SITE_URL` with tokens in fragment |
| `test_mfa_verify_returns_aal2` | `auth/src/mfa.rs` | Successful TOTP verification returns JWT with `aal: aal2` claim |
| `test_mfa_rejects_wrong_factor_owner` | `auth/src/mfa.rs` | Verifying a factor not owned by the user returns 403 |
| `test_token_expiry_configurable` | `auth/src/utils.rs` | JWT `exp` respects `ACCESS_TOKEN_LIFETIME` env var |
| `test_session_list` | `auth/src/session.rs` | `GET /sessions` returns active sessions for the current user |
| `test_confirmation_tokens_hashed` | `auth/src/handlers.rs` | Confirmation token stored in DB is hashed, not plaintext |
### 2. Integration / supabase-js Compatibility Verification
- [ ] `supabase.auth.signOut()` → 204, refresh tokens revoked
- [ ] `supabase.auth.getSession()` returns null after signOut
- [ ] Password recovery flow: request → verify with new password → login with new password works
- [ ] Email change: request → confirmation email sent → verify → email updated
- [ ] OAuth callback redirects to `SITE_URL` with tokens in fragment
- [ ] MFA enroll → challenge → verify returns aal2 token
- [ ] MFA challenge rejects if user doesn't own the factor
- [ ] Token expiry respects `ACCESS_TOKEN_LIFETIME` env var
- [ ] `GET /auth/v1/settings` returns correct provider availability
- [ ] `supabase.auth.signUp()` / `signInWithPassword()` / `signInWithOAuth()` / `resetPasswordForEmail()` / `updateUser()` — all return shapes match supabase-js expectations
### 3. CI Gate
- [ ] All unit tests run in `cargo test --workspace`
- [ ] Tests that require a running Postgres are either:
- (a) using an in-memory SQLx test database, or
- (b) gated behind `#[ignore]` with a comment explaining the external dependency
- [ ] No `#[allow(unused)]` on any new code in this milestone