Skip to main content

arcly_http_identity/
model.rs

1//! Canonical identity records and the DTOs the flows exchange.
2
3use serde::{Deserialize, Serialize};
4
5/// Lifecycle state of an account. Only `Active` may authenticate.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum AccountStatus {
9    /// Registered but e-mail / phone not yet verified (if verification required).
10    Pending,
11    /// Normal, may authenticate.
12    Active,
13    /// Temporarily blocked by the lockout policy.
14    Locked,
15    /// Administratively disabled.
16    Suspended,
17    /// Soft-deleted; PII crypto-shredded. Never authenticates.
18    Deleted,
19}
20
21impl AccountStatus {
22    pub fn can_authenticate(self) -> bool {
23        matches!(self, AccountStatus::Active)
24    }
25}
26
27/// Which second factors an identity has enrolled.
28#[derive(Debug, Clone, Default, Serialize, Deserialize)]
29pub struct MfaState {
30    /// TOTP authenticator enrolled and confirmed.
31    pub totp_enrolled: bool,
32    /// Number of unused recovery codes remaining.
33    pub recovery_codes_remaining: u32,
34    /// Count of registered passkeys / WebAuthn credentials.
35    pub passkeys: u32,
36}
37
38impl MfaState {
39    /// True when *any* second factor is enrolled — login must step up.
40    pub fn is_enrolled(&self) -> bool {
41        self.totp_enrolled || self.passkeys > 0
42    }
43}
44
45/// The canonical user record. PII fields (`email`, `phone`) are expected to be
46/// encrypted at rest by the app's [`UserStore`](crate::store::UserStore) via the
47/// compliance `CryptoVault`; this struct is the *decrypted* view a handler sees.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Identity {
50    pub id: String,
51    /// Home tenant (B2B). `None` for a global / B2C consumer.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub tenant: Option<String>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub email: Option<String>,
56    #[serde(default)]
57    pub email_verified: bool,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub phone: Option<String>,
60    #[serde(default)]
61    pub phone_verified: bool,
62    pub status: AccountStatus,
63    #[serde(default)]
64    pub roles: Vec<String>,
65    /// Fine-grained `resource:action` permissions embedded in the access token.
66    #[serde(default)]
67    pub perms: Vec<String>,
68    #[serde(default)]
69    pub mfa: MfaState,
70    /// Progressive-profiling / custom claims surfaced to policies and OIDC.
71    #[serde(default)]
72    pub attributes: serde_json::Map<String, serde_json::Value>,
73}
74
75impl Identity {
76    /// Primary role string for the JWT `role` claim (first role, or `"user"`).
77    pub fn primary_role(&self) -> &str {
78        self.roles.first().map(|s| s.as_str()).unwrap_or("user")
79    }
80}
81
82/// Registration input. Password is optional to support passwordless-first signup.
83#[derive(Debug, Clone, Deserialize)]
84pub struct RegisterRequest {
85    pub email: String,
86    #[serde(default)]
87    pub password: Option<String>,
88    #[serde(default)]
89    pub tenant: Option<String>,
90    #[serde(default)]
91    pub attributes: serde_json::Map<String, serde_json::Value>,
92}
93
94/// The token pair returned to a successful authentication. Shape mirrors the
95/// familiar OAuth2 token response so existing clients need no changes.
96#[derive(Debug, Clone, Serialize)]
97pub struct TokenResponse {
98    pub access_token: String,
99    pub refresh_token: String,
100    pub token_type: &'static str,
101    pub expires_in: u64,
102}
103
104impl TokenResponse {
105    pub fn bearer(access_token: String, refresh_token: String, expires_in: u64) -> Self {
106        Self {
107            access_token,
108            refresh_token,
109            token_type: "Bearer",
110            expires_in,
111        }
112    }
113}
114
115/// The result of a login attempt: either fully authenticated (tokens issued) or
116/// a second factor is still required.
117#[derive(Debug, Clone, Serialize)]
118#[serde(untagged)]
119pub enum LoginOutcome {
120    /// Authentication complete.
121    Authenticated(TokenResponse),
122    /// Password verified but a second factor is outstanding. The client submits
123    /// the factor with `challenge_id` to `/auth/mfa/verify`.
124    MfaChallenge {
125        mfa_required: bool,
126        challenge_id: String,
127        /// Factors the user may use to satisfy the challenge, e.g. `["totp"]`.
128        methods: Vec<String>,
129    },
130}