# 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, db: Option>, Extension(auth_ctx): Extension, ) -> Result { 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, ) -> Json { 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, db: Option>, Json(payload): Json, // Reuses email-only request ) -> Result, 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, db: Option>, Extension(auth_ctx): Extension, ) -> Result { 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, pub role: String, pub exp: usize, pub iss: String, pub aud: Option, pub iat: usize, pub session_id: Option, // NEW pub aal: Option, // NEW: "aal1" or "aal2" pub amr: Option>, // 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, } ``` 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::().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