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
399 lines
14 KiB
Markdown
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
|