14 KiB
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)
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:
.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:
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.
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:
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)
"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:
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)
// 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:
// 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:
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)
// 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:
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:
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:
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)
// 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
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:
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 --workspacepasses 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 revokedsupabase.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_URLwith 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_LIFETIMEenv var GET /auth/v1/settingsreturns correct provider availabilitysupabase.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