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

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.rsverify 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.rsupdate_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.rscallback 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.rsverify 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.rschallenge 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.rslogin 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.rsgenerate_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.rssignup 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 --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