From bee856fbd04afe251ced27fb97f00f91dfcf6a7b Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Sun, 9 Aug 2026 03:39:19 +0200 Subject: [PATCH] feat(session): handle sessions for admin --- frontend/src/lib/api/endpoints/admin.ts | 34 ++++ frontend/src/lib/api/types.ts | 28 +++ frontend/src/lib/components/AppShell.svelte | 6 + frontend/src/lib/utils/userAgent.test.ts | 80 ++++++++ frontend/src/lib/utils/userAgent.ts | 55 +++++ .../src/routes/admin/[[tab]]/+page.svelte | 189 ++++++++++++++++++ src/application/dtos/mod.rs | 1 + src/application/dtos/session_dto.rs | 164 +++++++++++++++ src/application/dtos/settings_dto.rs | 13 ++ src/application/ports/auth_ports.rs | 18 ++ .../services/auth_application_service.rs | 161 ++++++++++++--- src/domain/repositories/session_repository.rs | 17 ++ .../repositories/pg/session_pg_repository.rs | 84 ++++++++ src/interfaces/api/handlers/admin_handler.rs | 108 +++++++++- src/interfaces/api/handlers/auth_handler.rs | 44 +++- .../api/handlers/magic_link_handler.rs | 15 ++ .../api/handlers/opaque_auth_handler.rs | 15 +- 17 files changed, 997 insertions(+), 35 deletions(-) create mode 100644 frontend/src/lib/utils/userAgent.test.ts create mode 100644 frontend/src/lib/utils/userAgent.ts create mode 100644 src/application/dtos/session_dto.rs diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index 23834d19..2cf9ee48 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -6,6 +6,7 @@ import { apiFetch, apiJson } from '$lib/api/client'; import { getCsrfHeaders } from '$lib/api/csrf'; import type { + AdminSessionsPage, AdminUsersPage, Drive, DriveMember, @@ -241,6 +242,39 @@ export async function deleteDriveAdmin(driveId: string): Promise { } } +// ── Sessions (admin panel) ────────────────────────────────────────────── + +/** Options for {@link listAdminSessions}. */ +export interface ListSessionsOpts { + /** Narrow to one user's sessions; omit for cross-user listing. */ + userId?: string; + /** Include revoked / expired rows. Default `false` (active-only UX). */ + includeRevoked?: boolean; + /** Page size — server caps at 500. */ + limit?: number; + /** Pagination offset. */ + offset?: number; +} + +/** Global sessions listing — `GET /api/admin/sessions`. */ +export function listAdminSessions(opts: ListSessionsOpts = {}): Promise { + const params = new URLSearchParams(); + if (opts.userId) params.set('user_id', opts.userId); + if (opts.includeRevoked) params.set('include_revoked', 'true'); + if (opts.limit !== undefined) params.set('limit', String(opts.limit)); + if (opts.offset !== undefined) params.set('offset', String(opts.offset)); + const qs = params.toString(); + return apiJson(`/api/admin/sessions${qs ? '?' + qs : ''}`, { + credentials: 'same-origin' + }); +} + +/** Revoke a session — `DELETE /api/admin/sessions/{id}`. Sets + * `revoked=true`; the row stays in the DB for audit visibility. */ +export function revokeAdminSession(sessionId: string): Promise { + return mutate(`/api/admin/sessions/${encodeURIComponent(sessionId)}`, 'DELETE'); +} + // ── Users ─────────────────────────────────────────────────────────────── /** List the compact rows rendered by the management table; full account diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index f8cf8aed..51ee665e 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -734,3 +734,31 @@ export interface Finding { detail: Record; created_at: string; } + +/** + * Admin sessions-panel row shape. Backend: `SessionSummaryDto` in + * `src/application/dtos/session_dto.rs`. Deliberately narrower than + * the DB row — the refresh token is never serialised, and the full + * DPoP thumbprint is truncated to an 8-char prefix so admins viewing + * other users' sessions can't exfiltrate the full binding fingerprint. + */ +export interface SessionSummary { + id: string; + user_id: string; + created_at: string; + expires_at: string; + ip_address: string | null; + user_agent: string | null; + is_bound: boolean; + dpop_jkt_prefix: string | null; + is_revoked: boolean; + is_active: boolean; + oidc_sid: string | null; +} + +/** Wire response of `GET /api/admin/sessions`. */ +export interface AdminSessionsPage { + sessions: SessionSummary[]; + limit: number; + offset: number; +} diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index b1d4f788..da6107d0 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -86,6 +86,12 @@ icon: 'users', section: 'admin-users' }, + { + href: '/admin/sessions', + label: t('admin.sessions', 'Sessions'), + icon: 'key', + section: 'admin-sessions' + }, { href: '/admin/drives', label: t('admin.drives', 'Drives'), diff --git a/frontend/src/lib/utils/userAgent.test.ts b/frontend/src/lib/utils/userAgent.test.ts new file mode 100644 index 00000000..63b89d4a --- /dev/null +++ b/frontend/src/lib/utils/userAgent.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { shortUserAgent } from './userAgent'; + +describe('shortUserAgent', () => { + it('placeholder for missing input', () => { + expect(shortUserAgent(null)).toBe('—'); + expect(shortUserAgent(undefined)).toBe('—'); + expect(shortUserAgent('')).toBe('—'); + }); + + it('device-auth marker passes through unchanged', () => { + expect(shortUserAgent('device:my-tv-42')).toBe('device:my-tv-42'); + }); + + it.each([ + [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36', + 'Chrome on Mac' + ], + [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36', + 'Chrome on Windows' + ], + [ + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36', + 'Chrome on Linux' + ], + [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0', + 'Firefox on Windows' + ], + [ + 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0', + 'Firefox on Linux' + ], + [ + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15', + 'Safari on Mac' + ], + [ + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + 'Safari on iOS' + ], + [ + // iPad on iPadOS 13+ ships a UA with "Macintosh" — must NOT + // mis-detect as Mac. Guarded by iOS-first ordering. + 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + 'Safari on iOS' + ], + [ + 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36', + 'Chrome on Android' + ], + [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.2535.51', + 'Edge on Windows' + ], + [ + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 OPR/110.0.0.0', + 'Opera on Linux' + ], + ['curl/8.7.1', 'curl'], + ['Wget/1.21.4', 'wget'], + ['Mozilla/5.0 (Nextcloud desktop client 3.14.2 stable-x86_64)', 'Nextcloud client'], + ['node-fetch/1.0 (+https://github.com/bitinn/node-fetch)', 'Node'] + ])('%s → %s', (ua, expected) => { + expect(shortUserAgent(ua)).toBe(expected); + }); + + it('truncates unknown UA shapes past 40 chars', () => { + const long = 'SomeWeirdCrawler/1.0 with a very long description of its capabilities'; + const result = shortUserAgent(long); + expect(result.endsWith('…')).toBe(true); + expect(result.length).toBeLessThanOrEqual(41); + }); + + it('returns short unknown UA verbatim', () => { + expect(shortUserAgent('MyBot/1.0')).toBe('MyBot/1.0'); + }); +}); diff --git a/frontend/src/lib/utils/userAgent.ts b/frontend/src/lib/utils/userAgent.ts new file mode 100644 index 00000000..1a03e7f8 --- /dev/null +++ b/frontend/src/lib/utils/userAgent.ts @@ -0,0 +1,55 @@ +/** + * Parse a raw HTTP `User-Agent` string into a compact human label like + * "Chrome on Mac" for admin/session UIs. Not a general-purpose UA + * parser — pragmatic regex-based buckets covering the browsers + + * operating systems that make up ~99% of real-world traffic, plus the + * OxiCloud-specific device-auth prefix. + * + * Order of detection matters: + * * Edge / Opera / Firefox before Chrome (they all include `Chrome/…`) + * * Chrome before Safari (Chrome includes `Safari/…`) + * * Version check guards Safari against matching a WebKit-based crawler + * + * `null` / `undefined` / empty → `"—"` so the admin table renders a + * consistent placeholder without every callsite writing `?? '—'`. + */ +export function shortUserAgent(ua: string | null | undefined): string { + if (!ua) return '—'; + + // Device-authorization grant sessions carry a bespoke marker + // (`device:`) instead of a browser UA. Pass through. + if (ua.startsWith('device:')) return ua; + + // Browser detection — order matters. + let browser: string | null = null; + if (/\bEdg[eA]?\//.test(ua)) browser = 'Edge'; + else if (/\bOPR\/|Opera\//.test(ua)) browser = 'Opera'; + else if (/\bFirefox\/|FxiOS\//.test(ua)) browser = 'Firefox'; + else if (/\bChrome\//.test(ua)) browser = 'Chrome'; + else if (/\bSafari\//.test(ua) && /\bVersion\//.test(ua)) browser = 'Safari'; + else if (/\bcurl\//.test(ua)) browser = 'curl'; + else if (/\bwget/i.test(ua)) browser = 'wget'; + else if (/\bNextcloud\b/i.test(ua)) browser = 'Nextcloud client'; + else if (/\bnode\b/i.test(ua)) browser = 'Node'; + + // OS detection — iOS/iPad before Mac (iPad UAs include "Macintosh" on + // modern iPadOS "desktop mode"; without the iPad check first they'd + // be miscategorised as Mac). + let os: string | null = null; + if (/Windows/i.test(ua)) os = 'Windows'; + else if (/iPhone|iPad|iPod/i.test(ua)) os = 'iOS'; + else if (/Android/i.test(ua)) os = 'Android'; + else if (/Mac OS X|Macintosh/i.test(ua)) os = 'Mac'; + else if (/CrOS/i.test(ua)) os = 'ChromeOS'; + else if (/Linux/i.test(ua)) os = 'Linux'; + else if (/FreeBSD|OpenBSD|NetBSD/i.test(ua)) os = 'BSD'; + + if (browser && os) return `${browser} on ${os}`; + if (browser) return browser; + if (os) return os; + + // Unknown shape — truncate the raw string so a huge UA doesn't + // blow up the table column width. Full string still available in + // the row's `title=` tooltip. + return ua.length > 40 ? ua.slice(0, 40) + '…' : ua; +} diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index 837c631f..081979c6 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -18,6 +18,8 @@ installPlugin, listPlugins, listUsers, + listAdminSessions, + revokeAdminSession, getUserAdmin, migrationAction, reextractAudioMetadata, @@ -73,8 +75,10 @@ DriveMember, DrivePolicies, DrivePoliciesPartial, + SessionSummary, User } from '$lib/api/types'; + import { shortUserAgent } from '$lib/utils/userAgent'; import { triggerJob } from '$lib/api/endpoints/adminJobs'; import { serverStatus } from '$lib/stores/serverStatus.svelte'; import AdminJobsPanel from '$lib/components/AdminJobsPanel.svelte'; @@ -177,6 +181,7 @@ type Tab = | 'dashboard' | 'users' + | 'sessions' | 'drives' | 'mounts' | 'plugins' @@ -188,6 +193,7 @@ const VALID_TABS: readonly Tab[] = [ 'dashboard', 'users', + 'sessions', 'drives', 'mounts', 'plugins', @@ -221,6 +227,8 @@ return t('admin.dashboard', 'Dashboard'); case 'users': return t('admin.users', 'Users'); + case 'sessions': + return t('admin.sessions', 'Sessions'); case 'drives': return t('admin.drives', 'Drives'); case 'mounts': @@ -842,6 +850,56 @@ let resetError = $state(null); let resetting = $state(false); + // Sessions (admin panel — see task #52 / docs/plan/dpop.md Gate 10). + // Global cross-user listing by default; user-filter dropdown narrows. + // Active-only by default (hides revoked + expired); checkbox opts into + // showing everything for forensics. Revoke action mutates in place — + // the row is refetched to update `is_revoked` badge. + let sessions = $state([]); + let sessionsError = $state(null); + let sessionsLoading = $state(false); + let sessionsFilterUserId = $state(''); + let sessionsIncludeRevoked = $state(false); + let sessionRevokingId = $state(null); + + async function loadSessions() { + sessionsLoading = true; + sessionsError = null; + try { + const page = await listAdminSessions({ + userId: sessionsFilterUserId || undefined, + includeRevoked: sessionsIncludeRevoked, + limit: PAGE_SIZE + }); + sessions = page.sessions; + } catch (e) { + sessionsError = errorMessage(e); + } finally { + sessionsLoading = false; + } + } + + async function onRevokeSession(id: string) { + if ( + !confirm( + t( + 'admin.sessions.revoke_confirm', + 'Revoke this session? The next request from that browser will 401.' + ) + ) + ) + return; + sessionRevokingId = id; + try { + await revokeAdminSession(id); + await loadSessions(); + } catch (e) { + sessionsError = errorMessage(e); + } finally { + sessionRevokingId = null; + } + } + // Plugins let plugins = $state([]); let pluginsAvailable = $state(true); @@ -1581,6 +1639,7 @@ let loaded = $state>({ dashboard: false, users: false, + sessions: false, drives: false, mounts: false, plugins: false, @@ -1595,6 +1654,7 @@ loaded[tab] = true; if (tab === 'dashboard') void loadDashboard(); else if (tab === 'users') void loadUsers(); + else if (tab === 'sessions') void loadSessions(); else if (tab === 'drives') void loadDrivesTab(); else if (tab === 'mounts') void loadMounts(); else if (tab === 'plugins') void loadPlugins(); @@ -2898,6 +2958,135 @@ > {/if} + {:else if tab === 'sessions'} +
+

{t('admin.sessions.title', 'Sessions')}

+

+ {t( + 'admin.sessions.help', + 'Active sign-in sessions across all users. A locked icon means the session is bound to a browser keypair (DPoP) — a stolen cookie alone cannot use it. Revoke to force the browser to re-authenticate on its next request.' + )} +

+ +
+ + + +
+ + {#if sessionsError} +
+ {sessionsError} +
+ {/if} + +
+ + + + + + + + + + + + + + + {#each sessions as s (s.id)} + + + + + + + + + + + {/each} + {#if sessions.length === 0 && !sessionsLoading} + + + + {/if} + +
{t('admin.sessions.col_user', 'User')}{t('admin.sessions.col_created', 'Created')}{t('admin.sessions.col_expires', 'Expires')}{t('admin.sessions.col_ip', 'IP')}{t('admin.sessions.col_user_agent', 'User agent')}{t('admin.sessions.col_bound', 'Bound')}{t('admin.sessions.col_status', 'Status')}
{s.user_id.slice(0, 8)}…{new Date(s.created_at).toLocaleString()}{new Date(s.expires_at).toLocaleString()}{s.ip_address ?? '—'} + {shortUserAgent(s.user_agent)} + + {#if s.is_bound} + + 🔒 {s.dpop_jkt_prefix ?? ''} + + {:else} + {t('admin.sessions.unbound', 'unbound')} + {/if} + + {#if s.is_revoked} + + {t('admin.sessions.revoked', 'revoked')} + + {:else if !s.is_active} + {t('admin.sessions.expired', 'expired')} + {:else} + + {t('admin.sessions.active', 'active')} + + {/if} + + {#if !s.is_revoked} + + {/if} +
+ {t('admin.sessions.empty', 'No sessions match the current filter.')} +
+
+
{:else if tab === 'mounts'}

{t('admin.mounts.title', 'External File Mounts')}

diff --git a/src/application/dtos/mod.rs b/src/application/dtos/mod.rs index 58a76241..ab8ae8be 100644 --- a/src/application/dtos/mod.rs +++ b/src/application/dtos/mod.rs @@ -20,6 +20,7 @@ pub mod playlist_dto; pub mod plugin_dto; pub mod recent_dto; pub mod search_dto; +pub mod session_dto; pub mod settings_dto; pub mod share_dto; pub mod trash_dto; diff --git a/src/application/dtos/session_dto.rs b/src/application/dtos/session_dto.rs new file mode 100644 index 00000000..0554f6a1 --- /dev/null +++ b/src/application/dtos/session_dto.rs @@ -0,0 +1,164 @@ +//! DTOs for the admin sessions panel. +//! +//! [`SessionSummaryDto`] is the wire shape returned by +//! `GET /api/admin/sessions`. It's deliberately narrower than the +//! `Session` domain entity — the `refresh_token` and any OIDC +//! ID-token payload are **never** serialized; the raw DPoP thumbprint +//! is truncated to an 8-char prefix so an admin viewing another +//! user's sessions cannot exfiltrate the full binding fingerprint. +//! +//! Enrichment (username/email lookup for each `user_id`) is +//! intentionally deferred to the SPA — it already caches the admin +//! user list, and doing the JOIN server-side would either force a +//! per-request JOIN (extra work most operators don't need) or a +//! separate batch fetch (extra round-trip). Frontend cross-references +//! `user_id` against its cached user list. + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::domain::entities::session::Session; + +/// Wire shape for `GET /api/admin/sessions`. Contains everything the +/// admin table renders and **nothing the raw session entity would +/// leak** (refresh token, OIDC ID-token, full DPoP thumbprint). +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct SessionSummaryDto { + pub id: Uuid, + pub user_id: Uuid, + pub created_at: DateTime, + pub expires_at: DateTime, + pub ip_address: Option, + pub user_agent: Option, + /// `true` iff the session is DPoP-bound. Rendered as a lock icon + /// in the admin table. Complements the auth-badges surface. + pub is_bound: bool, + /// First 8 chars of the DPoP thumbprint when bound, `None` otherwise. + /// Enough to distinguish two bindings of the same user across + /// devices at a glance; not enough to leak the full jkt. + pub dpop_jkt_prefix: Option, + /// `true` when the row is revoked. Present because the panel has an + /// opt-in "include revoked" checkbox — active-only listings will + /// always show `false` here, but forensics listings need the flag. + pub is_revoked: bool, + /// Whether this row is currently usable — `!revoked && expires_at > now()`. + /// Kept server-side so the SPA doesn't drift if the browser clock is off. + pub is_active: bool, + /// OIDC session identifier when the login came through OIDC and the + /// IdP emitted `sid` — otherwise `None`. Useful when an operator is + /// correlating with the upstream IdP's session log. + pub oidc_sid: Option, +} + +impl From for SessionSummaryDto { + fn from(s: Session) -> Self { + let is_revoked = s.is_revoked(); + let is_expired = s.is_expired(); + let jkt = s.dpop_jkt().map(|s| s.to_owned()); + let dpop_jkt_prefix = jkt + .as_ref() + .map(|t| t.chars().take(8).collect::()); + Self { + id: s.id(), + user_id: s.user_id(), + created_at: s.created_at(), + expires_at: s.expires_at(), + ip_address: s.ip_address().map(str::to_owned), + user_agent: s.user_agent().map(str::to_owned), + is_bound: jkt.is_some(), + dpop_jkt_prefix, + is_revoked, + is_active: !is_revoked && !is_expired, + oidc_sid: s.oidc_sid().map(str::to_owned), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Duration; + + fn base(revoked: bool, jkt: Option<&str>) -> Session { + let mut s = Session::new( + Uuid::new_v4(), + "refresh-token".to_string(), + Some("192.0.2.1".to_string()), + Some("Mozilla/5.0".to_string()), + 30, + Uuid::new_v4(), + ); + if revoked { + s.revoke(); + } + if let Some(k) = jkt { + s = s.with_dpop_jkt(k.to_string()); + } + s + } + + #[test] + fn dto_never_leaks_refresh_token() { + let s = base(false, None); + let dto = SessionSummaryDto::from(s); + let json = serde_json::to_string(&dto).unwrap(); + assert!( + !json.contains("refresh-token"), + "refresh_token must never appear in the wire shape" + ); + } + + #[test] + fn dto_truncates_dpop_jkt_to_8_chars() { + // 44-char base64url thumbprint (SHA-256 → 32 bytes → ceil(32/3)*4 = 44) + let full = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH"; + let dto = SessionSummaryDto::from(base(false, Some(full))); + assert_eq!(dto.dpop_jkt_prefix.as_deref(), Some("abcdefgh")); + assert!(dto.is_bound); + } + + #[test] + fn dto_unbound_session_has_no_prefix() { + let dto = SessionSummaryDto::from(base(false, None)); + assert_eq!(dto.dpop_jkt_prefix, None); + assert!(!dto.is_bound); + } + + #[test] + fn is_active_false_when_revoked() { + let dto = SessionSummaryDto::from(base(true, None)); + assert!(dto.is_revoked); + assert!(!dto.is_active); + } + + #[test] + fn is_active_true_for_fresh_unrevoked_session() { + let dto = SessionSummaryDto::from(base(false, Some("jkt-abc"))); + assert!(!dto.is_revoked); + assert!(dto.is_active); + } + + #[test] + fn from_raw_expired_session_is_not_active() { + let past = Utc::now() - Duration::days(1); + let s = Session::from_raw( + Uuid::new_v4(), + Uuid::new_v4(), + "rt".to_string(), + past, + None, + None, + past, + false, + Uuid::new_v4(), + None, + None, + None, + ); + let dto = SessionSummaryDto::from(s); + assert!(!dto.is_active); + assert!(!dto.is_revoked); // exp-but-unrevoked distinct from revoked + } +} diff --git a/src/application/dtos/settings_dto.rs b/src/application/dtos/settings_dto.rs index ba7328bf..0d2cc9da 100644 --- a/src/application/dtos/settings_dto.rs +++ b/src/application/dtos/settings_dto.rs @@ -118,6 +118,19 @@ pub struct ListUsersQueryDto { pub summary: Option, } +/// Query parameters for the admin sessions listing. +/// +/// `user_id` is a String (not `Uuid`) because bad UUIDs need a clean +/// 400 response — the handler parses and rejects malformed input. +/// `include_revoked` defaults to `false` at the handler layer. +#[derive(Debug, Serialize, Deserialize)] +pub struct ListSessionsQueryDto { + pub user_id: Option, + pub include_revoked: Option, + pub limit: Option, + pub offset: Option, +} + /// One row of the dashboard's quota panel — usage aggregate for a /// single drive kind. Unlimited caps are excluded from `capped_quota_bytes` /// and counted in `unlimited_count` so the panel can render the ratio diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index d3c81373..cb8564fa 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -497,6 +497,24 @@ pub trait SessionStoragePort: Send + Sync + 'static { /// carries a thumbprint (anti-downgrade invariant, see /// `docs/plan/dpop.md`). async fn bind_dpop_jkt(&self, session_id: Uuid, dpop_jkt: &str) -> Result<(), DomainError>; + + /// Fetch a single session by id. Used by admin surfaces that need + /// to resolve `target_user_id` for audit lines before a mutation. + /// Returns `NotFound` when the id doesn't match any row. + async fn get_session_by_id(&self, session_id: Uuid) -> Result; + + /// Paginated cross-user listing for the admin sessions panel. + /// `user_id_filter` narrows to a single user when `Some`; `None` + /// spans all users. `include_revoked = false` (the default UX) + /// returns only rows where `revoked = false AND expires_at > NOW()`. + /// Ordered newest first (`created_at DESC`). + async fn list_sessions_paginated( + &self, + user_id_filter: Option, + include_revoked: bool, + limit: i64, + offset: i64, + ) -> Result, DomainError>; } // ============================================================================ diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 31abe677..528119c7 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -803,7 +803,12 @@ impl AuthApplicationService { Ok(UserDto::from(created_user)) } - pub async fn login(&self, dto: LoginDto) -> Result { + pub async fn login( + &self, + dto: LoginDto, + client_ip: Option, + user_agent: Option, + ) -> Result { // Gate: policy may forbid password logins entirely (either the // legacy OIDC-only mode or the newer `OXICLOUD_AUTH_METHODS` // allowlist without `password`). Refuse BEFORE the user lookup @@ -1006,7 +1011,7 @@ impl AuthApplicationService { // handshake (Phase 1, `login/ke3`). Both paths converge here // so lifecycle + token + session-family semantics stay in // one place. - self.mint_session_for_authenticated_user(user, dto.dpop_jkt) + self.mint_session_for_authenticated_user(user, dto.dpop_jkt, client_ip, user_agent) .await } @@ -1034,6 +1039,8 @@ impl AuthApplicationService { &self, mut user: crate::domain::entities::user::User, dpop_jkt: Option, + client_ip: Option, + user_agent: Option, ) -> Result { // Lifecycle: dispatch login BEFORE register_login() so hooks // observing `last_login_at().is_none()` see "first ever login" @@ -1092,8 +1099,8 @@ impl AuthApplicationService { let mut session = Session::new( user.id(), refresh_token.clone(), - None, // IP (can be added from the HTTP layer) - None, // User-Agent (can be added from the HTTP layer) + client_ip, + user_agent, self.token_service.refresh_token_expiry_days(), Uuid::new_v4(), ); @@ -1168,6 +1175,8 @@ impl AuthApplicationService { token: &str, incoming_challenge: Option<&str>, cross_browser_confirmed: bool, + client_ip: Option, + user_agent: Option, ) -> Result { let repo = self.magic_link_repo.as_ref().ok_or_else(|| { DomainError::new( @@ -1355,8 +1364,8 @@ impl AuthApplicationService { let session = Session::new( user.id(), refresh_token.clone(), - None, - None, + client_ip, + user_agent, self.token_service.refresh_token_expiry_days(), Uuid::new_v4(), ); @@ -1447,6 +1456,8 @@ impl AuthApplicationService { pub async fn refresh_token( &self, dto: RefreshTokenDto, + client_ip: Option, + user_agent: Option, ) -> Result { // Get valid session let session = self @@ -1525,8 +1536,8 @@ impl AuthApplicationService { let mut new_session = Session::new( user.id(), new_refresh_token.clone(), - None, - None, + client_ip, + user_agent, self.token_service.refresh_token_expiry_days(), session.family_id(), ); @@ -2931,6 +2942,78 @@ impl AuthApplicationService { Ok(names.into_iter().flatten().collect()) } + // ======================================================================== + // Admin Session Management Methods + // ======================================================================== + // + // AuthZ posture: /api/admin/* is already protected by a + // `require_admin` router layer (see + // `interfaces/api/routes.rs::admin_router`) — but every admin + // method here still calls `require_admin_caller` as a + // defense-in-depth check, matching the pattern + // `list_users_including_external_with_perms` established. If a + // handler is ever wired outside the /admin subtree, the AuthZ + // still holds. + + /// List sessions for the admin panel. `user_id_filter = Some(uuid)` + /// narrows to one user; `None` returns cross-user. `include_revoked` + /// controls whether to show revoked / expired rows — default UX + /// hides them (checkbox to opt in for forensics). + pub async fn admin_list_sessions_with_perms( + &self, + authorization: &A, + caller_id: Uuid, + user_id_filter: Option, + include_revoked: bool, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + self.require_admin_caller(authorization, caller_id).await?; + let sessions = self + .session_storage + .list_sessions_paginated(user_id_filter, include_revoked, limit, offset) + .await?; + Ok(sessions + .into_iter() + .map(crate::application::dtos::session_dto::SessionSummaryDto::from) + .collect()) + } + + /// Admin-driven session revocation. Sets `revoked = true` — the + /// row remains for audit visibility, but its refresh token is + /// dead and the next access-token refresh 401s naturally. + /// + /// Emits an audit line + counter increment so operators can trace + /// who killed which session and when. + pub async fn admin_revoke_session_with_perms( + &self, + authorization: &A, + caller_id: Uuid, + session_id: Uuid, + ) -> Result<(), DomainError> { + self.require_admin_caller(authorization, caller_id).await?; + // Resolve target user for the audit line before revocation — + // once the session row is revoked the user_id is still readable + // but the ORDER is stable this way. + let target_user_id = self + .session_storage + .get_session_by_id(session_id) + .await + .ok() + .map(|s| s.user_id()); + self.session_storage.revoke_session(session_id).await?; + tracing::info!( + target: "audit", + event = "admin.session_revoked", + caller_id = %caller_id, + session_id = %session_id, + target_user_id = target_user_id.map(|u| u.to_string()).unwrap_or_default(), + "👮🏻‍♂️ Admin revoked session", + ); + metrics::counter!("oxicloud_admin_session_revoked_total").increment(1); + Ok(()) + } + // ======================================================================== // Admin User Management Methods // ======================================================================== @@ -3753,6 +3836,8 @@ impl AuthApplicationService { code: &str, state: &str, locale_registry: &crate::common::locale::LocaleRegistry, + client_ip: Option, + user_agent: Option, ) -> Result { // 0. Validate CSRF state and retrieve PKCE verifier + nonce + optional NC token // (entry is auto-expired by moka TTL — remove returns None if expired) @@ -4276,8 +4361,8 @@ impl AuthApplicationService { let mut session = Session::new( user.id(), refresh_token.clone(), - None, - None, + client_ip, + user_agent, self.token_service.refresh_token_expiry_days(), Uuid::new_v4(), ) @@ -4492,11 +4577,15 @@ mod phase4_gate_integration_tests { let user_id = seed_user_with_password(&pool, &hasher, &email, "s3cret-passphrase").await; // Baseline — no envelope, no migration mark → legacy works. - svc.login(crate::application::dtos::user_dto::LoginDto { - username: email.clone(), - password: "s3cret-passphrase".to_string(), - dpop_jkt: None, - }) + svc.login( + crate::application::dtos::user_dto::LoginDto { + username: email.clone(), + password: "s3cret-passphrase".to_string(), + dpop_jkt: None, + }, + None, + None, + ) .await .expect("baseline legacy login must succeed"); @@ -4510,11 +4599,15 @@ mod phase4_gate_integration_tests { // but AccessDenied with the exact message the handler layer // remaps to `403 OpaqueLoginRequired`. let refused = svc - .login(crate::application::dtos::user_dto::LoginDto { - username: email.clone(), - password: "s3cret-passphrase".to_string(), - dpop_jkt: None, - }) + .login( + crate::application::dtos::user_dto::LoginDto { + username: email.clone(), + password: "s3cret-passphrase".to_string(), + dpop_jkt: None, + }, + None, + None, + ) .await .expect_err("legacy login must be refused post-migration"); assert_eq!( @@ -4534,11 +4627,15 @@ mod phase4_gate_integration_tests { // password check specifically so an attacker without the // password learns nothing about migration state. let wrong = svc - .login(crate::application::dtos::user_dto::LoginDto { - username: email.clone(), - password: "wrong-password".to_string(), - dpop_jkt: None, - }) + .login( + crate::application::dtos::user_dto::LoginDto { + username: email.clone(), + password: "wrong-password".to_string(), + dpop_jkt: None, + }, + None, + None, + ) .await .expect_err("wrong password must still fail"); assert_eq!(wrong.message, "Invalid credentials"); @@ -4551,11 +4648,15 @@ mod phase4_gate_integration_tests { .clear_registration(user_id) .await .expect("clear registration"); - svc.login(crate::application::dtos::user_dto::LoginDto { - username: email, - password: "s3cret-passphrase".to_string(), - dpop_jkt: None, - }) + svc.login( + crate::application::dtos::user_dto::LoginDto { + username: email, + password: "s3cret-passphrase".to_string(), + dpop_jkt: None, + }, + None, + None, + ) .await .expect("legacy login must succeed again after admin clear_registration"); } diff --git a/src/domain/repositories/session_repository.rs b/src/domain/repositories/session_repository.rs index 48589d4e..8a2a2086 100644 --- a/src/domain/repositories/session_repository.rs +++ b/src/domain/repositories/session_repository.rs @@ -58,6 +58,23 @@ pub trait SessionRepository: Send + Sync + 'static { async fn get_sessions_by_user_id(&self, user_id: Uuid) -> SessionRepositoryResult>; + /// Paginated listing for the admin sessions panel. Cross-user by + /// default; `user_id_filter = Some(uuid)` narrows to one user. + /// `include_revoked = false` (the default UX) filters to sessions + /// that are BOTH non-revoked AND non-expired — what an operator + /// would call "active right now". `include_revoked = true` shows + /// everything for incident forensics. + /// + /// Ordered by `created_at DESC` — newest first, matching the + /// existing `get_sessions_by_user_id` convention. + async fn list_sessions_paginated( + &self, + user_id_filter: Option, + include_revoked: bool, + limit: i64, + offset: i64, + ) -> SessionRepositoryResult>; + /// Revokes a specific session async fn revoke_session(&self, session_id: Uuid) -> SessionRepositoryResult<()>; diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index 4da55abe..6f6778d6 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -223,6 +223,66 @@ impl SessionRepository for SessionPgRepository { Ok(sessions) } + async fn list_sessions_paginated( + &self, + user_id_filter: Option, + include_revoked: bool, + limit: i64, + offset: i64, + ) -> SessionRepositoryResult> { + // Single SQL with nullable-user-id + include-revoked flag + // baked in as parameters, rather than four hand-forked + // queries. `$1::uuid IS NULL` short-circuits when no filter is + // set; `$2 OR (revoked = false AND expires_at > NOW())` folds + // the active-only rule into one predicate. Both branches use + // the same index (`idx_sessions_user_id`) on the filtered + // path, and a full table scan bounded by `LIMIT` on the + // unfiltered path — acceptable for an admin-triggered view + // that operators paginate through. + let rows = sqlx::query( + r#" + SELECT + id, user_id, refresh_token, expires_at, + ip_address, user_agent, created_at, revoked, family_id, + oidc_id_token, oidc_sid, dpop_jkt + FROM auth.sessions + WHERE ($1::uuid IS NULL OR user_id = $1) + AND ($2 OR (revoked = false AND expires_at > NOW())) + ORDER BY created_at DESC + LIMIT $3 OFFSET $4 + "#, + ) + .bind(user_id_filter) + .bind(include_revoked) + .bind(limit) + .bind(offset) + .fetch_all(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + let sessions = rows + .into_iter() + .map(|row| { + Session::from_raw( + row.get("id"), + row.get("user_id"), + row.get("refresh_token"), + row.get("expires_at"), + row.get("ip_address"), + row.get("user_agent"), + row.get("created_at"), + row.get("revoked"), + row.get("family_id"), + row.get("oidc_id_token"), + row.get("oidc_sid"), + row.get("dpop_jkt"), + ) + }) + .collect(); + + Ok(sessions) + } + /// Revokes a specific session using a transaction async fn revoke_session(&self, session_id: Uuid) -> SessionRepositoryResult<()> { let id = session_id; // Copy for use in closure @@ -649,4 +709,28 @@ impl SessionStoragePort for SessionPgRepository { .await .map_err(DomainError::from) } + + async fn get_session_by_id(&self, session_id: Uuid) -> Result { + SessionRepository::get_session_by_id(self, session_id) + .await + .map_err(DomainError::from) + } + + async fn list_sessions_paginated( + &self, + user_id_filter: Option, + include_revoked: bool, + limit: i64, + offset: i64, + ) -> Result, DomainError> { + SessionRepository::list_sessions_paginated( + self, + user_id_filter, + include_revoked, + limit, + offset, + ) + .await + .map_err(DomainError::from) + } } diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 373e51e8..cea20273 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -17,7 +17,8 @@ use crate::application::dtos::plugin_dto::{ }; use crate::application::dtos::settings_dto::{ AdminCreateUserDto, AdminResetPasswordDto, DashboardStatsDto, DriveKindUsageDto, - ListUsersQueryDto, MigrationStateDto, SaveOidcSettingsDto, SaveStorageSettingsDto, + ListSessionsQueryDto, ListUsersQueryDto, MigrationStateDto, SaveOidcSettingsDto, + SaveStorageSettingsDto, SendSmtpTestDto, SmtpInfoDto, SmtpTestResultDto, StartMigrationDto, TestOidcConnectionDto, TestStorageConnectionDto, UpdateUserActiveDto, UpdateUserQuotaDto, UpdateUserRoleDto, }; @@ -108,6 +109,11 @@ pub fn admin_routes() -> Router> { .route("/users", post(create_user)) .route("/users/{id}", get(get_user)) .route("/users/{id}", delete(delete_user)) + // Session management (DPoP admin panel — see docs/plan/dpop.md + // Gate 10). List is global cross-user with `?user_id=` narrow; + // revoke sets `revoked=true` (row stays for audit). + .route("/sessions", get(list_sessions)) + .route("/sessions/{id}", delete(revoke_session)) .route("/users/{id}/role", put(update_user_role)) .route("/users/{id}/active", put(update_user_active)) .route("/users/{id}/quota", put(update_user_quota)) @@ -1189,6 +1195,106 @@ pub async fn delete_user( )) } +/// GET /api/admin/sessions?user_id=&include_revoked=&limit=&offset= — list sessions +/// +/// Global cross-user listing by default. `user_id` narrows to one +/// user; omit for cross-user. `include_revoked=true` opts into +/// showing revoked / expired rows for forensics (default hides). +/// Response is `{sessions, limit, offset}` — no total count (would +/// require a second scan; the panel paginates on presence of +/// exactly `limit` rows returned). +#[utoipa::path( + get, + path = "/api/admin/sessions", + params( + ("user_id" = Option, Query, description = "Narrow to one user (UUID); omit for cross-user"), + ("include_revoked" = Option, Query, description = "Include revoked + expired rows (default false — active only)"), + ("limit" = Option, Query, description = "Max rows to return (default 100, max 500)"), + ("offset" = Option, Query, description = "Pagination offset") + ), + responses( + (status = 200, description = "List of sessions"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required") + ), + security(("bearerAuth" = [])), + tag = "admin" +)] +pub async fn list_sessions( + State(state): State>, + auth_user: AuthUser, + Query(query): Query, +) -> Result { + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + let limit = query.limit.unwrap_or(100).min(500); + let offset = query.offset.unwrap_or(0); + let include_revoked = query.include_revoked.unwrap_or(false); + let user_id_filter = match query.user_id.as_deref() { + Some(s) => Some(Uuid::parse_str(s).map_err(|_| AppError::bad_request("Invalid user_id"))?), + None => None, + }; + + let sessions = auth + .auth_application_service + .admin_list_sessions_with_perms( + state.authorization.as_ref(), + auth_user.id, + user_id_filter, + include_revoked, + limit, + offset, + ) + .await + .map_err(AppError::from)?; + + Ok(Json(serde_json::json!({ + "sessions": sessions, + "limit": limit, + "offset": offset, + }))) +} + +/// DELETE /api/admin/sessions/:id — revoke a session +#[utoipa::path( + delete, + path = "/api/admin/sessions/{id}", + params(("id" = String, Path, description = "Session UUID")), + responses( + (status = 200, description = "Session revoked"), + (status = 400, description = "Invalid UUID"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Admin required"), + (status = 404, description = "Session not found") + ), + security(("bearerAuth" = [])), + tag = "admin" +)] +pub async fn revoke_session( + State(state): State>, + auth_user: AuthUser, + Path(id): Path, +) -> Result { + let session_id = Uuid::parse_str(&id).map_err(|_| AppError::bad_request("Invalid UUID"))?; + let auth = state + .auth_service + .as_ref() + .ok_or_else(|| AppError::internal_error("Auth service not configured"))?; + + auth.auth_application_service + .admin_revoke_session_with_perms(state.authorization.as_ref(), auth_user.id, session_id) + .await + .map_err(AppError::from)?; + + Ok(( + StatusCode::OK, + Json(serde_json::json!({ "message": "Session revoked" })), + )) +} + /// PUT /api/admin/users/:id/role — change user role #[utoipa::path( put, diff --git a/src/interfaces/api/handlers/auth_handler.rs b/src/interfaces/api/handlers/auth_handler.rs index 3861db43..6b43fd2e 100644 --- a/src/interfaces/api/handlers/auth_handler.rs +++ b/src/interfaces/api/handlers/auth_handler.rs @@ -393,10 +393,19 @@ pub async fn login( )); } + // 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); + // Try the normal login process match auth_service .auth_application_service - .login(dto.clone()) + .login(dto.clone(), Some(client_ip.clone()), user_agent.clone()) .await { Ok(auth_response) => { @@ -547,6 +556,7 @@ pub async fn login( )] pub async fn refresh_token( State(state): State>, + ConnectInfo(peer): ConnectInfo, headers: HeaderMap, body: axum::body::Bytes, ) -> Result { @@ -568,9 +578,19 @@ pub async fn refresh_token( refresh_token: refresh_tok, }; + // 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); + let auth_response = auth_service .auth_application_service - .refresh_token(dto) + .refresh_token(dto, Some(client_ip), user_agent) .await?; tracing::info!("Token refresh successful, new token issued"); @@ -1560,6 +1580,8 @@ pub async fn oidc_unlink( )] pub async fn oidc_callback( State(state): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, Query(query): Query, ) -> Result { let auth_service = state @@ -1579,6 +1601,16 @@ pub async fn oidc_callback( tracing::info!("OIDC callback received with code"); + // 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); + // 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 @@ -1587,7 +1619,13 @@ pub async fn oidc_callback( // mid-navigation from the IdP, not the SPA. The SPA login page // renders localized copy per key. let result = match auth_app - .oidc_callback(&query.code, &query.state, &state.locale_registry) + .oidc_callback( + &query.code, + &query.state, + &state.locale_registry, + Some(client_ip), + user_agent, + ) .await { Ok(r) => r, diff --git a/src/interfaces/api/handlers/magic_link_handler.rs b/src/interfaces/api/handlers/magic_link_handler.rs index 3c2738af..89dbf60d 100644 --- a/src/interfaces/api/handlers/magic_link_handler.rs +++ b/src/interfaces/api/handlers/magic_link_handler.rs @@ -148,6 +148,7 @@ struct RedeemQuery { )] async fn redeem_magic_link( State(state): State>, + axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo, Path(token): Path, Query(query): Query, RequestLocale(locale): RequestLocale, @@ -171,12 +172,26 @@ async fn redeem_magic_link( .map(|v| v == "1" || v == "true") .unwrap_or(false); + // Capture IP + UA for the newly minted session row (admin sessions + // panel renders these; NULLs would show as "—"). + let client_ip = crate::interfaces::middleware::trusted_proxy::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); + match auth_svc .auth_application_service .redeem_magic_link( &token, incoming_challenge.as_deref(), cross_browser_confirmed, + Some(client_ip), + user_agent, ) .await { diff --git a/src/interfaces/api/handlers/opaque_auth_handler.rs b/src/interfaces/api/handlers/opaque_auth_handler.rs index e6870ad7..f9a4525f 100644 --- a/src/interfaces/api/handlers/opaque_auth_handler.rs +++ b/src/interfaces/api/handlers/opaque_auth_handler.rs @@ -682,6 +682,8 @@ pub async fn login_ke1( )] pub async fn login_ke3( State(state): State>, + axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo, + headers: axum::http::HeaderMap, Json(dto): Json, ) -> Result { let _svc = require_opaque_service(&state)?; @@ -764,12 +766,23 @@ pub async fn login_ke3( invalid_credentials() })?; + // Capture client IP + User-Agent so `sessions.ip_address` / + // `user_agent` land populated instead of NULL (admin panel would + // otherwise render "—"). Both are per-session and only refresh + // on rotation, matching the login pattern. + let client_ip = + crate::interfaces::middleware::trusted_proxy::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); + // Mint the session BEFORE stamping opaque_migrated_at — if the // session mint fails (rare, but not impossible under DB failure), // we don't want to have flipped the migration flag for a user // whose login didn't actually complete. let session = auth - .mint_session_for_authenticated_user(user, dto.dpop_jkt) + .mint_session_for_authenticated_user(user, dto.dpop_jkt, Some(client_ip), user_agent) .await .map_err(AppError::from)?;