2025-03-20 09:22:31 +01:00
use axum ::{
Router ,
2026-04-27 12:22:59 -07:00
extract ::{ ConnectInfo , Json , Query , State },
2026-03-05 16:56:09 -05:00
http ::{ HeaderMap , StatusCode },
2026-03-03 01:10:50 +01:00
response ::{ IntoResponse , Redirect , Response },
2026-03-05 16:56:09 -05:00
routing ::{ get , post , put },
2025-03-20 09:22:31 +01:00
};
2026-04-27 12:22:59 -07:00
use std ::net ::SocketAddr ;
2026-02-14 01:29:34 +01:00
use std ::sync ::Arc ;
2026-05-28 00:20:21 +02:00
use utoipa ::ToSchema ;
2026-03-07 14:59:32 +01:00
use uuid ::Uuid ;
2025-03-20 09:22:31 +01:00
use crate ::application ::dtos ::user_dto ::{
2026-08-21 23:19:00 +02:00
AuthResponseDto , ChangePasswordDto , LoginDto , OidcCallbackQueryDto , OidcExchangeDto ,
OidcProviderInfoDto , PublicUserDto , RefreshTokenDto , RegisterDto , SelfUserDto , SetupAdminDto ,
UpgradeToInternalDto ,
2025-03-20 09:22:31 +01:00
};
2026-06-02 22:50:13 +02:00
use crate ::application ::services ::auth_application_service ::{ OidcCallbackResult , RegisterResult };
2026-02-14 01:29:34 +01:00
use crate ::common ::di ::AppState ;
2026-03-03 01:10:50 +01:00
use crate ::interfaces ::api ::cookie_auth ;
2026-02-02 23:56:40 +01:00
use crate ::interfaces ::errors ::AppError ;
2026-08-09 12:04:45 +02:00
use crate ::interfaces ::middleware ::auth ::{ AuthUser , CurrentUserId };
2026-04-27 12:22:59 -07:00
use crate ::interfaces ::middleware ::trusted_proxy ::client_ip_from_parts ;
2026-05-26 00:50:38 +02:00
use serde ::Deserialize ;
2025-03-20 09:22:31 +01:00
2026-05-11 13:05:03 -07:00
/// Public auth routes, no authentication required.
2026-03-04 21:35:18 -05:00
pub fn auth_public_routes () -> Router < Arc < AppState >> {
Router ::new ()
2026-02-10 20:32:32 +01:00
. route ( "/status" , get ( get_system_status ))
// OIDC endpoints (all public)
. route ( "/oidc/providers" , get ( oidc_providers ))
. route ( "/oidc/authorize" , get ( oidc_authorize ))
2026-02-11 00:37:47 +01:00
. route ( "/oidc/callback" , get ( oidc_callback ))
2026-03-04 21:35:18 -05:00
. route ( "/oidc/exchange" , post ( oidc_exchange ))
2026-08-03 08:00:13 +02:00
// OIDC Back-Channel Logout 1.0 — public (server-to-server call
// from the IdP with a signed logout_token; no cookies, no CSRF).
. route ( "/oidc/backchannel-logout" , post ( oidc_backchannel_logout ))
2026-06-02 13:45:37 +02:00
// Login-via-email — sends a magic-link to the user's email so
// accounts with no other login credential can sign in.
. route ( "/magic-link/send" , post ( send_magic_link ))
2026-03-04 21:35:18 -05:00
}
2026-02-14 01:29:34 +01:00
2026-05-11 13:05:03 -07:00
/// Protected auth routes, require authentication (auth + CSRF middleware
2026-03-04 21:35:18 -05:00
/// must be applied by the caller in main.rs).
pub fn auth_protected_routes () -> Router < Arc < AppState >> {
2026-06-03 00:19:31 +02:00
use axum ::routing ::patch ;
2026-03-04 21:35:18 -05:00
Router ::new ()
2025-03-20 09:22:31 +01:00
. route ( "/me" , get ( get_current_user ))
2026-05-26 00:50:38 +02:00
. route ( "/me/image" , put ( update_user_image ))
2026-06-03 00:19:31 +02:00
. route ( "/me/profile" , patch ( update_profile ))
2025-03-20 09:22:31 +01:00
. route ( "/change-password" , put ( change_password ))
2026-07-14 04:12:48 +02:00
. route ( "/upgrade-to-internal" , post ( upgrade_to_internal ))
2026-03-04 21:35:18 -05:00
. route ( "/logout" , post ( logout ))
2026-08-08 17:19:05 +02:00
// Self-service OIDC identity linking — see
// docs/plan/oidc-account-linking.md.
. route ( "/oidc/link/start" , post ( oidc_link_start ))
. route ( "/oidc/unlink" , post ( oidc_unlink ))
2026-08-08 14:15:26 +02:00
. route ( "/dpop/bind" , post ( dpop_bind ))
2025-03-20 09:22:31 +01:00
}
2026-05-11 13:05:03 -07:00
/// Rate-limited auth routes, split out so main.rs can apply per-endpoint
2026-03-03 01:44:39 +01:00
/// rate limiting middleware independently.
pub fn login_route () -> Router < Arc < AppState >> {
Router ::new (). route ( "/login" , post ( login ))
}
pub fn register_route () -> Router < Arc < AppState >> {
Router ::new (). route ( "/register" , post ( register ))
}
pub fn refresh_route () -> Router < Arc < AppState >> {
Router ::new (). route ( "/refresh" , post ( refresh_token ))
}
2026-05-11 13:05:03 -07:00
/// Public setup route, only active before the first admin is created.
2026-03-04 14:14:40 +01:00
pub fn setup_route () -> Router < Arc < AppState >> {
Router ::new (). route ( "/setup" , post ( setup_admin ))
}
2026-05-28 00:20:21 +02:00
/// Register a new user account.
2026-06-02 22:50:13 +02:00
///
/// **Response shape depends on SMTP availability**:
///
/// - **SMTP configured** (`magic_link_invite_service` is wired): the
/// endpoint returns a **uniform 200** for both success and collision
/// (anti-enumeration). The "Registration request received" message
/// covers both branches honestly because successful email-only
/// signups receive a welcome magic-link. Real outcome recorded in
/// the `audit` channel as `auth.register` with `reason` one of
/// `created`, `email_taken`, `username_taken`.
/// - **SMTP not configured**: there is no welcome-mail cover story, so
2026-08-21 16:09:10 +02:00
/// the classic `201 + PublicUserDto` on success and `409` on collision
2026-06-02 22:50:13 +02:00
/// apply. Anti-enumeration would just be misleading UX (telling the
/// user to check an email that will never arrive). Email-only
/// signup is **503** in this mode because the user would otherwise
/// be stranded with an account they can't log into.
///
/// **Instance-wide policy stays visible** in both modes: when
/// registration is disabled by the admin or password registration is
/// disabled in OIDC-only mode, the endpoint returns **403** with a
/// clear message. These are instance-wide settings, not per-user
/// oracles — legitimate users deserve an actionable error.
2026-05-28 00:20:21 +02:00
#[utoipa::path(
post,
path = "/api/auth/register" ,
request_body = RegisterDto,
responses(
2026-06-02 22:50:13 +02:00
(status = 200, description = "Uniform registration response (SMTP configured, anti-enumeration mode)" ),
2026-08-21 16:09:10 +02:00
(status = 201, description = "User registered successfully (SMTP not configured)" , body = PublicUserDto),
2026-06-02 22:50:13 +02:00
(status = 400, description = "Validation error (malformed request body)" ),
(status = 403, description = "Registration disabled (admin setting or OIDC-only mode)" ),
(status = 409, description = "Username or email already taken (SMTP not configured)" ),
(status = 503, description = "Email-only signup requires SMTP to be configured" ),
2026-05-28 00:20:21 +02:00
),
tag = "auth"
)]
pub async fn register (
2025-03-20 09:22:31 +01:00
State ( state ) : State < Arc < AppState >> ,
Json ( dto ) : Json < RegisterDto > ,
2026-06-02 22:26:11 +02:00
) -> Result < axum ::response ::Response , AppError > {
2026-06-02 22:50:13 +02:00
// Uniform 200 response used in anti-enumeration mode (SMTP wired).
let uniform_ok = || {
let payload = serde_json ::json! ({
"message" : "Registration request received." ,
});
( StatusCode ::OK , Json ( payload )). into_response ()
};
2026-02-14 01:29:34 +01:00
2025-03-23 22:44:18 +01:00
// Verify auth service exists
let auth_service = match state . auth_service . as_ref () {
2026-06-02 22:26:11 +02:00
Some ( service ) => service ,
2025-03-23 22:44:18 +01:00
None => {
tracing ::error! ( "Auth service not configured" );
2026-02-14 01:29:34 +01:00
return Err ( AppError ::internal_error (
"Authentication service not configured" ,
));
2025-03-23 22:44:18 +01:00
}
};
2026-02-11 00:37:47 +01:00
2026-07-14 01:46:33 +02:00
// Block password registration when the policy forbids password
// logins (OIDC-only mode OR `OXICLOUD_AUTH_METHODS` allowlist
// without `password`). Email-only signup still works — the user
// authenticates via magic-link or SSO on their first visit.
2026-06-02 22:26:11 +02:00
if dto . password . is_some ()
2026-07-14 01:46:33 +02:00
&& ! auth_service
2026-06-02 22:26:11 +02:00
. auth_application_service
2026-07-14 01:46:33 +02:00
. is_password_login_allowed ()
2026-02-14 01:29:34 +01:00
{
2026-02-11 00:37:47 +01:00
return Err ( AppError ::new (
StatusCode ::FORBIDDEN ,
2026-07-14 01:46:33 +02:00
"Password registration is disabled by policy." ,
2026-02-11 00:37:47 +01:00
"PasswordRegistrationDisabled" ,
));
}
2026-02-13 16:46:59 +01:00
2026-07-14 01:46:33 +02:00
// Symmetric guard: when magic-link is off, an email-only signup has
// no path to a session (there's no token to click). Refuse rather
// than silently succeed and leave the user with an unusable account.
if dto . password . is_none ()
&& ! auth_service
. auth_application_service
. is_magic_link_login_allowed ()
{
return Err ( AppError ::new (
StatusCode ::FORBIDDEN ,
"Email-only registration requires magic-link login, which is disabled." ,
"MagicLinkLoginDisabled" ,
));
}
2026-06-02 22:50:13 +02:00
// Admin disabled public registration globally — surface 403.
2026-02-14 01:26:02 +01:00
if let Some ( admin_svc ) = state . admin_settings_service . as_ref ()
2026-02-14 01:29:34 +01:00
&& ! admin_svc . get_registration_enabled (). await
{
return Err ( AppError ::new (
StatusCode ::FORBIDDEN ,
"Public registration has been disabled by the administrator." ,
"RegistrationDisabled" ,
));
}
2026-07-13 23:37:23 +02:00
// Operator-configured allowlist of email domains that can
// self-register. Empty list = no restriction (any domain accepted).
// Distinct from `OXICLOUD_EXTERNAL_EMAIL_DOMAINS`, which gates
// magic-link / grant invitations — an operator can leave that
// permissive while locking self-registration down, or vice versa.
//
// Matching mirrors the magic-link list:
// * post-`@` part of the address is extracted and lowercased
// * case-insensitive exact match against the allowlist
// * no wildcard / subdomain expansion (list every domain
// explicitly, per the config docstring)
//
// Audit-log denials at the `audit` target so operators can spot
// enumeration / probe attempts — mirrors the shape used by the
// magic-link domain rejection at
// `magic_link_invite_service.rs`.
let allow_list = & state . core . config . auth . registration_allowed_email_domains ;
if ! allow_list . is_empty () {
let domain = dto
. email
. split ( '@' )
. nth ( 1 )
. map ( | d | d . trim (). to_ascii_lowercase ())
. unwrap_or_default ();
if domain . is_empty () || ! allow_list . iter (). any ( | d | d == & domain ) {
tracing ::info! (
target : "audit" ,
event = "auth.register_rejected" ,
reason = "domain_not_allowed" ,
domain = % domain ,
"👮🏻♂️ Public registration refused: email domain not in \
OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS"
);
return Err ( AppError ::new (
StatusCode ::FORBIDDEN ,
"Registration is not open to this email domain." ,
"RegistrationDomainNotAllowed" ,
));
}
}
2026-06-02 22:50:13 +02:00
// Email-only signup requires SMTP. Without it the welcome mail
// can't be dispatched and the user is stranded with no way to log
// in. 503 is the right response: instance-wide policy, no per-user
// oracle leaked.
let smtp_enabled = state . magic_link_invite_service . is_some ();
if dto . password . is_none () && ! smtp_enabled {
return Err ( AppError ::new (
StatusCode ::SERVICE_UNAVAILABLE ,
"Email-only registration requires SMTP to be configured on this server." ,
"SmtpRequired" ,
));
}
2026-06-02 22:26:11 +02:00
let was_passwordless = dto . password . is_none ();
let email = dto . email . clone ();
2026-06-02 22:50:13 +02:00
let result = match auth_service . auth_application_service . register ( dto ). await {
Ok ( r ) => r ,
2025-03-23 22:44:18 +01:00
Err ( err ) => {
2026-06-02 22:50:13 +02:00
tracing ::error! ( "Registration failed: {}" , err );
2026-06-02 22:26:11 +02:00
return Err ( err . into ());
2025-03-23 22:44:18 +01:00
}
2026-06-02 22:26:11 +02:00
};
2026-06-02 22:50:13 +02:00
match result {
RegisterResult ::Created ( user ) => {
2026-06-02 23:12:41 +02:00
// Email-only signup: dispatch the welcome magic-link with
// a fresh browser-binding challenge (PR 22). Best-effort —
// SMTP failures don't roll back the user.
let challenge = cookie_auth ::generate_magic_request_challenge ();
let login_ttl_secs = ( state . core . config . magic_link . login_ttl_minutes * 60 ) as i64 ;
2026-06-02 22:50:13 +02:00
if was_passwordless
&& let Some ( invite ) = state . magic_link_invite_service . as_ref ()
2026-06-02 23:12:41 +02:00
&& let Err ( e ) = invite . send_login_link ( & email , & challenge ). await
2026-06-02 22:50:13 +02:00
{
tracing ::warn! (
target : "audit" ,
event = "auth.register_welcome_mail_failed" ,
user_id = % user . id ,
email = % email ,
error = % e ,
"register: welcome magic-link send failed (user created)" ,
);
}
if smtp_enabled {
// Anti-enumeration mode: hide success-vs-collision behind
2026-06-02 23:12:41 +02:00
// the uniform "check your email" cover story. Attach the
// browser-binding challenge cookie on every email-only
// path — preserves the "did a mail go out" anti-enum
// property at the cookie level too.
let mut resp = uniform_ok ();
if was_passwordless {
cookie_auth ::append_magic_request_cookie (
resp . headers_mut (),
& challenge ,
login_ttl_secs ,
);
}
Ok ( resp )
2026-06-02 22:50:13 +02:00
} else {
2026-08-21 16:09:10 +02:00
// Classic mode: clear 201 + PublicUserDto so the frontend can
2026-06-02 22:50:13 +02:00
// log the user in directly with the password they just
// submitted. Unbox the DTO for the JSON serialisation.
Ok (( StatusCode ::CREATED , Json ( * user )). into_response ())
}
}
RegisterResult ::UsernameTaken => {
if smtp_enabled {
Ok ( uniform_ok ())
} else {
Err ( AppError ::new (
StatusCode ::CONFLICT ,
"Username is already taken" ,
"UsernameTaken" ,
))
}
}
RegisterResult ::EmailTaken => {
if smtp_enabled {
Ok ( uniform_ok ())
} else {
Err ( AppError ::new (
StatusCode ::CONFLICT ,
"Email is already registered" ,
"EmailTaken" ,
))
}
2026-06-02 22:26:11 +02:00
}
2025-03-23 22:44:18 +01:00
}
2025-03-20 09:22:31 +01:00
}
2026-05-28 00:20:21 +02:00
/// Authenticate with username and password.
///
/// On success, sets `oxicloud_access`, `oxicloud_refresh`, and `oxicloud_csrf`
/// HttpOnly cookies in addition to returning the tokens in the JSON body.
#[utoipa::path(
post,
path = "/api/auth/login" ,
request_body = LoginDto,
responses(
(status = 200, description = "Login successful — tokens in body and cookies" , body = AuthResponseDto),
(status = 401, description = "Invalid credentials or password login disabled" ),
(status = 403, description = "Account disabled" ),
(status = 429, description = "Account temporarily locked (too many failed attempts)" ),
),
tag = "auth"
)]
pub async fn login (
2025-03-20 09:22:31 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-04-27 12:22:59 -07:00
ConnectInfo ( peer ) : ConnectInfo < SocketAddr > ,
2026-04-12 01:38:19 +02:00
headers : HeaderMap ,
2025-03-20 09:22:31 +01:00
Json ( dto ) : Json < LoginDto > ,
2026-03-03 01:10:50 +01:00
) -> Result < Response , AppError > {
2025-03-23 22:44:18 +01:00
// Add detailed logging for debugging
tracing ::info! ( "Login attempt for user: {}" , dto . username );
2026-02-14 01:29:34 +01:00
// Verify auth service exists
2025-03-23 22:44:18 +01:00
let auth_service = match state . auth_service . as_ref () {
Some ( service ) => {
tracing ::info! ( "Auth service found, proceeding with login" );
service
2026-02-14 01:29:34 +01:00
}
2025-03-23 22:44:18 +01:00
None => {
tracing ::error! ( "Auth service not configured" );
2026-02-14 01:29:34 +01:00
return Err ( AppError ::internal_error (
"Authentication service not configured" ,
));
2025-03-23 22:44:18 +01:00
}
};
2026-02-10 20:32:32 +01:00
2026-03-03 01:44:39 +01:00
// ── Account lockout check ──────────────────────────────────────────
2026-04-27 12:22:59 -07:00
// Reject immediately if (this account, this IP) has too many consecutive
// failures. The IP is part of the key so an attacker flooding bad
// passwords from one address cannot lock a legitimate user out of the
// same account from a different address (issue #323). The check runs
// BEFORE Argon2 to save CPU under brute-force attacks.
let client_ip = client_ip_from_parts ( & headers , Some ( peer ), false );
2026-06-10 11:51:56 +00:00
if let Err ( lockout_secs ) = auth_service . login_lockout . check ( & dto . username , & client_ip ) {
2026-03-03 01:44:39 +01:00
tracing ::warn! (
2026-04-27 12:22:59 -07:00
target : "audit" ,
event = "auth.login" ,
reason = "account_ip_locked" ,
2026-03-03 01:44:39 +01:00
username = % dto . username ,
2026-04-27 12:22:59 -07:00
ip = % client_ip ,
2026-03-03 01:44:39 +01:00
lockout_secs = lockout_secs ,
2026-04-27 12:22:59 -07:00
"Login rejected: account temporarily locked for this IP"
2026-03-03 01:44:39 +01:00
);
return Err ( AppError ::new (
StatusCode ::TOO_MANY_REQUESTS ,
2026-03-04 23:55:08 +01:00
format! (
2026-03-03 01:44:39 +01:00
"Account temporarily locked due to too many failed attempts. Try again in {} seconds." ,
lockout_secs
),
"AccountLocked" ,
));
}
2026-07-14 01:46:33 +02:00
// Check if password login is allowed (composes the legacy OIDC-only
// flag with the newer `OXICLOUD_AUTH_METHODS` allowlist). When
// disabled, return `PasswordLoginDisabled` so the SPA can hide the
// password field and surface the available fallback (magic-link or
// SSO) instead of showing a generic "invalid credentials".
if ! auth_service
2026-02-14 01:29:34 +01:00
. auth_application_service
2026-07-14 01:46:33 +02:00
. is_password_login_allowed ()
2026-02-14 01:29:34 +01:00
{
2026-07-14 01:46:33 +02:00
return Err ( AppError ::new (
StatusCode ::FORBIDDEN ,
"Password login is disabled by policy." ,
"PasswordLoginDisabled" ,
2026-02-10 20:32:32 +01:00
));
}
2026-02-14 01:29:34 +01:00
2026-08-09 03:39:19 +02:00
// Extract the User-Agent once — the audit lines already carry
// `client_ip` on the request-scope span; passing both to the
// service lets `create_session` capture them on the row so the
// admin panel can show *who logged in from where*.
let user_agent = headers
. get ( axum ::http ::header ::USER_AGENT )
. and_then ( | v | v . to_str (). ok ())
. map ( str ::to_owned );
2025-03-23 22:44:18 +01:00
// Try the normal login process
2026-02-14 01:29:34 +01:00
match auth_service
. auth_application_service
2026-08-09 03:39:19 +02:00
. login ( dto . clone (), Some ( client_ip . clone ()), user_agent . clone ())
2026-02-14 01:29:34 +01:00
. await
{
2025-03-23 22:44:18 +01:00
Ok ( auth_response ) => {
2026-05-11 13:05:03 -07:00
// ── Successful login, reset lockout counter ──
2026-04-27 12:22:59 -07:00
auth_service
. login_lockout
. record_success ( & dto . username , & client_ip );
2026-03-03 01:44:39 +01:00
2025-03-23 22:44:18 +01:00
tracing ::info! ( "Login successful for user: {}" , dto . username );
2025-03-24 16:47:42 +01:00
// Log the response structure for debugging
tracing ::debug! ( "Auth response: {:?}" , & auth_response );
2026-02-14 01:29:34 +01:00
2025-03-24 16:47:42 +01:00
// Ensure the response has the expected fields
if auth_response . access_token . is_empty () || auth_response . refresh_token . is_empty () {
2026-02-14 01:29:34 +01:00
tracing ::error! (
"Login response contains empty tokens for user: {}" ,
dto . username
);
return Err ( AppError ::internal_error (
"Error generating authentication tokens" ,
));
2025-03-24 16:47:42 +01:00
}
2026-02-14 01:29:34 +01:00
2026-03-03 01:10:50 +01:00
// ── Set HttpOnly cookies so the browser never stores tokens in JS ──
let mut response = ( StatusCode ::OK , Json ( & auth_response )). into_response ();
cookie_auth ::append_auth_cookies (
response . headers_mut (),
& auth_response . access_token ,
& auth_response . refresh_token ,
auth_response . expires_in ,
state . core . config . auth . refresh_token_expiry_secs ,
);
2026-03-03 01:49:18 +01:00
cookie_auth ::append_csrf_cookie ( response . headers_mut (), auth_response . expires_in );
2026-08-09 14:40:10 +02:00
// Seed the SPA's DPoP-nonce cache so the first bound request
// after login doesn't eat a `use_dpop_nonce` challenge → retry.
// No-op when `dpop_mode = off`.
cookie_auth ::maybe_append_dpop_nonce_cookie (
response . headers_mut (),
& state . dpop_nonce_service ,
state . core . config . auth . dpop_mode ,
);
2026-04-12 01:38:19 +02:00
// Diagnostic: warn when Secure cookies are set but the request
2026-05-11 13:05:03 -07:00
// arrived over plain HTTP, the browser will reject them (#241).
2026-04-12 01:38:19 +02:00
if cookie_auth ::is_cookie_secure () {
let is_tls = headers
. get ( "x-forwarded-proto" )
. and_then ( | v | v . to_str (). ok ())
. is_some_and ( | p | p . eq_ignore_ascii_case ( "https" ));
if ! is_tls {
tracing ::warn! (
"Login for '{}': Secure cookies are enabled but the request \
does not appear to be over HTTPS (no X-Forwarded-Proto: https). \
The browser may reject the cookies. Set OXICLOUD_COOKIE_SECURE=false \
in .env if you access OxiCloud via plain HTTP." ,
dto . username ,
);
}
}
2026-03-03 01:10:50 +01:00
Ok ( response )
2026-02-14 01:29:34 +01:00
}
2025-03-23 22:44:18 +01:00
Err ( err ) => {
2026-03-03 01:44:39 +01:00
// ── Record failed attempt for lockout tracking ──
2026-04-27 12:22:59 -07:00
auth_service
. login_lockout
. record_failure ( & dto . username , & client_ip );
2025-03-23 22:44:18 +01:00
tracing ::error! ( "Login failed for user {}: {}" , dto . username , err );
2026-07-14 01:46:33 +02:00
// Remap the `require_verified_email` refusal (message
// string comes from AuthApplicationService::login) into a
// distinguished error_type and, critically, PIGGYBACK a
// verification link on the successful-password proof: the
// caller just showed they know the password, so we can
// safely mint a verification magic-link for their address
// without going through the anti-enum-fronted
// `magic-link/send` (which would refuse `has_password`).
//
// This branch is reached ONLY when the password validated
// successfully — the service checks `require_verified_email`
// AFTER the password check specifically so an attacker
// without the password can't discover an account's
// verification state from the response shape.
2026-08-01 20:25:56 +02:00
// Phase 4: the service refuses legacy login for
// OPAQUE-migrated users with this exact message. Remap to a
// stable `error_type` the SPA can branch on — a legit
// caller reaching this branch would already have taken the
// OPAQUE path (the SPA's login form calls
// `/api/auth/opaque/login/lookup` first), so this response
// primarily serves legacy clients and downgrade-attack
// detection. 403 keeps the shape consistent with the other
// policy refusals (PasswordLoginDisabled, EmailNotVerified).
if err . message == "Password login refused: this account has migrated to OPAQUE" {
return Err ( AppError ::new (
StatusCode ::FORBIDDEN ,
"Password login is no longer available for this account. Please sign in via the OPAQUE flow." ,
"OpaqueLoginRequired" ,
));
}
2026-07-14 01:46:33 +02:00
if err . message == "Email not verified" {
// Best-effort auto-send. We swallow any error and still
// return the same EmailNotVerified response — the
// frontend hint ("check your inbox") doubles as the
// resend affordance if delivery didn't land.
if let Some ( invite_svc ) = state . magic_link_invite_service . as_ref () {
// Re-look up the user by identifier (mirrors the
// service's login dispatch) to get the User entity
// that the verification helper needs. On any
// lookup failure we skip the send — attacker never
// sees the difference.
let lookup = if dto . username . contains ( '@' ) {
auth_service
. auth_application_service
. find_user_by_email ( & dto . username )
. await
} else {
auth_service
. auth_application_service
. find_user_by_username ( & dto . username )
. await
};
if let Ok ( user ) = lookup {
let challenge = cookie_auth ::generate_magic_request_challenge ();
let _ = invite_svc
. send_verification_link_authenticated ( & user , & challenge )
. await ;
}
}
return Err ( AppError ::new (
StatusCode ::FORBIDDEN ,
"Your email is not verified. We sent a verification link to your inbox." ,
"EmailNotVerified" ,
));
}
2025-03-23 22:44:18 +01:00
Err ( err . into ())
}
}
2025-03-20 09:22:31 +01:00
}
2026-05-28 00:20:21 +02:00
/// Refresh an access token.
///
/// Accepts the refresh token from **either**:
/// 1. JSON body `{ "refresh_token": "..." }` (API clients / backward compat)
/// 2. HttpOnly `oxicloud_refresh` cookie (browsers)
///
/// Issues new access + refresh tokens and rotates all three auth cookies.
#[utoipa::path(
post,
path = "/api/auth/refresh" ,
request_body(content = inline(RefreshTokenDto),
description = "Optional — omit when using the HttpOnly cookie" ),
responses(
(status = 200, description = "New tokens issued" , body = AuthResponseDto),
(status = 401, description = "Refresh token missing, expired, or revoked" ),
),
tag = "auth"
)]
pub async fn refresh_token (
2025-03-20 09:22:31 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-08-09 03:39:19 +02:00
ConnectInfo ( peer ) : ConnectInfo < SocketAddr > ,
2026-03-03 01:10:50 +01:00
headers : HeaderMap ,
body : axum ::body ::Bytes ,
) -> Result < Response , AppError > {
2026-02-08 13:40:23 +01:00
tracing ::info! ( "Token refresh requested" );
2026-02-14 01:29:34 +01:00
let auth_service = state
. auth_service
. as_ref ()
2026-02-12 09:41:25 +01:00
. ok_or_else ( || AppError ::internal_error ( "Authentication service not configured" )) ? ;
2026-02-14 01:29:34 +01:00
2026-03-03 01:10:50 +01:00
// Try JSON body first (backward compat), then fall back to HttpOnly cookie
let refresh_tok = serde_json ::from_slice ::< RefreshTokenDto > ( & body )
. ok ()
. map ( | dto | dto . refresh_token )
. or_else ( || cookie_auth ::extract_cookie_value ( & headers , cookie_auth ::REFRESH_COOKIE ))
. ok_or_else ( || AppError ::unauthorized ( "Refresh token required (JSON body or cookie)" )) ? ;
let dto = RefreshTokenDto {
refresh_token : refresh_tok ,
};
2026-08-09 03:39:19 +02:00
// Refresh rotates the session row — capture current IP + UA so the
// NEW row's `ip_address`/`user_agent` reflect the latest observed
// client (see `sessions.rotate_session`). Old row keeps its own
// capture from creation time.
let client_ip = client_ip_from_parts ( & headers , Some ( peer ), false );
let user_agent = headers
. get ( axum ::http ::header ::USER_AGENT )
. and_then ( | v | v . to_str (). ok ())
. map ( str ::to_owned );
2026-02-14 01:29:34 +01:00
let auth_response = auth_service
. auth_application_service
2026-08-09 03:39:19 +02:00
. refresh_token ( dto , Some ( client_ip ), user_agent )
2026-02-14 01:29:34 +01:00
. await ? ;
2025-03-31 06:20:15 +02:00
tracing ::info! ( "Token refresh successful, new token issued" );
2026-02-14 01:29:34 +01:00
2026-03-03 01:10:50 +01:00
let mut response = ( StatusCode ::OK , Json ( & auth_response )). into_response ();
cookie_auth ::append_auth_cookies (
response . headers_mut (),
& auth_response . access_token ,
& auth_response . refresh_token ,
auth_response . expires_in ,
state . core . config . auth . refresh_token_expiry_secs ,
);
2026-03-03 01:49:18 +01:00
cookie_auth ::append_csrf_cookie ( response . headers_mut (), auth_response . expires_in );
2026-08-09 14:40:10 +02:00
// Seed the SPA's DPoP-nonce cache — see the login handler above.
cookie_auth ::maybe_append_dpop_nonce_cookie (
response . headers_mut (),
& state . dpop_nonce_service ,
state . core . config . auth . dpop_mode ,
);
2026-03-03 01:10:50 +01:00
Ok ( response )
2025-03-20 09:22:31 +01:00
}
2026-06-07 01:13:58 +02:00
/// Return the authenticated user's profile, including cached storage usage.
2026-05-28 00:20:21 +02:00
#[utoipa::path(
get,
path = "/api/auth/me" ,
responses(
2026-08-21 15:42:22 +02:00
(status = 200, description = "Current user profile" , body = SelfUserDto),
2026-05-28 00:20:21 +02:00
(status = 401, description = "Not authenticated" ),
),
security(( "bearerAuth" = [])),
tag = "auth"
)]
pub async fn get_current_user (
2025-03-20 09:22:31 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-08-09 12:04:45 +02:00
auth_user : AuthUser ,
2025-03-20 09:22:31 +01:00
) -> Result < impl IntoResponse , AppError > {
2026-08-09 12:04:45 +02:00
let user_id = auth_user . id ;
2026-02-14 01:29:34 +01:00
let auth_service = state
. auth_service
. as_ref ()
2026-02-12 09:41:25 +01:00
. ok_or_else ( || AppError ::internal_error ( "Authentication service not configured" )) ? ;
2026-02-14 01:29:34 +01:00
2026-06-07 01:13:58 +02:00
// Storage usage is served from the cached `storage_used_bytes` column —
// it is NOT recomputed here. Recomputing on this hot endpoint meant an
2026-06-26 00:30:09 +02:00
// O(N) `SUM(size)` plus an `UPDATE` of `auth.users` on every single call
// (one of the most frequent endpoints). The cached value is kept current
// by the per-upload update and a periodic background reconciliation sweep
2026-06-07 01:13:58 +02:00
// (see `StorageUsageService::start_reconciliation_job`).
2026-06-26 00:30:09 +02:00
//
// Semantics (`docs/plan/drive.md` §7): `storage_used_bytes` is the SUM
// of `used_bytes` across the user's personal drives only. Shared drives
// never count against this envelope — collaborating in a team drive
2026-08-21 23:19:00 +02:00
// costs no personal bytes.
2026-08-21 15:42:22 +02:00
//
2026-08-21 23:19:00 +02:00
// Delegate to the shared `build_self_user_dto_for_id` — same code
// path `PATCH /me/profile` and `POST /upgrade-to-internal` use so
// all three self endpoints ship byte-for-byte identical shapes.
// The DPoP-bound signal comes from the JWT `cnf.jkt` claim
// (surfaced by the auth middleware into `AuthUser.dpop_jkt`);
// when present the session that minted this JWT is bound and
// the SPA can skip a redundant `/dpop/bind` call.
let self_dto = auth_service
2026-02-14 01:29:34 +01:00
. auth_application_service
2026-08-21 23:19:00 +02:00
. build_self_user_dto_for_id ( user_id , auth_user . dpop_jkt . is_some ())
2026-02-14 01:29:34 +01:00
. await ? ;
2026-08-21 15:42:22 +02:00
Ok (( StatusCode ::OK , Json ( self_dto )))
2025-03-20 09:22:31 +01:00
}
2026-05-26 00:50:38 +02:00
/// DTO for updating the user's profile image.
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateUserImageDto {
2026-05-26 01:50:59 +02:00
/// Image URL (https/http) or data URI (data:image/png|webp|jpeg;base64,…). Null to clear.
2026-05-26 00:50:38 +02:00
pub image : Option < String > ,
}
2026-05-28 00:20:21 +02:00
/// Change the current user's password.
#[utoipa::path(
put,
path = "/api/auth/change-password" ,
request_body = ChangePasswordDto,
responses(
(status = 200, description = "Password changed successfully" ),
(status = 400, description = "New password does not meet requirements" ),
(status = 401, description = "Not authenticated or current password incorrect" ),
),
security(( "bearerAuth" = [])),
tag = "auth"
)]
pub async fn change_password (
2025-03-20 09:22:31 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-08-05 23:35:05 +02:00
ConnectInfo ( peer ) : ConnectInfo < SocketAddr > ,
headers : HeaderMap ,
2026-03-03 01:10:50 +01:00
CurrentUserId ( user_id ) : CurrentUserId ,
2025-03-20 09:22:31 +01:00
Json ( dto ) : Json < ChangePasswordDto > ,
) -> Result < impl IntoResponse , AppError > {
2026-02-14 01:29:34 +01:00
let auth_service = state
. auth_service
. as_ref ()
2026-02-12 09:41:25 +01:00
. ok_or_else ( || AppError ::internal_error ( "Authentication service not configured" )) ? ;
2026-02-14 01:29:34 +01:00
2026-08-05 23:35:05 +02:00
// Resolve the CURRENT session id from the refresh-token cookie so
// the service layer can revoke every OTHER session (classic
// password-change security posture) while keeping THIS session
// alive — the SPA needs to hit `/api/auth/opaque/register/*`
// immediately after this response to re-mint the OPAQUE envelope
// under the new password. If we revoked the current session too
// (the old behaviour), the follow-up register requests would 401
// silently and the envelope would stay bound to the OLD password.
//
// Best-effort: an unauthenticated or cookie-less caller (a CLI
// hitting this endpoint with just a bearer, no refresh cookie)
// falls back to `None` → the service revokes ALL sessions, same
// as the pre-refactor behaviour. That's the safer default when we
// can't identify "this" session.
let keep_session_id : Option < Uuid > = {
let refresh_tok = cookie_auth ::extract_cookie_value ( & headers , cookie_auth ::REFRESH_COOKIE );
match refresh_tok {
Some ( tok ) => auth_service
. auth_application_service
. get_session_id_by_refresh_token ( & tok )
. await
. ok ()
. flatten (),
None => None ,
}
};
2026-08-04 21:05:08 +02:00
match auth_service
2026-02-14 01:29:34 +01:00
. auth_application_service
2026-08-05 23:35:05 +02:00
. change_password ( user_id , dto , keep_session_id )
2026-08-04 21:05:08 +02:00
. await
{
2026-08-05 23:35:05 +02:00
Ok (()) => {
// Fire-and-forget security notification: password changed
// at [now] from [client_ip]. Reaches the user out-of-band
// so a compromised-account victim can notice and alert
// their admin. SMTP delivery failures don't affect the
// 200 response (the change already succeeded); the
// service's own audit log tracks send outcomes.
//
// Runs on a background task so a slow SMTP handshake
// (30-60 s under a marginal mail server) can't stall the
// response to the SPA. Cloning the `Arc<MagicLinkInviteService>`
// is a refcount bump; the User entity is re-fetched inside
// the task from the same user_id we just verified.
if let Some ( invite_svc ) = state . magic_link_invite_service . as_ref () {
let client_ip = crate ::interfaces ::middleware ::trusted_proxy ::client_ip_from_parts (
& headers ,
Some ( peer ),
false ,
)
. to_string ();
let invite = invite_svc . clone ();
let auth = auth_service . auth_application_service . clone ();
tokio ::spawn ( async move {
// Refetch the user entity fresh — the change we
// just made rewrote the row (password_hash), and
// the notification method reads `is_active` +
// `is_oidc_user` + `email` off the entity to
// decide whether to send and where.
match auth . get_user_entity ( user_id ). await {
Ok ( u ) => {
let _ = invite
. send_password_changed_notification ( & u , & client_ip )
. await ;
}
Err ( e ) => {
tracing ::warn! (
target : "audit" ,
event = "auth.password_changed_notification_lookup_failed" ,
user_id = % user_id ,
error = % e . message ,
"🔔 skipped notification: could not re-fetch user after change_password"
);
}
}
});
}
Ok ( StatusCode ::OK )
}
2026-08-04 21:05:08 +02:00
Err ( err ) => {
// Remap the same-as-current guard into a stable error_type
// the SPA can surface as "pick a different one" without
// needing to fall back to the generic 400 message. The
// service returns InvalidInput; keep the 400 status but
// swap the shape.
if err . message == "New password must differ from the current password" {
return Err ( AppError ::new (
StatusCode ::BAD_REQUEST ,
"New password must differ from the current password" ,
"PasswordUnchanged" ,
));
}
Err ( err . into ())
}
}
2025-03-20 09:22:31 +01:00
}
2026-07-14 04:12:48 +02:00
/// Convert the authenticated external user into a full internal
/// account. The caller must currently be `is_external = true`; on
/// success, `is_external` is flipped to `false`, a personal drive is
/// provisioned (atomic CTE via `PersonalDriveLifecycleHook`), and the
/// user's flags cache is invalidated so subsequent per-request guards
/// see the new state within cache-round-trip time.
///
/// Password policy:
/// * If the deployment offers magic-link login
/// (`OXICLOUD_AUTH_METHODS` includes `magic_link` AND OIDC is not
/// enabled AND SMTP is wired), the body's `password` field is
/// optional — an upgraded user without a password stays magic-
/// link-only for login.
/// * Otherwise, `password` is required — refused with 400
/// `error_type = "PasswordRequired"`.
///
/// Domain gate: the caller's email domain MUST be in
/// `OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` (when non-empty).
/// Otherwise invitations would become a bypass of the operator's
/// self-registration policy. Refused with 403
/// `error_type = "RegistrationDomainNotAllowed"`.
///
2026-08-21 23:19:00 +02:00
/// Response: the updated `SelfUserDto` (same shape as `GET /me`) so the SPA
/// absorbs the post-upgrade state — new `storage_quota_bytes`,
/// `is_external = false`, updated OPAQUE / auth capability flags — in one
/// round trip without a follow-up `/me` fetch.
2026-07-14 04:12:48 +02:00
#[utoipa::path(
post,
path = "/api/auth/upgrade-to-internal" ,
request_body = UpgradeToInternalDto,
responses(
2026-08-21 23:19:00 +02:00
(status = 200, description = "Upgrade succeeded — returns SelfUserDto (same shape as GET /me)" , body = SelfUserDto),
2026-07-14 04:12:48 +02:00
(status = 400, description = "Password missing / too short" ),
(status = 401, description = "Not authenticated" ),
(status = 403, description = "OIDC user, or domain not in allowlist" ),
(status = 409, description = "Already internal" ),
),
security(( "bearerAuth" = [])),
tag = "auth"
)]
pub async fn upgrade_to_internal (
State ( state ) : State < Arc < AppState >> ,
2026-08-21 23:19:00 +02:00
auth_user : AuthUser ,
2026-07-14 04:12:48 +02:00
Json ( dto ) : Json < UpgradeToInternalDto > ,
) -> Result < impl IntoResponse , AppError > {
2026-08-21 23:19:00 +02:00
let user_id = auth_user . id ;
2026-07-14 04:12:48 +02:00
let auth_service = state
. auth_service
. as_ref ()
. ok_or_else ( || AppError ::internal_error ( "Authentication service not configured" )) ? ;
// Domain gate. Mirrors the register handler
// (`OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS`). Rationale: an
// internal-user invitation must NOT become a way around the
// operator's self-registration policy. If a domain isn't
// allowlisted for register, it shouldn't be allowed for upgrade
// either. External users on non-allowlisted domains remain
// external — they can still act on shared resources but never own
// a drive of their own on this deployment.
let allow_list = & state . core . config . auth . registration_allowed_email_domains ;
if ! allow_list . is_empty () {
// The service re-fetches the user inside `upgrade_to_internal`;
// one extra id-lookup here just to extract the email is cheap
// and keeps the domain check at the same layer as the register
// handler for consistency.
let email = auth_service
. auth_application_service
. get_user_by_id ( user_id )
. await
. map ( | dto | dto . email ) ? ;
let domain = email
. split ( '@' )
. nth ( 1 )
. map ( | d | d . trim (). to_ascii_lowercase ())
. unwrap_or_default ();
if domain . is_empty () || ! allow_list . iter (). any ( | d | d == & domain ) {
tracing ::info! (
target : "audit" ,
event = "user.upgrade_rejected" ,
reason = "domain_not_allowed" ,
user_id = % user_id ,
domain = % domain ,
"👮🏻♂️ upgrade refused: email domain not in \
OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS"
);
return Err ( AppError ::new (
StatusCode ::FORBIDDEN ,
"This deployment does not accept new accounts from your email domain." ,
"RegistrationDomainNotAllowed" ,
));
}
}
2026-08-21 23:19:00 +02:00
// Apply the upgrade. Service returns the updated `PublicUserDto`;
// we discard it and rebuild the full self view via the shared
// `build_self_user_dto_for_id` helper so the wire shape matches
// `GET /me` and `PATCH /me/profile` byte-for-byte.
let _ = auth_service
2026-07-14 04:12:48 +02:00
. auth_application_service
. upgrade_to_internal ( user_id , dto )
. await
. map_err ( | err | match err . message . as_str () {
"Account is already internal" => {
AppError ::new ( StatusCode ::CONFLICT , err . message . clone (), "AlreadyInternal" )
}
"SSO/OIDC accounts are managed by your identity provider" => {
AppError ::new ( StatusCode ::FORBIDDEN , err . message . clone (), "ManagedByIdP" )
}
m if m . starts_with ( "Password is required" ) => AppError ::new (
StatusCode ::BAD_REQUEST ,
err . message . clone (),
"PasswordRequired" ,
),
_ => AppError ::from ( err ),
}) ? ;
2026-08-21 23:19:00 +02:00
let self_dto = auth_service
. auth_application_service
. build_self_user_dto_for_id ( user_id , auth_user . dpop_jkt . is_some ())
. await ? ;
Ok (( StatusCode ::OK , Json ( self_dto )))
2026-07-14 04:12:48 +02:00
}
2026-06-03 00:19:31 +02:00
/// Update the caller's profile (PR 24).
///
/// Fields are individually optional — absent = no change. Username is
/// **claim-once, immutable**: passing `username` when the caller
/// already has one is rejected with 409 (the DAV / NextCloud path
/// surface bakes username in as a stable identifier; renaming would
/// break clients). Given / family name are freely settable.
///
/// OIDC-linked users are rejected wholesale with 403 — their profile
/// is owned by the IdP.
#[utoipa::path(
patch,
path = "/api/auth/me/profile" ,
request_body = crate::application::dtos::user_dto::UpdateProfileDto,
responses(
2026-08-21 23:19:00 +02:00
(status = 200, description = "Updated profile (SelfUserDto) — same shape as GET /me so the SPA sees the just-written state without a follow-up fetch" , body = SelfUserDto),
2026-06-03 00:19:31 +02:00
(status = 400, description = "Validation error (e.g. invalid handle format, empty given_name)" ),
(status = 401, description = "Not authenticated" ),
(status = 403, description = "OIDC-managed profile — edit at the IdP" ),
(status = 409, description = "Username already claimed (immutable) or taken by another user" ),
),
security(( "bearerAuth" = [])),
tag = "auth"
)]
pub async fn update_profile (
State ( state ) : State < Arc < AppState >> ,
2026-08-21 23:19:00 +02:00
auth_user : AuthUser ,
2026-06-03 00:19:31 +02:00
Json ( dto ) : Json < crate ::application ::dtos ::user_dto ::UpdateProfileDto > ,
) -> Result < impl IntoResponse , AppError > {
2026-08-21 23:19:00 +02:00
let user_id = auth_user . id ;
2026-06-03 00:19:31 +02:00
let auth_service = state
. auth_service
. as_ref ()
. ok_or_else ( || AppError ::internal_error ( "Authentication service not configured" )) ? ;
2026-08-21 23:19:00 +02:00
// Apply the patch. The service returns the updated `PublicUserDto`
// internally; we discard it and re-fetch the full self view below
// so the response matches `GET /me`'s `SelfUserDto` shape.
//
// Why SelfUserDto instead of PublicUserDto: a self-write endpoint
// whose response mirrors GET /me lets the SPA update its session
// store in one round trip. Returning a slim PublicUserDto would
// force the SPA to follow up with GET /me anyway to observe the
// just-written `ui_preferences` / `notify_on_share` / etc — those
// fields live on SelfUserDto only, not on the public identity
// slice. Same shape for both endpoints avoids "quiet lie" reads
// where a client PATCHes and then reads a stale local value.
let _ = auth_service
2026-06-03 00:19:31 +02:00
. auth_application_service
2026-06-03 14:26:45 +02:00
. update_profile_with_perms ( user_id , dto , & state . locale_registry )
2026-06-03 00:19:31 +02:00
. await ? ;
2026-08-21 23:19:00 +02:00
// Rebuild via the shared helper so the wire shape matches
// `GET /me` and `POST /upgrade-to-internal` byte-for-byte.
let self_dto = auth_service
. auth_application_service
. build_self_user_dto_for_id ( user_id , auth_user . dpop_jkt . is_some ())
. await ? ;
Ok (( StatusCode ::OK , Json ( self_dto )))
2026-06-03 00:19:31 +02:00
}
2026-05-28 00:20:21 +02:00
// TODO: add utoipa
2026-05-26 00:50:38 +02:00
pub async fn update_user_image (
State ( state ) : State < Arc < AppState >> ,
CurrentUserId ( user_id ) : CurrentUserId ,
Json ( dto ) : Json < UpdateUserImageDto > ,
) -> impl IntoResponse {
let auth_service = match state . auth_service . as_ref () {
Some ( svc ) => svc ,
None => {
return AppError ::internal_error ( "Authentication service not configured" )
. into_response ();
}
};
match auth_service
. auth_application_service
. update_user_image ( user_id , dto . image )
. await
{
Ok ( _ ) => StatusCode ::OK . into_response (),
Err ( e ) => AppError ::from ( e ). into_response (),
}
}
2026-05-28 00:20:21 +02:00
/// Revoke the current session and clear auth cookies.
///
/// Accepts the refresh token from **either** a JSON body
/// `{ "refresh_token": "..." }` (API clients) or the `oxicloud_refresh`
/// HttpOnly cookie (browsers).
#[utoipa::path(
post,
path = "/api/auth/logout" ,
request_body(content = inline(RefreshTokenDto),
description = "Optional — omit when using the HttpOnly cookie" ),
responses(
(status = 200, description = "Logged out, auth cookies cleared" ),
(status = 401, description = "Not authenticated or refresh token missing" ),
),
security(( "bearerAuth" = [])),
tag = "auth"
)]
pub async fn logout (
2025-03-20 09:22:31 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-03-03 01:10:50 +01:00
CurrentUserId ( user_id ) : CurrentUserId ,
2026-03-05 10:30:39 +01:00
headers : HeaderMap ,
body : axum ::body ::Bytes ,
2026-03-03 01:10:50 +01:00
) -> Result < Response , AppError > {
2026-02-14 01:29:34 +01:00
let auth_service = state
. auth_service
. as_ref ()
2026-02-12 09:41:25 +01:00
. ok_or_else ( || AppError ::internal_error ( "Authentication service not configured" )) ? ;
2026-02-14 01:29:34 +01:00
2026-03-05 10:30:39 +01:00
// Extract the REFRESH token (not the access token) so the service can
// look up and revoke the correct session.
// Strategy: try JSON body first (API clients), then HttpOnly cookie (browsers).
let refresh_token = serde_json ::from_slice ::< RefreshTokenDto > ( & body )
. ok ()
. map ( | dto | dto . refresh_token )
. or_else ( || cookie_auth ::extract_cookie_value ( & headers , cookie_auth ::REFRESH_COOKIE ))
2026-03-05 21:28:51 +01:00
. ok_or_else ( || {
AppError ::unauthorized ( "Refresh token required for logout (JSON body or cookie)" )
}) ? ;
2026-02-14 01:29:34 +01:00
2026-08-03 01:17:16 +02:00
// Post-logout redirect URI = OxiCloud's `/login`. Must be registered on
// the OIDC client (Keycloak: "Valid post logout redirect URIs"), else
// the IdP will refuse the redirect and strand the user on its error page.
let post_logout_redirect_uri = format! ( " {} /login" , state . core . config . base_url ());
let post_logout_url = auth_service
2026-02-14 01:29:34 +01:00
. auth_application_service
2026-08-03 01:17:16 +02:00
. logout ( user_id , & refresh_token , & post_logout_redirect_uri )
2026-02-14 01:29:34 +01:00
. await ? ;
2026-03-03 01:10:50 +01:00
// Clear HttpOnly + CSRF cookies so the browser forgets the session
2026-08-03 01:17:16 +02:00
// regardless of whether we also redirect to the IdP.
let body = post_logout_url
. map ( | url | serde_json ::json! ({ "post_logout_url" : url }))
. unwrap_or_else ( || serde_json ::json! ({}));
let mut response = ( StatusCode ::OK , axum ::Json ( body )). into_response ();
2026-03-03 01:10:50 +01:00
cookie_auth ::append_clear_cookies ( response . headers_mut ());
cookie_auth ::append_clear_csrf_cookie ( response . headers_mut ());
Ok ( response )
2025-03-20 09:22:31 +01:00
}
2026-08-08 14:15:26 +02:00
/// Post-redirect DPoP bind DTO — only field is the JWK thumbprint.
#[derive(Debug, serde::Deserialize, ToSchema)]
pub struct DpopBindDto {
/// Base64url SHA-256 of the canonical public-key JWK (RFC 7638) —
/// exactly 43 characters, `[A-Za-z0-9_-]`.
#[serde(rename = "dpop_jkt" , alias = "dpopJkt" )]
pub dpop_jkt : String ,
}
/// One-shot bind a DPoP JWK thumbprint to the caller's current session.
///
/// Purpose: post-redirect flows (OIDC callback, magic-link redemption)
/// create the session before the SPA has a chance to send its DPoP
/// keypair thumbprint. The SPA calls this endpoint immediately after
/// the redirect lands, so the session graduates from unbound to bound
/// before the first authenticated `/api/*` request.
///
/// Contract:
/// * 200 on success — session now carries the thumbprint.
/// * 400 if the thumbprint is malformed (wrong length / non-base64url).
/// * 409 if the session already carries a thumbprint (anti-downgrade
/// invariant per `docs/plan/dpop.md` — a bound session cannot be
/// re-bound to a different key).
/// * 401 if no session (auth middleware layer emits this).
#[utoipa::path(
post,
path = "/api/auth/dpop/bind" ,
request_body = DpopBindDto,
responses(
(status = 200, description = "Thumbprint bound" ),
(status = 400, description = "Malformed thumbprint" ),
(status = 401, description = "Not authenticated" ),
(status = 409, description = "Session already bound" ),
),
security(( "bearerAuth" = [])),
tag = "auth"
)]
pub async fn dpop_bind (
State ( state ) : State < Arc < AppState >> ,
CurrentUserId ( _user_id ) : CurrentUserId ,
headers : HeaderMap ,
Json ( dto ) : Json < DpopBindDto > ,
) -> Result < StatusCode , AppError > {
let auth = state
. auth_service
. as_ref ()
. ok_or_else ( || AppError ::internal_error ( "Authentication service not configured" )) ? ;
// The auth middleware validates the access token but doesn't
// expose the session id. Look it up via the refresh cookie —
// same shape logout uses. Refresh cookie is HttpOnly + SameSite,
// so an attacker who has the access token but not the refresh
// cookie (theft window: seconds between token mint and refresh
// cookie install) simply gets 400.
let refresh_token = cookie_auth ::extract_cookie_value ( & headers , cookie_auth ::REFRESH_COOKIE )
. ok_or_else ( || AppError ::unauthorized ( "Refresh cookie required to identify session" )) ? ;
let session_id = auth
. auth_application_service
. get_session_id_by_refresh_token ( & refresh_token )
. await ?
. ok_or_else ( || AppError ::unauthorized ( "Session not found" )) ? ;
auth . auth_application_service
. bind_dpop_jkt_to_session ( session_id , & dto . dpop_jkt )
. await ? ;
Ok ( StatusCode ::OK )
}
2026-08-03 08:00:13 +02:00
/// OIDC Back-Channel Logout 1.0 receiver.
///
/// The IdP POSTs a signed `logout_token` JWT here when a user's SSO
/// session ends (they logged out elsewhere, admin revoked the session,
/// account was disabled). Body is `application/x-www-form-urlencoded`
/// per spec §2.5 with a single `logout_token` field.
///
/// Response codes are constrained by the spec (§2.8):
/// - 200 on successful processing (including a validated token that
/// matched no OxiCloud sessions — the notification is still "handled").
/// - 400 on any validation failure (bad signature, expired, missing
/// required claims, replay). We do NOT return 200 with an error body.
///
/// This endpoint is public: no auth middleware, no CSRF, no cookie.
/// The `logout_token` signature IS the authentication — an unsigned or
/// wrongly-signed token gets rejected by the OIDC service validator.
#[utoipa::path(
post,
path = "/api/auth/oidc/backchannel-logout" ,
request_body(
content_type = "application/x-www-form-urlencoded" ,
description = "Form-encoded body with a single `logout_token` field (the signed JWT from the IdP)" ,
),
responses(
(status = 200, description = "Logout notification accepted (0 or more sessions revoked)" ),
(status = 400, description = "logout_token missing, malformed, or failed validation" ),
(status = 503, description = "OIDC not configured on this deployment" ),
),
tag = "auth"
)]
pub async fn oidc_backchannel_logout (
State ( state ) : State < Arc < AppState >> ,
axum ::Form ( form ) : axum ::Form < BackchannelLogoutForm > ,
) -> Response {
let auth_service = match state . auth_service . as_ref () {
Some ( s ) => s ,
None => {
return (
StatusCode ::SERVICE_UNAVAILABLE ,
axum ::Json ( serde_json ::json! ({
"error" : "authentication_not_configured" ,
"error_description" : "OIDC is not enabled on this deployment"
})),
)
. into_response ();
}
};
match auth_service
. auth_application_service
. backchannel_logout ( & form . logout_token )
. await
{
Ok ( revoked ) => {
tracing ::info! (
target : "audit" ,
event = "oidc.backchannel_logout_accepted" ,
revoked_count = revoked ,
"👮🏻♂️ OIDC backchannel-logout accepted"
);
// Spec §2.8: response body has no defined content. Empty JSON
// object keeps content-type coherent and is cheap for the IdP
// to skip.
( StatusCode ::OK , axum ::Json ( serde_json ::json! ({}))). into_response ()
}
Err ( e ) => {
// Log the real reason for operators; return a spec-compliant
// 400 with a minimal error payload (spec §2.8 recommends
// `application/json` with `error` + `error_description` per
// OAuth 2.0 error style).
tracing ::warn! (
target : "audit" ,
event = "oidc.backchannel_logout_rejected" ,
reason = % e ,
"👮🏻♂️ OIDC backchannel-logout rejected"
);
(
StatusCode ::BAD_REQUEST ,
axum ::Json ( serde_json ::json! ({
"error" : "invalid_request" ,
"error_description" : "logout_token validation failed"
})),
)
. into_response ()
}
}
}
/// Form body carried by OIDC Back-Channel Logout notifications.
#[derive(Debug, serde::Deserialize)]
pub struct BackchannelLogoutForm {
pub logout_token : String ,
}
2026-05-28 00:20:21 +02:00
/// One-time endpoint to create the first admin user.
2026-03-04 14:14:40 +01:00
///
2026-03-05 22:12:21 +01:00
/// Available only when the system is not yet initialized (no admin exists).
2026-05-28 00:20:21 +02:00
/// Once the admin is created the endpoint permanently returns 403.
/// Uses an atomic "claim" operation so concurrent requests cannot both succeed.
#[utoipa::path(
post,
path = "/api/setup" ,
request_body = SetupAdminDto,
responses(
2026-08-21 16:09:10 +02:00
(status = 201, description = "First admin created and system initialized" , body = PublicUserDto),
2026-05-28 00:20:21 +02:00
(status = 403, description = "System already initialized" ),
(status = 503, description = "Auth service not configured" ),
),
tag = "auth"
)]
pub async fn setup_admin (
2026-03-04 14:14:40 +01:00
State ( state ) : State < Arc < AppState >> ,
Json ( dto ) : Json < SetupAdminDto > ,
) -> Result < impl IntoResponse , AppError > {
tracing ::info! ( "Setup admin request received for user: {}" , dto . username );
// 1. Verify auth service exists
let auth_service = state
. auth_service
. as_ref ()
. ok_or_else ( || AppError ::internal_error ( "Authentication service not configured" )) ? ;
2026-03-05 16:09:37 +01:00
// 2. Verify admin settings service exists
2026-03-04 14:14:40 +01:00
let admin_svc = state
. admin_settings_service
. as_ref ()
. ok_or_else ( || AppError ::internal_error ( "Admin settings service not configured" )) ? ;
2026-03-05 16:09:37 +01:00
// 3. Quick pre-check: if the system is already initialized, reject early
2026-03-05 22:12:21 +01:00
// (avoids Argon2 work on obviously-late requests)
2026-03-04 14:14:40 +01:00
if admin_svc . is_system_initialized (). await {
tracing ::warn! (
"Setup admin rejected: system already initialized (user: {})" ,
dto . username
);
return Err ( AppError ::new (
StatusCode ::FORBIDDEN ,
"System is already initialized. Use the admin panel to manage users." ,
"SystemAlreadyInitialized" ,
));
}
2026-05-11 13:05:03 -07:00
// 4. ATOMIC: claim initialization, only one concurrent request can win.
2026-03-07 14:59:32 +01:00
// We use Uuid::nil() as a placeholder because the admin user
2026-03-05 22:12:21 +01:00
// doesn't exist yet. It will be updated to the real id below.
2026-03-05 16:09:37 +01:00
let claimed = admin_svc
2026-03-07 14:59:32 +01:00
. try_claim_initialization ( Uuid ::nil ())
2026-03-05 16:09:37 +01:00
. await
. map_err ( | e | {
tracing ::error! ( "Failed to claim system initialization: {}" , e );
AppError ::internal_error ( "Failed to claim system initialization" )
}) ? ;
if ! claimed {
tracing ::warn! (
"Setup admin rejected: another request already claimed initialization (user: {})" ,
dto . username
);
return Err ( AppError ::new (
StatusCode ::FORBIDDEN ,
"System is already initialized. Use the admin panel to manage users." ,
"SystemAlreadyInitialized" ,
));
}
2026-03-05 22:12:21 +01:00
// 5. Create the first admin user (we hold the exclusive claim)
2026-03-04 14:14:40 +01:00
let user = auth_service
. auth_application_service
. setup_create_admin ( dto . username . clone (), dto . email , dto . password )
. await
. map_err ( | e | {
tracing ::error! ( "Setup admin creation failed: {}" , e );
AppError ::from ( e )
}) ? ;
2026-03-05 22:12:21 +01:00
// 5. Update the initialization record with the real admin user_id
2026-03-07 14:59:32 +01:00
let real_user_id = Uuid ::parse_str ( & user . id ). unwrap_or_default ();
if let Err ( e ) = admin_svc . mark_system_initialized ( real_user_id ). await {
2026-05-11 13:05:03 -07:00
// Not fatal, the claim already prevents concurrent re-initialization,
2026-03-05 16:09:37 +01:00
// and the "pending" marker is still "true" so the system stays locked.
2026-03-04 14:14:40 +01:00
tracing ::error! (
2026-03-05 16:09:37 +01:00
"Created admin but failed to update initialized_by with real user id: {}" ,
2026-03-04 14:14:40 +01:00
e
);
}
tracing ::info! (
"System initialized: first admin '{}' created successfully" ,
dto . username
);
Ok (( StatusCode ::CREATED , Json ( user )))
}
2026-05-28 00:20:21 +02:00
/// System initialisation state, returned by `GET /api/auth/status`.
#[derive(serde::Serialize, ToSchema)]
pub struct SystemStatus {
/// Whether the system has been set up with an admin.
2026-02-03 17:59:04 +01:00
initialized : bool ,
2026-05-28 00:20:21 +02:00
/// Number of admin users in the system.
2026-02-03 17:59:04 +01:00
admin_count : i64 ,
2026-05-28 00:20:21 +02:00
/// Whether self-registration is allowed.
2026-02-03 17:59:04 +01:00
registration_allowed : bool ,
}
2026-05-28 00:20:21 +02:00
/// Return the system initialisation state (used by the UI before setup).
#[utoipa::path(
get,
path = "/api/auth/status" ,
responses(
(status = 200, description = "System status" , body = SystemStatus),
(status = 503, description = "Auth service not configured" ),
),
tag = "auth"
)]
pub async fn get_system_status (
2026-02-03 17:59:04 +01:00
State ( state ) : State < Arc < AppState >> ,
) -> Result < impl IntoResponse , AppError > {
2026-02-14 01:29:34 +01:00
let auth_service = state
. auth_service
. as_ref ()
2026-02-12 09:41:25 +01:00
. ok_or_else ( || AppError ::internal_error ( "Authentication service not configured" )) ? ;
2026-02-14 01:29:34 +01:00
2026-03-04 14:14:40 +01:00
// Use the DB flag as the authoritative source for initialization status
let db_initialized = if let Some ( admin_svc ) = state . admin_settings_service . as_ref () {
admin_svc . is_system_initialized (). await
} else {
false
};
// Count admin users for additional info
2026-02-14 01:29:34 +01:00
let admin_count = auth_service
. auth_application_service
. count_admin_users ()
. await
2026-02-03 17:59:04 +01:00
. unwrap_or ( 0 );
2026-02-14 01:29:34 +01:00
2026-02-03 17:59:04 +01:00
let status = SystemStatus {
2026-03-04 14:14:40 +01:00
initialized : db_initialized || admin_count > 0 ,
2026-02-03 17:59:04 +01:00
admin_count ,
2026-03-04 14:14:40 +01:00
registration_allowed : db_initialized || admin_count > 0 ,
2026-02-03 17:59:04 +01:00
};
2026-02-14 01:29:34 +01:00
tracing ::info! (
"System status check: initialized={}, admin_count={}" ,
status . initialized ,
status . admin_count
);
2026-02-03 17:59:04 +01:00
Ok (( StatusCode ::OK , Json ( status )))
}
2026-02-10 20:32:32 +01:00
2026-03-04 14:02:15 +01:00
// ============================================================================
2026-02-10 20:32:32 +01:00
// ============================================================================
// OIDC Handlers
// ============================================================================
2026-05-28 00:20:21 +02:00
/// Return OIDC provider information for the login UI.
///
/// Returns `enabled: false` when OIDC is not configured.
#[utoipa::path(
get,
path = "/api/auth/oidc/providers" ,
responses(
(status = 200, description = "OIDC provider info (enabled=false when OIDC not configured)" , body = OidcProviderInfoDto),
(status = 503, description = "Auth service not configured" ),
),
tag = "auth"
)]
2026-05-27 12:43:07 +02:00
pub async fn oidc_providers (
State ( state ) : State < Arc < AppState >> ,
) -> Result < impl IntoResponse , AppError > {
2026-02-14 01:29:34 +01:00
let auth_service = state
. auth_service
. as_ref ()
2026-02-10 20:32:32 +01:00
. ok_or_else ( || AppError ::internal_error ( "Auth service not configured" )) ? ;
let auth_app = & auth_service . auth_application_service ;
2026-07-14 01:46:33 +02:00
// Policy questions the SPA needs to decide which forms to render.
// `is_magic_link_login_allowed()` composes SMTP wiring + allowlist +
// the "OIDC master → no magic-link login" hard rule; the login page
// shows the magic-link tab iff this is true.
let password_login_enabled = auth_app . is_password_login_allowed ();
let magic_link_login_enabled = auth_app . is_magic_link_login_allowed ();
let require_verified_email = auth_app . require_verified_email ();
2026-08-03 00:22:19 +02:00
let auto_redirect_to_oidc = auth_app . auto_redirect_to_oidc ();
2026-07-14 01:46:33 +02:00
2026-02-10 20:32:32 +01:00
if ! auth_app . oidc_enabled () {
return Ok ( Json ( OidcProviderInfoDto {
enabled : false ,
2026-08-08 15:24:06 +02:00
issuer : String ::new (),
2026-02-10 20:32:32 +01:00
provider_name : String ::new (),
authorize_endpoint : String ::new (),
2026-07-14 01:46:33 +02:00
password_login_enabled ,
magic_link_login_enabled ,
require_verified_email ,
2026-08-03 00:22:19 +02:00
auto_redirect_to_oidc : false ,
2026-02-10 20:32:32 +01:00
}));
}
let config = auth_app . oidc_config (). unwrap ();
2026-08-08 15:24:06 +02:00
// Prefer the DISCOVERY document's issuer — that's what
// OidcService uses to validate id_tokens AND what lands on
// `auth.users.federation_issuer` at JIT provisioning / lazy
// rebind. The config's `issuer_url` is only what the operator
// typed to point at discovery; the discovery document publishes
// the authoritative value (may differ by trailing slash, host
// casing, etc). **Cache-only lookup on purpose** — this
// endpoint is PUBLIC + UNAUTHENTICATED, so triggering an IdP
// HTTP fetch per request is a DoS amplifier (attacker at N req/s
// → we hit the IdP at N req/s, and the cache only stores on
// success so a degraded IdP means every call retries). On cold
// cache (before the first real OIDC flow warms it), fall back to
// the operator-typed `config.issuer_url`. In practice the cache
// is warm within seconds of the first login; the fallback only
// shows during that window and is only wrong if the IdP
// publishes an issuer that differs from the URL used to fetch
// discovery (rare in normal deployments).
let issuer = auth_app
. oidc_service ()
. and_then ( | svc | svc . cached_issuer ())
. unwrap_or_else ( || config . issuer_url . clone ());
2026-02-10 20:32:32 +01:00
Ok ( Json ( OidcProviderInfoDto {
enabled : true ,
2026-08-08 15:24:06 +02:00
issuer ,
2026-02-10 20:32:32 +01:00
provider_name : config . provider_name . clone (),
authorize_endpoint : "/api/auth/oidc/authorize" . to_string (),
2026-07-14 01:46:33 +02:00
password_login_enabled ,
magic_link_login_enabled ,
require_verified_email ,
2026-08-03 00:22:19 +02:00
auto_redirect_to_oidc ,
2026-02-10 20:32:32 +01:00
}))
}
2026-05-28 00:20:21 +02:00
/// Initiate OIDC authorization — redirects to the configured identity provider.
///
/// Generates PKCE, CSRF state, and nonce then issues a 302 redirect to the
/// provider's authorization endpoint.
#[utoipa::path(
get,
path = "/api/auth/oidc/authorize" ,
responses(
(status = 302, description = "Redirect to OIDC provider authorization URL" ),
(status = 404, description = "OIDC not enabled" ),
(status = 503, description = "Auth service not configured" ),
),
tag = "auth"
)]
2026-05-27 12:43:07 +02:00
pub async fn oidc_authorize (
State ( state ) : State < Arc < AppState >> ,
) -> Result < impl IntoResponse , AppError > {
2026-02-14 01:29:34 +01:00
let auth_service = state
. auth_service
. as_ref ()
2026-02-10 20:32:32 +01:00
. ok_or_else ( || AppError ::internal_error ( "Auth service not configured" )) ? ;
let auth_app = & auth_service . auth_application_service ;
if ! auth_app . oidc_enabled () {
return Err ( AppError ::new (
StatusCode ::NOT_FOUND ,
"OIDC is not enabled" ,
"OidcDisabled" ,
));
}
2026-02-11 00:37:47 +01:00
// Prepare OIDC authorization flow (generates CSRF state, PKCE pair, nonce)
2026-02-13 21:42:41 +01:00
let authorize_url = auth_app . prepare_oidc_authorize (). await ? ;
2026-02-10 20:32:32 +01:00
tracing ::info! ( "OIDC authorize redirect generated" );
Ok ( Redirect ::temporary ( & authorize_url ))
}
2026-08-08 17:19:05 +02:00
/// Start a self-service OIDC linking flow for the currently-authenticated
/// user. Returns the authorize URL for the SPA to `window.location`
/// navigate to. Callback lands on the standard `/api/auth/oidc/callback`
/// which dispatches to the link branch based on the state cache's
/// `intent` field. See docs/plan/oidc-account-linking.md.
#[utoipa::path(
post,
path = "/api/auth/oidc/link/start" ,
responses(
(status = 200, description = "Authorize URL to navigate the user to" , body = serde_json::Value),
(status = 401, description = "Not authenticated" ),
(status = 404, description = "OIDC not enabled" ),
(status = 409, description = "User is already linked — unlink first" ),
),
security(( "bearerAuth" = [])),
tag = "auth"
)]
pub async fn oidc_link_start (
State ( state ) : State < Arc < AppState >> ,
CurrentUserId ( user_id ) : CurrentUserId ,
) -> Result < impl IntoResponse , AppError > {
let auth_service = state
. auth_service
. as_ref ()
. ok_or_else ( || AppError ::internal_error ( "Auth service not configured" )) ? ;
let auth_app = & auth_service . auth_application_service ;
if ! auth_app . oidc_enabled () {
return Err ( AppError ::new (
StatusCode ::NOT_FOUND ,
"OIDC is not enabled" ,
"OidcDisabled" ,
));
}
let authorize_url = auth_app . prepare_oidc_link ( user_id ). await ? ;
Ok ( Json ( serde_json ::json! ({
"authorize_url" : authorize_url ,
})))
}
/// Unlink the current user's OIDC identity. Refuses when the user has
/// no other credential (password / OPAQUE) — see plan doc for the
/// no-alternative-auth guard rationale.
#[utoipa::path(
post,
path = "/api/auth/oidc/unlink" ,
responses(
(status = 200, description = "OIDC identity unlinked (or was already unlinked)" ),
(status = 401, description = "Not authenticated" ),
(status = 403, description = "Refused — user has no other credential and would be locked out" ),
),
security(( "bearerAuth" = [])),
tag = "auth"
)]
pub async fn oidc_unlink (
State ( state ) : State < Arc < AppState >> ,
CurrentUserId ( user_id ) : CurrentUserId ,
) -> Result < impl IntoResponse , AppError > {
let auth_service = state
. auth_service
. as_ref ()
. ok_or_else ( || AppError ::internal_error ( "Auth service not configured" )) ? ;
// Translate the app-service's generic AccessDenied refusal into a
// stable machine-readable `error_type` the SPA can switch on to
// render the "set a password first" affordance. The app service
// already emits the audit line with reason=no_alternative_auth;
// this hop maps the domain error to a wire contract.
match auth_service
. auth_application_service
. unlink_oidc ( user_id )
. await
{
Ok (()) => Ok ( StatusCode ::OK ),
Err ( e ) if e . kind == crate ::domain ::errors ::ErrorKind ::AccessDenied => Err ( AppError ::new (
StatusCode ::FORBIDDEN ,
e . message . clone (),
"NoAlternativeAuth" ,
)),
Err ( e ) => Err ( e . into ()),
}
}
2026-05-28 00:20:21 +02:00
/// Handle the OIDC provider callback.
///
/// Validates the `state` / PKCE / nonce, exchanges the code for tokens, then
2026-06-21 15:18:23 -05:00
/// redirects the browser to the frontend login route with a short-lived
/// exchange code (`/login?oidc_code=…`), which the SPA swaps for a session.
2026-05-28 00:20:21 +02:00
#[utoipa::path(
get,
path = "/api/auth/oidc/callback" ,
params(
( "code" = String, Query, description = "Authorization code from the OIDC provider" ),
( "state" = String, Query, description = "CSRF state value echoed by the provider" ),
),
responses(
(status = 302, description = "Redirect to frontend with one-time exchange code" ),
(status = 401, description = "OIDC validation failed (bad state, nonce, or code)" ),
(status = 404, description = "OIDC not enabled" ),
),
tag = "auth"
)]
pub async fn oidc_callback (
2026-02-10 20:32:32 +01:00
State ( state ) : State < Arc < AppState >> ,
2026-08-09 03:39:19 +02:00
ConnectInfo ( peer ) : ConnectInfo < SocketAddr > ,
headers : HeaderMap ,
2026-02-10 20:32:32 +01:00
Query ( query ) : Query < OidcCallbackQueryDto > ,
) -> Result < impl IntoResponse , AppError > {
2026-02-14 01:29:34 +01:00
let auth_service = state
. auth_service
. as_ref ()
2026-02-10 20:32:32 +01:00
. ok_or_else ( || AppError ::internal_error ( "Auth service not configured" )) ? ;
let auth_app = & auth_service . auth_application_service ;
if ! auth_app . oidc_enabled () {
return Err ( AppError ::new (
StatusCode ::NOT_FOUND ,
"OIDC is not enabled" ,
"OidcDisabled" ,
));
}
tracing ::info! ( "OIDC callback received with code" );
2026-08-09 03:39:19 +02:00
// Capture IP + UA so the OIDC-minted session row lands populated
// (admin panel would otherwise show "—" for SSO logins). Callback
// is a browser-initiated GET after the IdP redirect, so peer is
// the browser and User-Agent is the browser's.
let client_ip = client_ip_from_parts ( & headers , Some ( peer ), false );
let user_agent = headers
. get ( axum ::http ::header ::USER_AGENT )
. and_then ( | v | v . to_str (). ok ())
. map ( str ::to_owned );
2026-08-08 20:31:19 +02:00
// Exchange code, validate state/nonce/PKCE, authenticate user.
// Any Err path (expired state on refresh, consumed code on replay,
// anti-takeover email refusal, etc.) is caught below and turned
// into a redirect to /login?login_error=<key> — a JSON 4xx here
// would render as raw JSON in the browser since the caller is
// mid-navigation from the IdP, not the SPA. The SPA login page
// renders localized copy per key.
let result = match auth_app
2026-08-09 03:39:19 +02:00
. oidc_callback (
& query . code ,
& query . state ,
& state . locale_registry ,
Some ( client_ip ),
user_agent ,
)
2026-02-14 01:29:34 +01:00
. await
2026-08-08 20:31:19 +02:00
{
Ok ( r ) => r ,
Err ( e ) => {
2026-02-10 20:32:32 +01:00
tracing ::error! ( "OIDC callback failed: {}" , e );
2026-08-08 20:31:19 +02:00
let config = auth_app . oidc_config (). unwrap ();
let frontend_url = config . frontend_url . trim_end_matches ( '/' );
// AccessDenied covers the CSRF/state/code/nonce validation
// failures (the common "refresh replayed a consumed state"
// case). Everything else is bucketed as a generic callback
// failure — operators dig into the log line above for the
// specifics; end-users only need "try again" guidance.
let reason = match e . kind {
crate ::domain ::errors ::ErrorKind ::AccessDenied => "callback_denied" ,
_ => "callback_failed" ,
};
let redirect_url = format! ( " {} /login?login_error= {} " , frontend_url , reason );
return Ok ( Redirect ::temporary ( & redirect_url ). into_response ());
}
};
2026-02-10 20:32:32 +01:00
2026-03-04 14:02:15 +01:00
match result {
OidcCallbackResult ::WebLogin { exchange_code } => {
2026-05-11 13:05:03 -07:00
// Regular web login, redirect to frontend with exchange code
2026-03-04 14:02:15 +01:00
let config = auth_app . oidc_config (). unwrap ();
let frontend_url = config . frontend_url . trim_end_matches ( '/' );
2026-06-21 15:18:23 -05:00
let redirect_url = format! ( " {} /login?oidc_code= {} " , frontend_url , exchange_code );
2026-03-04 14:02:15 +01:00
tracing ::info! ( "OIDC login successful, redirecting with exchange code" );
2026-07-13 18:29:30 +02:00
Ok ( Redirect ::temporary ( & redirect_url ). into_response ())
2026-03-04 14:02:15 +01:00
}
OidcCallbackResult ::NextcloudLogin {
nc_flow_token ,
user_id ,
username ,
} => {
2026-07-13 18:29:30 +02:00
// Hand the browser off to the shared LFv2 completion path.
// That path lists the user's drives, renders the picker
// when there are ≥ 2, and only completes the flow (via the
// poll backchannel) when the user has picked. Prior to
// this refactor the OIDC arm minted the app password
// inline and completed with the bare username — customers
// with multiple drives had no way to pick a non-home
// drive under SSO, and the deprecated `nc://` redirect
// caused the "Impossible de valider la requête" dialog on
// NC clients that had already picked up credentials via
// the poll endpoint. Routing through the shared helper
// fixes both.
tracing ::info! (
user = % username ,
"OIDC callback → NC Login Flow v2: handing off to picker/completion path"
);
Ok (
crate ::interfaces ::nextcloud ::login_v2_handler ::handle_oidc_login_completion (
& state ,
& nc_flow_token ,
user_id ,
& username ,
)
. await ,
)
2026-03-04 14:02:15 +01:00
}
2026-08-08 17:19:05 +02:00
// Self-service link flow completion — redirect the user back
// to their profile with a query-param signal the SPA reads on
// mount to render a toast + strip the param via history.
// See docs/plan/oidc-account-linking.md § UX flow — link.
OidcCallbackResult ::LinkCompleted { user_id } => {
let config = auth_app . oidc_config (). unwrap ();
let frontend_url = config . frontend_url . trim_end_matches ( '/' );
let redirect_url = format! ( " {} /profile?linked=1" , frontend_url );
tracing ::info! (
user_id = % user_id ,
"OIDC link completed, redirecting to /profile?linked=1"
);
Ok ( Redirect ::temporary ( & redirect_url ). into_response ())
}
OidcCallbackResult ::LinkRefused { reason } => {
let config = auth_app . oidc_config (). unwrap ();
let frontend_url = config . frontend_url . trim_end_matches ( '/' );
let redirect_url = format! ( " {} /profile?link_error= {} " , frontend_url , reason );
tracing ::info! (
reason = reason ,
"OIDC link refused, redirecting to /profile?link_error"
);
Ok ( Redirect ::temporary ( & redirect_url ). into_response ())
}
2026-08-08 20:31:19 +02:00
// Redirect the browser back to the login page with a
// machine-readable reason on the query string, mirroring the
// LinkRefused → `/profile?link_error=<reason>` pattern above.
// The browser is mid-redirect from the IdP; returning a 409
// JSON body would leave the user staring at raw JSON. The SPA
// login page reads `?login_error=<reason>` on mount, renders a
// localized notice, and strips the param via history.replaceState.
2026-08-08 22:11:31 +02:00
//
// Reasons currently emitted (see auth_application_service.rs
// auto-link decision tree): auto_link_disabled,
// auto_link_email_not_verified, already_linked_elsewhere,
// email_ambiguous.
2026-08-08 20:09:02 +02:00
OidcCallbackResult ::AutoLinkRefused { reason } => {
2026-08-08 20:31:19 +02:00
let config = auth_app . oidc_config (). unwrap ();
let frontend_url = config . frontend_url . trim_end_matches ( '/' );
let redirect_url = format! ( " {} /login?login_error= {} " , frontend_url , reason );
tracing ::info! (
reason = reason ,
"OIDC auto-link refused, redirecting to /login?login_error"
);
Ok ( Redirect ::temporary ( & redirect_url ). into_response ())
2026-08-08 20:09:02 +02:00
}
2026-08-14 13:13:43 +02:00
// Rejected-by-policy (e.g. `email_verified=false` while
// `OXICLOUD_REQUIRE_VERIFIED_EMAIL=true`). Map each stable reason
// to its own `login_error` key so the SPA can render targeted
// copy — "verify your email at the IdP" instead of a misleading
// generic "sign-in link expired" toast. See app service's OIDC
// callback email-verification block for the reason enumeration.
OidcCallbackResult ::Rejected { reason } => {
let key = match reason {
"idp_asserts_unverified" => "email_not_verified_at_idp" ,
"claim_absent_and_required" => "email_verification_required" ,
// Future-proof fallback — a new backend reason without an
// explicit map here lands on the generic bucket rather
// than silently 200'ing the user through.
_ => "callback_denied" ,
};
let config = auth_app . oidc_config (). unwrap ();
let frontend_url = config . frontend_url . trim_end_matches ( '/' );
let redirect_url = format! ( " {} /login?login_error= {} " , frontend_url , key );
tracing ::info! (
reason = reason ,
login_error = key ,
"OIDC callback rejected by policy, redirecting to /login?login_error"
);
Ok ( Redirect ::temporary ( & redirect_url ). into_response ())
}
2026-03-04 14:02:15 +01:00
}
2026-02-10 20:32:32 +01:00
}
2026-02-11 00:37:47 +01:00
2026-05-28 00:20:21 +02:00
/// Exchange a one-time OIDC code for access + refresh tokens.
///
/// The frontend calls this after being redirected back with `?oidc_code=…`.
/// The exchange code is valid for a single use and expires in 60 s.
#[utoipa::path(
post,
path = "/api/auth/oidc/exchange" ,
request_body = OidcExchangeDto,
responses(
(status = 200, description = "Tokens issued, auth cookies set" , body = AuthResponseDto),
(status = 401, description = "Exchange code invalid or expired" ),
),
tag = "auth"
)]
pub async fn oidc_exchange (
2026-02-11 00:37:47 +01:00
State ( state ) : State < Arc < AppState >> ,
Json ( body ) : Json < OidcExchangeDto > ,
2026-03-03 01:10:50 +01:00
) -> Result < Response , AppError > {
2026-02-14 01:29:34 +01:00
let auth_service = state
. auth_service
. as_ref ()
2026-02-11 00:37:47 +01:00
. ok_or_else ( || AppError ::internal_error ( "Auth service not configured" )) ? ;
2026-02-14 01:29:34 +01:00
let auth_response = auth_service
. auth_application_service
2026-02-11 00:37:47 +01:00
. exchange_oidc_token ( & body . code )
. map_err ( | e | {
tracing ::warn! ( "OIDC token exchange failed: {}" , e );
AppError ::from ( e )
}) ? ;
2026-02-14 01:29:34 +01:00
tracing ::info! (
"OIDC token exchange successful for user: {}" ,
2026-06-02 21:21:24 +02:00
auth_response
2026-08-21 15:42:22 +02:00
. user
. full
2026-06-02 21:21:24 +02:00
. user
. username
. as_deref ()
2026-08-21 15:42:22 +02:00
. unwrap_or ( & auth_response . user . full . user . email )
2026-02-14 01:29:34 +01:00
);
2026-02-11 00:37:47 +01:00
2026-03-03 01:10:50 +01:00
// Set HttpOnly cookies for the browser
let mut response = ( StatusCode ::OK , Json ( & auth_response )). into_response ();
cookie_auth ::append_auth_cookies (
response . headers_mut (),
& auth_response . access_token ,
& auth_response . refresh_token ,
auth_response . expires_in ,
state . core . config . auth . refresh_token_expiry_secs ,
);
2026-03-03 01:49:18 +01:00
cookie_auth ::append_csrf_cookie ( response . headers_mut (), auth_response . expires_in );
2026-08-09 14:40:10 +02:00
// Seed the SPA's DPoP-nonce cache — see the login handler above.
cookie_auth ::maybe_append_dpop_nonce_cookie (
response . headers_mut (),
& state . dpop_nonce_service ,
state . core . config . auth . dpop_mode ,
);
2026-03-03 01:10:50 +01:00
Ok ( response )
2026-03-06 13:01:08 -05:00
}
2026-06-02 13:45:37 +02:00
/// Request body for `POST /api/auth/magic-link/send`.
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct SendMagicLinkDto {
pub email : String ,
}
/// POST /api/auth/magic-link/send — request a sign-in link by email.
///
/// Always returns 200 with a uniform message regardless of outcome, so
/// the response shape doesn't leak account existence. The real outcome
/// (sent / no-account / has-credential / account-deactivated /
/// malformed-email) is recorded in the `audit` channel via
/// `MagicLinkInviteService::send_login_link`.
///
/// 503 only when the magic-link feature isn't configured at all
/// (SMTP env missing) — operators need to know about misconfiguration;
/// it's not a state an anonymous caller can probe via timing because
/// the absence of the entire feature is visible from any other
/// endpoint touching `/api/auth/magic-link/*`.
///
2026-06-02 14:23:31 +02:00
/// PR 12 rate limits:
/// - **Per-source-IP**, 200/hour — bounds the cost of one attacker
/// spreading low per-email volumes over many target addresses.
/// - **Per-target-email**, 5/hour, keyed on the normalised email —
/// stops the endpoint from being an email-bombing primitive against
/// a single known recipient.
/// Both caps return the uniform 200 (never 429 to anonymous callers,
/// otherwise the status itself becomes an enumeration oracle); the
/// real reason is recorded in the audit channel.
/// Authenticated callers (Authorization header or access cookie
/// present) bypass both limits.
2026-06-02 13:45:37 +02:00
#[utoipa::path(
post,
path = "/api/auth/magic-link/send" ,
request_body = SendMagicLinkDto,
responses(
(status = 200, description = "Uniform 'if an account exists, a link will be sent' response" ),
(status = 503, description = "Magic-link / SMTP is not configured on this server" ),
),
tag = "auth" ,
)]
pub async fn send_magic_link (
State ( state ) : State < Arc < AppState >> ,
2026-06-02 14:23:31 +02:00
req : axum ::http ::Request < axum ::body ::Body > ,
2026-06-02 13:45:37 +02:00
) -> Result < Response , AppError > {
let Some ( invite_svc ) = state . magic_link_invite_service . as_ref () else {
return Err ( AppError ::new (
StatusCode ::SERVICE_UNAVAILABLE ,
"Magic-link sign-in is not configured on this server" ,
"ServiceUnavailable" ,
));
};
2026-07-14 01:46:33 +02:00
// Policy: `OXICLOUD_AUTH_METHODS` may forbid magic-link login even
// when SMTP is wired (an operator might want the invite path — used
// by admins to seed accounts — without offering it as a login
// fallback). Refuse with the same anti-enum shape as any other
// policy-gated endpoint.
if let Some ( auth ) = state . auth_service . as_ref ()
&& ! auth . auth_application_service . is_magic_link_login_allowed ()
{
return Err ( AppError ::new (
StatusCode ::FORBIDDEN ,
"Magic-link login is disabled by policy." ,
"MagicLinkLoginDisabled" ,
));
}
2026-06-02 14:23:31 +02:00
// Authentication signal — presence (not validity) of Bearer header
// OR access cookie. We deliberately don't decode the JWT here: a
// stale-cookie holder gets a 401 from any other endpoint they
// touch, and the worst-case bypass of these anti-flood caps is a
// narrow window where an attacker keeps a single expired cookie
// alive. False-negatives (a logged-in user being rate-limited
// resending to themselves) are the real cost we're avoiding.
let headers = req . headers (). clone ();
let is_authenticated = headers . contains_key ( axum ::http ::header ::AUTHORIZATION )
|| crate ::interfaces ::api ::cookie_auth ::extract_cookie_value (
& headers ,
crate ::interfaces ::api ::cookie_auth ::ACCESS_COOKIE ,
)
. is_some ();
let client_ip = crate ::interfaces ::middleware ::rate_limit ::extract_client_ip ( & req );
// Body parsing — manual because Request<Body> already consumed
// any chance of a Json extractor. 4 KiB is generous for
// `{ "email": "..." }`.
let body_bytes = axum ::body ::to_bytes ( req . into_body (), 4 * 1024 )
. await
. map_err ( | _ | {
AppError ::new (
StatusCode ::BAD_REQUEST ,
"Request body too large or unreadable" ,
"InvalidInput" ,
)
}) ? ;
let body : SendMagicLinkDto = serde_json ::from_slice ( & body_bytes ). map_err ( | e | {
AppError ::new (
StatusCode ::BAD_REQUEST ,
format! ( "Invalid JSON body: {e} " ),
"InvalidInput" ,
)
}) ? ;
2026-07-14 01:46:33 +02:00
// Login-identifier resolution. The DTO field is named `email` for
// backwards-compat, but the value may be either an email address or
// a username — dispatch matches the `POST /api/auth/login`
// convention (`@` present → email, else → username). Username
// lookups happen BEFORE rate-limiting so `alice` and
// `alice@example.com` bucket on the same key; without this,
// alternating shapes would double the effective per-email budget.
//
// Anti-enum: username misses fall through to `body.email` unchanged
// and land in the malformed_email / no_account branches downstream,
// both of which return the uniform 200 with an audit line.
let resolved_email = if let Some ( auth ) = state . auth_service . as_ref () {
auth . auth_application_service
. resolve_login_identifier_to_email ( & body . email )
. await
. unwrap_or_else ( || body . email . clone ())
} else {
body . email . clone ()
};
2026-06-02 23:12:41 +02:00
// Per-request browser-binding challenge (PR 22). Generated for
// every request and set as a cookie on every 200 response —
// including the silent-rate-limit paths — so the cookie's
// presence is uniform and can't be used as an enumeration oracle.
// The corresponding token row only carries the challenge when a
// token is actually minted; cookie-without-token simply fails to
// match on the eventual redemption.
let challenge = cookie_auth ::generate_magic_request_challenge ();
let login_ttl_secs = ( state . core . config . magic_link . login_ttl_minutes * 60 ) as i64 ;
let challenge_for_closure = challenge . clone ();
2026-06-02 14:23:31 +02:00
let uniform_ok = || {
let payload = serde_json ::json! ({
"message" : "If an account exists for that email, a sign-in link will be sent." ,
});
2026-06-02 23:12:41 +02:00
let mut resp = ( StatusCode ::OK , Json ( payload )). into_response ();
cookie_auth ::append_magic_request_cookie (
resp . headers_mut (),
& challenge_for_closure ,
login_ttl_secs ,
);
resp
2026-06-02 14:23:31 +02:00
};
if ! is_authenticated {
// Per-IP backstop fires first — covers the case where an
// attacker iterates many distinct emails to spread the
// per-email budget thin.
if state
. magic_link_send_per_ip_rate_limiter
. check_and_increment ( & client_ip )
. is_err ()
{
tracing ::warn! (
target : "audit" ,
event = "auth.magic_link_send" ,
reason = "rate_limited_ip" ,
ip = % client_ip ,
"Per-IP rate limit exceeded on /api/auth/magic-link/send"
);
return Ok ( uniform_ok ());
}
// Per-target-email cap, keyed on the normalised form so
// casing/IDN-host tricks don't multiply the budget. Malformed
// addresses skip this check and fall through to the service,
// which records its own audit entry under reason="malformed_email".
2026-07-14 01:46:33 +02:00
// Buckets on the RESOLVED email (post-username lookup) so
// username and email inputs for the same account share one
// budget — see resolve_login_identifier_to_email() above.
2026-06-02 14:23:31 +02:00
if let Ok ( normalised ) =
2026-07-14 01:46:33 +02:00
crate ::domain ::services ::email_normalize ::normalize_email ( & resolved_email )
2026-06-02 14:23:31 +02:00
&& state
. magic_link_send_per_email_rate_limiter
. check_and_increment ( & normalised )
. is_err ()
{
tracing ::warn! (
target : "audit" ,
event = "auth.magic_link_send" ,
reason = "rate_limited_email" ,
ip = % client_ip ,
"Per-target-email rate limit exceeded on /api/auth/magic-link/send"
);
return Ok ( uniform_ok ());
}
}
2026-06-02 13:45:37 +02:00
// The service swallows every operational outcome and logs the truth
// via the audit channel; we surface only an internal error (DB down,
// etc.). Anti-enumeration means we always return the same body.
2026-07-14 01:46:33 +02:00
// We pass the resolved email — if the caller sent a username, the
// service sees the corresponding address; if the caller sent a
// bare unknown identifier, the service still audits it as
// malformed_email / no_account.
2026-06-02 13:45:37 +02:00
invite_svc
2026-07-14 01:46:33 +02:00
. send_login_link ( & resolved_email , & challenge )
2026-06-02 13:45:37 +02:00
. await
. map_err ( AppError ::from ) ? ;
2026-06-02 14:23:31 +02:00
Ok ( uniform_ok ())
2026-06-02 13:45:37 +02:00
}