Files
Oxicloud/src/infrastructure/auth_factory.rs
T
Edouard Vanbelle e94063d96a test(login/register): via password or magic-link
Password login

┌─────┬────────────────────────────────────────────────────┬────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────────┐
│  #  │                        Case                        │         Where          │                                          Assertion                                          │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L1  │ Login by username                                  │ auth_login.hurl Case 1 │ 200 + access_token, user.email match                                                        │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L2  │ Login by email (dispatch on @)                     │ auth_login.hurl Case 2 │ 200, same session shape as L1                                                               │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L3  │ Bad password on username path                      │ auth_login.hurl Case 3 │ 403 anti-enum                                                                               │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L4  │ Bad password on email path                         │ auth_login.hurl Case 4 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L5  │ Unknown username                                   │ auth_login.hurl Case 5 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L6  │ Unknown email                                      │ auth_login.hurl Case 6 │ 403 anti-enum (same shape as L3)                                                            │
├─────┼────────────────────────────────────────────────────┼────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────────┤
│ L7  │ /api/auth/oidc/providers reports methods correctly │ auth_login.hurl Case 7 │ password_login_enabled: true, magic_link_login_enabled: true, require_verified_email: false │
└─────┴────────────────────────────────────────────────────┴────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────────┘

Password registration

┌─────┬───────────────────────────────────────────────────┬──────────────────────────────┬─────────────────────────────────────────────────────────┐
│  #  │                       Case                        │            Where             │                        Assertion                        │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R1  │ Classic username + email + password → uniform 200 │ registration.hurl Step 2     │ anti-enum message contains "request received"           │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R2  │ Login after register works                        │ registration.hurl Step 2b    │ 200 + session for the new user                          │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼─────────────────────────────────────────────────────────┤
│ R3  │ Email collision → uniform 200 (no rewrite)        │ registration.hurl Steps 8-10 │ attacker password doesn't work; original account intact │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R4  │ Username collision → uniform 200                  │ registration.hurl Step 11    │ same anti-enum shape                                    │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R5  │ Off-domain rejection                              │ registration.hurl Step 12    │ 403 RegistrationDomainNotAllowed                        │
├─────┼───────────────────────────────────────────────────┼──────────────────────────────┼────────────────────────────┤
│ R6  │ Case-insensitive domain match                     │ registration.hurl Step 12b   │ uniform 200 on charlie@EXAMPLE.COM                      │
└─────┴───────────────────────────────────────────────────┴──────────────────────────────┴────────────────────────────┘

Magic-link registration (email-only signup)

┌─────┬──────────────────────────────────────────────────────────────────────────────────────────────────┬───────────────────────────────────────────────────┐
│  #  │                                               Case                                               │             Where             │                   Assertion                    │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR1 │ Email-only signup → welcome mail queued                                                          │ registration.hurl Step 3      │ uniform 200 + browser-binding cookie set       │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR2 │ Welcome mail contains magic-link URL                                                             │ registration.hurl Step 4      │ captured from mock SMTP                        │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR3 │ PR 22 cross-browser confirmation page                                                            │ registration.hurl Step 5a     │ 200 HTML "different browser"                   │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR4 │ Cookie-bound redemption lands on SPA                                                             │ registration.hurl Step 5b     │ 302 → /files (SvelteKit route, post-migration) │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR5 │ email_verified_at stamped after redemption                                                       │ registration.hurl Step 6      │ field present on /api/auth/me                  │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR6 │ Second magic-link post-signup                                                                    │ registration.hurl Step 7      │ uniform 200                                    │
├─────┼──────────────────────────────────────────────────────────────────────────────────────────────────┼───────────────────────────────────────────────────┤
│ MR7 │ Profile PATCH — no-op, name set, empty-string rejected, username-taken 409, claim-once 409, etc. │ registration.hurl Steps 6a–6i │ full profile lifecycle                         │
└─────┴──────────────────────────────────────────────────────────────────────────────────────────────────┴───────────────────────────────────────────────────┘

Magic-link login (existing account)

┌─────┬──────────────────────────────────────────────────────────┬──────────────────────────────────────┬───────────────────────────────────────┐
│  #  │                           Case                           │                Where                 │                             Assertion                              │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML1 │ Baseline password login still works                      │ auth_magic_link_login.hurl Steps 1-2 │ 200                                                                │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML2 │ magic-link/send with email identifier                    │ auth_magic_link_login.hurl Step 3    │ uniform 200 + cookie                                               │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML3 │ magic-link/send with username identifier (dispatch on @) │ auth_magic_link_login.hurl Step 4    │ uniform 200                                                        │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML4 │ Password-user policy: mail actually sent                 │ auth_magic_link_login.hurl Step 5    │ SMTP capture proves permit_magic_link_for_password_users in effect │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML5 │ Redemption creates a session                             │ auth_magic_link_login.hurl Steps 6-7 │ 302 → /files, /api/auth/me returns the same user                   │
├─────┼──────────────────────────────────────────────────────────┼──────────────────────────────────────┼───────────────────────────────────────┤
│ ML6 │ Anti-enum on unknown identifier                          │ auth_magic_link_login.hurl Step 8    │ same uniform 200 shape as ML3                                      │
└─────┴──────────────────────────────────────────────────────────┴──────────────────────────────────────┴───────────────────────────────────────┘

OIDC

┌─────┬────────────────────────────────────────────────────────────────────────┬───────────────────┬────────────────────────────────────────────────────────────────────────────────────────────┐
│  #  │                                  Case                                  │       Where       │                                                        Assertion                                                        │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O1  │ Setup local admin (bootstrap)                                          │ oidc.hurl Step 1  │ 201                                                                                                                     │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O2  │ Providers endpoint — OIDC visible                                      │ oidc.hurl Step 2  │ enabled: true, provider_name: MockSSO, password_login_enabled: true, magic_link_login_enabled: false (OIDC-master rule) │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O2b │ Magic-link/send refused (endpoint layer)                               │ oidc.hurl Step 2b │ 403 MagicLinkLoginDisabled — proves the policy gate fires, not a 503 SMTP-unwired                                       │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O3  │ Authorize redirect includes PKCE + state                               │ oidc.hurl Step 3  │ 307 to fake IdP                                                                                                         │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O4  │ IdP round-trip + JIT provisioning                                      │ oidc.hurl Step 4  │ Callback lands on /login?oidc_code=…                                                                                    │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O5  │ Code exchange → session cookies                                        │ oidc.hurl Step 5  │ 200 + all three cookies                                                                                                 │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O6  │ JIT profile mapping (name, given/family, picture, groups → admin role) │ oidc.hurl Step 6  │ every claim reflected on /api/auth/me                                                                                   │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O7  │ Refresh rotation on OIDC session                                       │ oidc.hurl Step 7  │ new access/refresh/CSRF cookies                                                                                         │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O8  │ Refreshed cookies authenticate                                         │ oidc.hurl Step 8  │ 200 on /api/auth/me                                                                                                     │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O9  │ Repeat login = same local user (no dup)                                │ oidc.hurl Step 9  │ user_id stable                                                                                                          │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O10 │ Anti-takeover: unverified email → refused                              │ oidc.hurl Step 10 │ 401/403                                                                                                                 │
├─────┼────────────────────────────────────────────────────────────────────────┼───────────────────┼────────────────────────────────────────────────────────────────────────────────────────────┤
│ O11 │ One-time code replay refused                                           │ oidc.hurl Step 11 │ second /exchange → 401                                                                                                  │
└─────┴────────────────────────────────────────────────────────────────────────┴───────────────────┴────────────────────────────────────────────────────────────────────────────────────────────┘

test
2026-07-14 03:16:25 +02:00

112 lines
4.6 KiB
Rust

use sqlx::PgPool;
use std::sync::Arc;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
use crate::application::services::auth_application_service::AuthApplicationService;
use crate::application::services::user_lifecycle_service::UserLifecycleService;
use crate::common::config::AppConfig;
use crate::common::di::AuthServices;
use crate::domain::repositories::magic_link_token_repository::MagicLinkTokenRepository;
use crate::infrastructure::repositories::pg::MagicLinkTokenPgRepository;
use crate::infrastructure::repositories::{SessionPgRepository, UserPgRepository};
use crate::infrastructure::services::jwt_service::JwtTokenService;
use crate::infrastructure::services::oidc_service::OidcService;
use crate::infrastructure::services::password_hasher::Argon2PasswordHasher;
pub async fn create_auth_services(
config: &AppConfig,
pool: Arc<PgPool>,
user_lifecycle: Arc<UserLifecycleService>,
) -> Result<AuthServices> {
// Create JWT token service (TokenServicePort implementation)
let token_service: Arc<JwtTokenService> = Arc::new(JwtTokenService::new(
config.auth.jwt_secret.clone(),
config.auth.access_token_expiry_secs,
config.auth.refresh_token_expiry_secs,
));
// Create password hashing service with configured Argon2id parameters
let password_hasher = Arc::new(Argon2PasswordHasher::new(
config.auth.hash_memory_cost,
config.auth.hash_time_cost,
config.auth.hash_parallelism,
));
// Create PostgreSQL repositories
let user_repository = Arc::new(UserPgRepository::new(pool.clone()));
let session_repository = Arc::new(SessionPgRepository::new(pool.clone()));
// Create authentication application service
let mut auth_app_service = AuthApplicationService::new(
user_repository,
session_repository,
password_hasher,
token_service.clone(),
config.storage_path.clone(),
);
// Wire the user-lifecycle dispatcher. Home-folder provisioning is
// now handled by PersonalDriveLifecycleHook (registered on the
// dispatcher in DI) — AuthApplicationService no longer needs a
// direct FolderService dependency for that path.
auth_app_service = auth_app_service.with_user_lifecycle(user_lifecycle);
// Wire the auth-method allowlist + email-verification requirement so
// login / magic-link / register handlers consult a single snapshot
// rather than reaching into the app config on every call.
auth_app_service = auth_app_service.with_auth_policy(
config.auth.allowed_auth_methods.clone(),
config.auth.require_verified_email,
);
// Wire the magic-link token repo. Enables `GET /magic/v1/{token}`
// and the future `POST /api/auth/magic-link/send` endpoint to mint
// and consume tokens. The repo is unconditional (it's just SQL on
// an empty table when the feature is dormant); the feature kill
// switch lives in `config.magic_link.allow_external_users`, checked
// by the issuance side, not by the redemption side.
let magic_link_repo: Arc<dyn MagicLinkTokenRepository> =
Arc::new(MagicLinkTokenPgRepository::new(pool.clone()));
auth_app_service = auth_app_service.with_magic_link_repo(magic_link_repo);
// Configure OIDC service if enabled
if config.oidc.enabled {
tracing::info!(
"Initializing OIDC service (provider: {}, issuer: {})",
config.oidc.provider_name,
config.oidc.issuer_url
);
let oidc_service = Arc::new(OidcService::new(config.oidc.clone()));
auth_app_service = auth_app_service.with_oidc(oidc_service, config.oidc.clone());
if config.oidc.disable_password_login {
tracing::warn!("Password login is DISABLED — only OIDC authentication is allowed");
}
}
// Package service in Arc
let auth_application_service = Arc::new(auth_app_service);
// Account lockout service — in-memory brute-force protection
let login_lockout = Arc::new(
crate::infrastructure::services::login_lockout_service::LoginLockoutService::new(
config.auth.rate_limit.lockout_max_failures,
config.auth.rate_limit.lockout_duration_secs,
100_000, // Track up to 100k accounts concurrently
),
);
tracing::info!(
"Login lockout service initialized: max {} failures, {}s lockout",
config.auth.rate_limit.lockout_max_failures,
config.auth.rate_limit.lockout_duration_secs,
);
Ok(AuthServices {
token_service,
auth_application_service,
login_lockout,
})
}