Skip to main content

oauth2_passkey/audit/
types.rs

1//! Types for login history tracking
2
3use chrono::{DateTime, Utc};
4use http::HeaderMap;
5use serde::{Deserialize, Serialize};
6use sqlx::FromRow;
7use std::fmt;
8
9/// Authentication method used for login
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "snake_case")]
12pub(crate) enum AuthMethod {
13    Passkey,
14    OAuth2,
15    FedCM,
16}
17
18impl fmt::Display for AuthMethod {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            AuthMethod::Passkey => write!(f, "passkey"),
22            AuthMethod::OAuth2 => write!(f, "oauth2"),
23            AuthMethod::FedCM => write!(f, "fedcm"),
24        }
25    }
26}
27
28/// A single login history entry
29#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
30pub struct LoginHistoryEntry {
31    /// Database ID (auto-generated)
32    pub id: Option<i64>,
33    /// User ID who logged in
34    pub user_id: String,
35    /// Timestamp of the login attempt
36    pub timestamp: DateTime<Utc>,
37    /// Authentication method used (passkey/oauth2)
38    pub auth_method: String,
39    /// IP address of the client (may be None for privacy)
40    pub ip_address: Option<String>,
41    /// User-Agent header (truncated)
42    pub user_agent: Option<String>,
43    /// Whether the login was successful
44    pub success: bool,
45    /// Passkey credential ID (for passkey logins)
46    pub credential_id: Option<String>,
47    /// OAuth2 provider name (for OAuth2 logins)
48    pub provider: Option<String>,
49    /// OAuth2 provider user ID (for OAuth2 logins)
50    pub provider_user_id: Option<String>,
51    /// Reason for failure (if success is false)
52    pub failure_reason: Option<String>,
53    /// AAGUID of the authenticator (for passkey logins)
54    pub aaguid: Option<String>,
55    /// Email address used for OAuth2 login
56    pub email: Option<String>,
57}
58
59/// Auth-method-specific details for a login history entry
60///
61/// Groups the optional fields that vary by authentication method (passkey vs OAuth2),
62/// replacing individual parameters to keep function signatures clean.
63#[derive(Debug, Clone, Default)]
64pub(crate) struct AuthMethodDetails {
65    /// Passkey credential ID
66    pub credential_id: Option<String>,
67    /// OAuth2 provider name
68    pub provider: Option<String>,
69    /// OAuth2 provider user ID
70    pub provider_user_id: Option<String>,
71    /// AAGUID of the authenticator (passkey)
72    pub aaguid: Option<String>,
73    /// Email address (OAuth2)
74    pub email: Option<String>,
75}
76
77impl LoginHistoryEntry {
78    /// Create a new login history entry for a successful login
79    pub(crate) fn success(
80        user_id: String,
81        auth_method: AuthMethod,
82        context: LoginContext,
83        details: AuthMethodDetails,
84    ) -> Self {
85        Self {
86            id: None,
87            user_id,
88            timestamp: Utc::now(),
89            auth_method: auth_method.to_string(),
90            ip_address: context.ip_address,
91            user_agent: context.user_agent.map(|ua| truncate_user_agent(&ua)),
92            success: true,
93            credential_id: details.credential_id,
94            provider: details.provider,
95            provider_user_id: details.provider_user_id,
96            failure_reason: None,
97            aaguid: details.aaguid,
98            email: details.email,
99        }
100    }
101
102    /// Create a new login history entry for a failed login
103    pub(crate) fn failure(
104        user_id: String,
105        auth_method: AuthMethod,
106        context: LoginContext,
107        credential_id: Option<String>,
108        reason: String,
109    ) -> Self {
110        Self {
111            id: None,
112            user_id,
113            timestamp: Utc::now(),
114            auth_method: auth_method.to_string(),
115            ip_address: context.ip_address,
116            user_agent: context.user_agent.map(|ua| truncate_user_agent(&ua)),
117            success: false,
118            credential_id,
119            provider: None,
120            provider_user_id: None,
121            failure_reason: Some(reason),
122            aaguid: None,
123            email: None,
124        }
125    }
126}
127
128/// Context information for a login attempt
129#[derive(Debug, Clone, Default)]
130pub(crate) struct LoginContext {
131    /// IP address from request headers
132    ip_address: Option<String>,
133    /// User-Agent from request headers
134    user_agent: Option<String>,
135}
136
137impl LoginContext {
138    /// Extract login context from HTTP headers
139    ///
140    /// Extracts the client IP address (from X-Forwarded-For or X-Real-IP headers)
141    /// and User-Agent for recording in the login history.
142    pub(crate) fn from_headers(headers: &HeaderMap) -> Self {
143        let ip_address = headers
144            .get("x-forwarded-for")
145            .and_then(|v| v.to_str().ok())
146            .map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
147            .or_else(|| {
148                headers
149                    .get("x-real-ip")
150                    .and_then(|v| v.to_str().ok())
151                    .map(|s| s.to_string())
152            });
153
154        let user_agent = headers
155            .get("user-agent")
156            .and_then(|v| v.to_str().ok())
157            .map(|s| s.to_string());
158
159        Self {
160            ip_address,
161            user_agent,
162        }
163    }
164}
165
166/// Truncate user agent to reasonable length
167fn truncate_user_agent(ua: &str) -> String {
168    const MAX_LENGTH: usize = 512;
169    if ua.len() > MAX_LENGTH {
170        ua[..MAX_LENGTH].to_string()
171    } else {
172        ua.to_string()
173    }
174}
175
176#[cfg(test)]
177mod tests;