Skip to main content

arcly_http_identity/
identity.rs

1//! [`IdentityService`] — the orchestrator that ties stores, hashing, JWT
2//! minting, lockout, and MFA together into the register / login / refresh /
3//! logout flows. Provide it via DI and call it from your controllers (or mount
4//! the ready-made ones behind the `controllers` feature).
5
6use std::sync::Arc;
7
8use arcly_http_core::auth::JwtService;
9use rand::RngCore;
10
11use crate::consent::ConsentStore;
12use crate::error::{IdentityError, Result};
13use crate::federation::{
14    FederatedIdentity, FederatedIdentityStore, FederatedOutcome, FederatedProfile, FederationAudit,
15    FederationDecision, FederationEvent, LinkingConfig,
16};
17use crate::lockout::LockoutPolicy;
18use crate::mfa::MfaVerifier;
19use crate::model::{AccountStatus, Identity, LoginOutcome, RegisterRequest, TokenResponse};
20use crate::password::{BreachChecker, PasswordHasher, PasswordPolicy};
21use crate::risk::{RiskEngine, RiskSignal, RiskVerdict};
22use crate::session::{ChallengeStore, RefreshStore};
23use crate::store::{CredentialStore, UserStore};
24
25/// Behavioural knobs for the identity flows (B2C ↔ B2B).
26#[derive(Debug, Clone)]
27pub struct IdentityConfig {
28    /// New accounts start `Pending` and cannot log in until their email is
29    /// verified. B2C consumer default: `true`.
30    pub require_email_verification: bool,
31    /// Embed the identity's `tenant` into the access token so `TENANT` guard
32    /// can cross-check it. B2B default: `true`.
33    pub bind_tenant: bool,
34    /// Registration is closed — only pre-provisioned / invited users exist.
35    /// B2B org default: often `true`.
36    pub invite_only: bool,
37    /// TTL for a pending MFA challenge (seconds).
38    pub mfa_challenge_ttl_secs: u64,
39}
40
41impl Default for IdentityConfig {
42    fn default() -> Self {
43        Self {
44            require_email_verification: false,
45            bind_tenant: true,
46            invite_only: false,
47            mfa_challenge_ttl_secs: 300,
48        }
49    }
50}
51
52/// Composes every collaborator needed for the core auth flows. Cheap to clone
53/// (all fields are `Arc`), so it can be `provide`d and injected freely.
54#[derive(Clone)]
55pub struct IdentityService {
56    users: Arc<dyn UserStore>,
57    creds: Arc<dyn CredentialStore>,
58    hasher: Arc<dyn PasswordHasher>,
59    jwt: Arc<JwtService>,
60    refresh: Arc<dyn RefreshStore>,
61    challenges: Arc<dyn ChallengeStore>,
62    lockout: Option<Arc<LockoutPolicy>>,
63    mfa: Option<Arc<dyn MfaVerifier>>,
64    federated: Option<Arc<dyn FederatedIdentityStore>>,
65    risk: Option<Arc<dyn RiskEngine>>,
66    fed_audit: Option<Arc<dyn FederationAudit>>,
67    linking: LinkingConfig,
68    password_policy: PasswordPolicy,
69    breach: Option<Arc<dyn BreachChecker>>,
70    consent: Option<Arc<dyn ConsentStore>>,
71    required_consents: Vec<String>,
72    config: IdentityConfig,
73}
74
75/// Builder — the constructor has too many collaborators for a positional `new`.
76pub struct IdentityServiceBuilder {
77    users: Arc<dyn UserStore>,
78    creds: Arc<dyn CredentialStore>,
79    hasher: Arc<dyn PasswordHasher>,
80    jwt: Arc<JwtService>,
81    refresh: Arc<dyn RefreshStore>,
82    challenges: Arc<dyn ChallengeStore>,
83    lockout: Option<Arc<LockoutPolicy>>,
84    mfa: Option<Arc<dyn MfaVerifier>>,
85    federated: Option<Arc<dyn FederatedIdentityStore>>,
86    risk: Option<Arc<dyn RiskEngine>>,
87    fed_audit: Option<Arc<dyn FederationAudit>>,
88    linking: LinkingConfig,
89    password_policy: PasswordPolicy,
90    breach: Option<Arc<dyn BreachChecker>>,
91    consent: Option<Arc<dyn ConsentStore>>,
92    required_consents: Vec<String>,
93    config: IdentityConfig,
94}
95
96impl IdentityService {
97    /// Start building. Required collaborators are passed here; optional ones
98    /// (lockout, MFA) and config are attached fluently.
99    pub fn builder(
100        users: Arc<dyn UserStore>,
101        creds: Arc<dyn CredentialStore>,
102        hasher: Arc<dyn PasswordHasher>,
103        jwt: Arc<JwtService>,
104        refresh: Arc<dyn RefreshStore>,
105        challenges: Arc<dyn ChallengeStore>,
106    ) -> IdentityServiceBuilder {
107        IdentityServiceBuilder {
108            users,
109            creds,
110            hasher,
111            jwt,
112            refresh,
113            challenges,
114            lockout: None,
115            mfa: None,
116            federated: None,
117            risk: None,
118            fed_audit: None,
119            linking: LinkingConfig::default(),
120            password_policy: PasswordPolicy::default(),
121            breach: None,
122            consent: None,
123            required_consents: Vec::new(),
124            config: IdentityConfig::default(),
125        }
126    }
127
128    /// Register a new user. Hashes the password (if any) off the reactor, stores
129    /// the identity, and returns it. Does **not** issue tokens — callers usually
130    /// send a verification email first.
131    pub async fn register(&self, req: RegisterRequest) -> Result<Identity> {
132        if self.config.invite_only {
133            return Err(IdentityError::Forbidden);
134        }
135        // Reject duplicates early (the store also enforces this atomically).
136        if self
137            .users
138            .find_by_email(req.tenant.as_deref(), &req.email)
139            .await?
140            .is_some()
141        {
142            return Err(IdentityError::AlreadyExists);
143        }
144
145        let status = if self.config.require_email_verification {
146            AccountStatus::Pending
147        } else {
148            AccountStatus::Active
149        };
150        let identity = Identity {
151            id: new_id(),
152            tenant: req.tenant.clone(),
153            email: Some(req.email.clone()),
154            email_verified: false,
155            phone: None,
156            phone_verified: false,
157            status,
158            roles: vec!["user".to_owned()],
159            perms: Vec::new(),
160            mfa: Default::default(),
161            attributes: req.attributes.clone(),
162        };
163        // Password strength + breach checks (H4) run *before* the user is
164        // created, and their reasons are safe to surface (registration, not login).
165        if let Some(password) = &req.password {
166            self.password_policy.validate(password)?;
167            if let Some(checker) = &self.breach {
168                if checker.is_breached(password).await? {
169                    return Err(IdentityError::PasswordRejected(
170                        "this password has appeared in a known data breach".into(),
171                    ));
172                }
173            }
174        }
175
176        self.users.insert(&identity).await?;
177
178        if let Some(password) = req.password {
179            let phc = self.hasher.hash(password).await?;
180            self.creds.set_password(&identity.id, &phc).await?;
181        }
182        Ok(identity)
183    }
184
185    /// Authenticate with email + password. Returns either issued tokens or an
186    /// MFA challenge. `principal_ip` seeds lockout before the user is known.
187    pub async fn login(
188        &self,
189        tenant: Option<&str>,
190        email: &str,
191        password: &str,
192    ) -> Result<LoginOutcome> {
193        self.login_bound(tenant, email, password, None).await
194    }
195
196    /// Like [`login`](Self::login) but **sender-constrains** the issued access
197    /// token to a DPoP key (`cnf_jkt` from a verified proof, RFC 9449 / H3). The
198    /// binding is applied on the direct-auth path; when MFA is enrolled the
199    /// challenge path mints in `verify_mfa` (unbound for now).
200    pub async fn login_bound(
201        &self,
202        tenant: Option<&str>,
203        email: &str,
204        password: &str,
205        cnf_jkt: Option<&str>,
206    ) -> Result<LoginOutcome> {
207        // Throttle before the user is even known, keyed on tenant+email, so
208        // unknown-account probing is rate-limited and cannot enumerate.
209        if let Some(lock) = &self.lockout {
210            let principal = format!("{}::{}", tenant.unwrap_or("-"), email.to_ascii_lowercase());
211            lock.check(&principal).await?;
212        }
213
214        let user = self.users.find_by_email(tenant, email).await?;
215
216        // Uniform failure: run through the same shape whether or not the user
217        // exists to avoid timing / status-code enumeration.
218        let Some(user) = user else {
219            return Err(IdentityError::InvalidCredentials);
220        };
221
222        if !user.status.can_authenticate() {
223            // Pending / suspended / locked / deleted all present as 401.
224            return Err(IdentityError::InvalidCredentials);
225        }
226
227        let phc = self
228            .creds
229            .get_password(&user.id)
230            .await?
231            .ok_or(IdentityError::InvalidCredentials)?;
232
233        let ok = self.hasher.verify(password.to_owned(), phc.clone()).await?;
234        if !ok {
235            return Err(IdentityError::InvalidCredentials);
236        }
237
238        // Upgrade a legacy (bcrypt) hash transparently on successful login.
239        if self.hasher.needs_rehash(&phc) {
240            if let Ok(new_phc) = self.hasher.hash(password.to_owned()).await {
241                let _ = self.creds.set_password(&user.id, &new_phc).await;
242            }
243        }
244
245        if user.mfa.is_enrolled() {
246            let challenge_id = new_id();
247            self.challenges
248                .put(&challenge_id, &user.id, self.config.mfa_challenge_ttl_secs)
249                .await?;
250            return Ok(LoginOutcome::MfaChallenge {
251                mfa_required: true,
252                challenge_id,
253                methods: self.enrolled_methods(&user),
254            });
255        }
256
257        Ok(LoginOutcome::Authenticated(
258            self.mint_bound(&user, cnf_jkt).await?,
259        ))
260    }
261
262    /// Complete a pending MFA challenge with a factor response (`method` = e.g.
263    /// `"totp"` / `"recovery"`, `code` = the user-supplied value).
264    pub async fn verify_mfa(
265        &self,
266        challenge_id: &str,
267        method: &str,
268        code: &str,
269    ) -> Result<TokenResponse> {
270        let challenge = self
271            .challenges
272            .take(challenge_id)
273            .await?
274            .ok_or(IdentityError::InvalidToken)?;
275
276        let verifier = self.mfa.as_ref().ok_or(IdentityError::MfaFailed)?;
277        let ok = verifier.verify(&challenge.user_id, method, code).await?;
278        if !ok {
279            return Err(IdentityError::MfaFailed);
280        }
281        let user = self
282            .users
283            .find_by_id(&challenge.user_id)
284            .await?
285            .ok_or(IdentityError::NotFound)?;
286        self.mint(&user).await
287    }
288
289    /// Authenticate a user from an **external identity provider** (social login /
290    /// enterprise SSO), applying the takeover-safe linking policy, optional risk
291    /// step-up, and auditing — then minting through the *same* path as password
292    /// login. `signal` (when a `RiskEngine` is configured) drives adaptive MFA.
293    ///
294    /// See [`crate::federation`] for the security rationale behind each branch.
295    pub async fn login_federated(
296        &self,
297        tenant: Option<&str>,
298        profile: &FederatedProfile,
299        signal: Option<RiskSignal>,
300    ) -> Result<FederatedOutcome> {
301        let fed = self
302            .federated
303            .as_ref()
304            .ok_or_else(|| IdentityError::Internal("federated store not configured".into()))?;
305
306        // 1. Known external identity → straight to (risk/MFA →) mint.
307        if let Some(uid) = fed
308            .find_user(&profile.provider, &profile.provider_id)
309            .await?
310        {
311            let user = self
312                .users
313                .find_by_id(&uid)
314                .await?
315                .ok_or(IdentityError::NotFound)?;
316            self.audit_fed(
317                profile,
318                Some(&user.id),
319                tenant,
320                FederationDecision::ReturningLogin,
321                &signal,
322            )
323            .await;
324            return self.finish_federated(user, signal).await;
325        }
326
327        // 2. First sighting of this external identity — decide the local account.
328        let (user, decision) = match self.resolve_account(tenant, profile).await? {
329            Resolution::LinkRequired(email) => {
330                self.audit_fed(
331                    profile,
332                    None,
333                    tenant,
334                    FederationDecision::LinkRequired,
335                    &signal,
336                )
337                .await;
338                return Ok(FederatedOutcome::LinkRequired { email });
339            }
340            Resolution::Denied => {
341                self.audit_fed(profile, None, tenant, FederationDecision::Denied, &signal)
342                    .await;
343                return Err(IdentityError::Forbidden);
344            }
345            Resolution::Existing(user) => (user, FederationDecision::AutoLinked),
346            Resolution::Provisioned(user) => (user, FederationDecision::Provisioned),
347        };
348
349        // 3. Persist the link and continue.
350        fed.link(FederatedIdentity {
351            provider: profile.provider.clone(),
352            provider_id: profile.provider_id.clone(),
353            user_id: user.id.clone(),
354            linked_at: JwtService::unix_now(),
355        })
356        .await?;
357        self.audit_fed(profile, Some(&user.id), tenant, decision, &signal)
358            .await;
359        self.finish_federated(user, signal).await
360    }
361
362    /// Explicitly link an external identity to an **already-authenticated** local
363    /// user (`user_id`). This is the takeover-proof path the app runs after the
364    /// user proves ownership of the local account, in response to a
365    /// [`FederatedOutcome::LinkRequired`].
366    pub async fn link_federated(&self, user_id: &str, profile: &FederatedProfile) -> Result<()> {
367        let fed = self
368            .federated
369            .as_ref()
370            .ok_or_else(|| IdentityError::Internal("federated store not configured".into()))?;
371        // The external identity must not already belong to someone else.
372        if let Some(existing) = fed
373            .find_user(&profile.provider, &profile.provider_id)
374            .await?
375        {
376            if existing != user_id {
377                return Err(IdentityError::Forbidden);
378            }
379            return Ok(());
380        }
381        fed.link(FederatedIdentity {
382            provider: profile.provider.clone(),
383            provider_id: profile.provider_id.clone(),
384            user_id: user_id.to_owned(),
385            linked_at: JwtService::unix_now(),
386        })
387        .await
388    }
389
390    /// Decide which local account a first-time external identity maps to.
391    async fn resolve_account(
392        &self,
393        tenant: Option<&str>,
394        profile: &FederatedProfile,
395    ) -> Result<Resolution> {
396        // If a local account already owns this email, we must never create a
397        // duplicate and must be careful about linking.
398        if let Some(email) = profile.email.as_deref() {
399            if let Some(existing) = self.users.find_by_email(tenant, email).await? {
400                // The *only* case where silent linking is safe: the provider
401                // verified the email AND the local account's email is verified
402                // AND policy opts in. Everything else (either side unverified,
403                // or opt-in disabled) is held for an explicit, authenticated
404                // linking step — this is the pre-hijacking defence.
405                let both_verified = profile.email_verified && existing.email_verified;
406                return Ok(if both_verified && self.linking.auto_link_verified_email {
407                    Resolution::Existing(existing)
408                } else {
409                    Resolution::LinkRequired(email.to_owned())
410                });
411            }
412        }
413        // No email collision → JIT provision a brand-new account.
414        if self.linking.jit_provisioning && !self.config.invite_only {
415            Ok(Resolution::Provisioned(
416                self.provision_from_profile(tenant, profile).await?,
417            ))
418        } else {
419            Ok(Resolution::Denied)
420        }
421    }
422
423    /// Create a brand-new local identity from an external profile (no password).
424    async fn provision_from_profile(
425        &self,
426        tenant: Option<&str>,
427        profile: &FederatedProfile,
428    ) -> Result<Identity> {
429        let identity = Identity {
430            id: new_id(),
431            tenant: tenant.map(str::to_owned),
432            email: profile.email.clone(),
433            // Trust the provider's verified flag; unverified stays unverified so
434            // downstream flows can require confirmation.
435            email_verified: profile.email_verified,
436            phone: None,
437            phone_verified: false,
438            status: AccountStatus::Active,
439            roles: vec!["user".to_owned()],
440            perms: Vec::new(),
441            mfa: Default::default(),
442            attributes: profile.attributes.clone(),
443        };
444        self.users.insert(&identity).await?;
445        Ok(identity)
446    }
447
448    /// Shared tail for federated logins: apply risk step-up and existing MFA
449    /// enrolment, else mint.
450    async fn finish_federated(
451        &self,
452        user: Identity,
453        signal: Option<RiskSignal>,
454    ) -> Result<FederatedOutcome> {
455        if let (Some(engine), Some(sig)) = (&self.risk, signal.as_ref()) {
456            match engine.assess(sig).await {
457                RiskVerdict::Deny => return Err(IdentityError::Forbidden),
458                // StepUp is honoured by the enrolment check below: if a factor
459                // exists we challenge; if none exists we can't step up, so the
460                // login proceeds (the audit trail records the risk decision).
461                RiskVerdict::StepUp | RiskVerdict::Allow => {}
462            }
463        }
464        // Challenge whenever the account has a second factor enrolled.
465        if user.mfa.is_enrolled() {
466            let challenge_id = new_id();
467            self.challenges
468                .put(&challenge_id, &user.id, self.config.mfa_challenge_ttl_secs)
469                .await?;
470            return Ok(FederatedOutcome::MfaChallenge {
471                mfa_required: true,
472                challenge_id,
473                methods: self.enrolled_methods(&user),
474            });
475        }
476        Ok(FederatedOutcome::Authenticated(self.mint(&user).await?))
477    }
478
479    async fn audit_fed(
480        &self,
481        profile: &FederatedProfile,
482        user_id: Option<&str>,
483        tenant: Option<&str>,
484        decision: FederationDecision,
485        signal: &Option<RiskSignal>,
486    ) {
487        if let Some(sink) = &self.fed_audit {
488            sink.record(FederationEvent {
489                provider: profile.provider.clone(),
490                provider_id: profile.provider_id.clone(),
491                user_id: user_id.map(str::to_owned),
492                email: profile.email.clone(),
493                tenant: tenant.map(str::to_owned),
494                decision,
495                ip: signal.as_ref().and_then(|s| s.ip.clone()),
496                trace_id: signal.as_ref().and_then(|s| s.trace_id.clone()),
497            })
498            .await;
499        }
500    }
501
502    /// Rotate a refresh token: validate signature + stored `jti`, atomically
503    /// consume the old `jti`, and issue a fresh pair. Reuse of a consumed token
504    /// fails closed.
505    pub async fn refresh(&self, refresh_token: &str) -> Result<TokenResponse> {
506        let (sub, jti) = self
507            .jwt
508            .validate_refresh(refresh_token)
509            .ok_or(IdentityError::InvalidToken)?;
510
511        // Atomic single-use: if this returns None the token was already rotated
512        // (possible replay) — reject and revoke the whole chain.
513        match self.refresh.take(&jti).await? {
514            Some(uid) if uid == sub => {}
515            Some(_) => return Err(IdentityError::InvalidToken),
516            None => {
517                self.refresh.revoke_all(&sub).await.ok();
518                return Err(IdentityError::InvalidToken);
519            }
520        }
521
522        let user = self
523            .users
524            .find_by_id(&sub)
525            .await?
526            .ok_or(IdentityError::NotFound)?;
527        self.mint(&user).await
528    }
529
530    /// Revoke a specific refresh token (single-device logout).
531    pub async fn logout(&self, refresh_token: &str) -> Result<()> {
532        if let Some((_, jti)) = self.jwt.validate_refresh(refresh_token) {
533            self.refresh.revoke(&jti).await?;
534        }
535        Ok(())
536    }
537
538    /// Revoke every refresh token for a user ("log out everywhere").
539    pub async fn logout_all(&self, user_id: &str) -> Result<()> {
540        self.refresh.revoke_all(user_id).await
541    }
542
543    /// Fetch the current identity by id (for `/auth/me`).
544    pub async fn me(&self, user_id: &str) -> Result<Identity> {
545        self.users
546            .find_by_id(user_id)
547            .await?
548            .ok_or(IdentityError::NotFound)
549    }
550
551    // ── internals ──────────────────────────────────────────────────────────
552
553    /// Mint an access+refresh pair for an authenticated identity and persist the
554    /// refresh `jti` for rotation.
555    pub async fn mint(&self, user: &Identity) -> Result<TokenResponse> {
556        self.mint_bound(user, None).await
557    }
558
559    /// Like [`mint`](Self::mint) but **sender-constrains** the access token to a
560    /// DPoP key thumbprint (RFC 9449, H3). `cnf_jkt` comes from a verified
561    /// [`DpopProof`](crate::dpop::DpopProof); the resource server then requires a
562    /// matching proof on every request, so a leaked token can't be replayed.
563    pub async fn mint_bound(
564        &self,
565        user: &Identity,
566        cnf_jkt: Option<&str>,
567    ) -> Result<TokenResponse> {
568        // Consent gate (H5): the single funnel every login path passes through,
569        // so an unconsented subject can never be issued tokens regardless of the
570        // authentication method (password / social / SSO / passwordless).
571        if !self.required_consents.is_empty() {
572            if let Some(consent) = &self.consent {
573                let mut missing = Vec::new();
574                for purpose in &self.required_consents {
575                    if !consent.has_consent(&user.id, purpose).await? {
576                        missing.push(purpose.clone());
577                    }
578                }
579                if !missing.is_empty() {
580                    return Err(IdentityError::ConsentRequired(missing));
581                }
582            }
583        }
584
585        let tenant = if self.config.bind_tenant {
586            user.tenant.as_deref()
587        } else {
588            None
589        };
590        let email = user.email.clone().unwrap_or_default();
591        let access = self
592            .jwt
593            .issue_access_bound_cnf(
594                &user.id,
595                user.primary_role(),
596                &email,
597                &user.perms,
598                tenant,
599                cnf_jkt,
600            )
601            .map_err(|e| IdentityError::Internal(e.to_string()))?;
602        let (refresh, jti) = self
603            .jwt
604            .issue_refresh(&user.id)
605            .map_err(|e| IdentityError::Internal(e.to_string()))?;
606        self.refresh
607            .save(&jti, &user.id, self.jwt.refresh_ttl_secs())
608            .await?;
609        Ok(TokenResponse::bearer(
610            access,
611            refresh,
612            self.jwt.access_ttl_secs(),
613        ))
614    }
615
616    fn enrolled_methods(&self, user: &Identity) -> Vec<String> {
617        let mut m = Vec::new();
618        if user.mfa.totp_enrolled {
619            m.push("totp".to_owned());
620        }
621        if user.mfa.passkeys > 0 {
622            m.push("passkey".to_owned());
623        }
624        if user.mfa.recovery_codes_remaining > 0 {
625            m.push("recovery".to_owned());
626        }
627        m
628    }
629}
630
631impl IdentityServiceBuilder {
632    pub fn lockout(mut self, lockout: Arc<LockoutPolicy>) -> Self {
633        self.lockout = Some(lockout);
634        self
635    }
636    pub fn mfa(mut self, verifier: Arc<dyn MfaVerifier>) -> Self {
637        self.mfa = Some(verifier);
638        self
639    }
640    /// Enable federated login by supplying the identity-link store.
641    pub fn federated(mut self, store: Arc<dyn FederatedIdentityStore>) -> Self {
642        self.federated = Some(store);
643        self
644    }
645    /// Attach a risk engine for adaptive step-up on (federated or password) login.
646    pub fn risk(mut self, engine: Arc<dyn RiskEngine>) -> Self {
647        self.risk = Some(engine);
648        self
649    }
650    /// Attach an audit sink for federated-login decisions.
651    pub fn federation_audit(mut self, sink: Arc<dyn FederationAudit>) -> Self {
652        self.fed_audit = Some(sink);
653        self
654    }
655    /// Override the account-linking / provisioning policy (default is the
656    /// enterprise-safe posture).
657    pub fn linking(mut self, linking: LinkingConfig) -> Self {
658        self.linking = linking;
659        self
660    }
661    /// Enforce a password-strength policy at registration (H4).
662    pub fn password_policy(mut self, policy: PasswordPolicy) -> Self {
663        self.password_policy = policy;
664        self
665    }
666    /// Reject known-breached passwords via an app-supplied checker (HIBP).
667    pub fn breach_checker(mut self, checker: Arc<dyn BreachChecker>) -> Self {
668        self.breach = Some(checker);
669        self
670    }
671    /// Require the listed consent purposes before any token is minted (H5). The
672    /// gate lives at the single `mint` funnel, so it applies to every login path.
673    pub fn require_consents(mut self, store: Arc<dyn ConsentStore>, purposes: Vec<String>) -> Self {
674        self.consent = Some(store);
675        self.required_consents = purposes;
676        self
677    }
678    pub fn config(mut self, config: IdentityConfig) -> Self {
679        self.config = config;
680        self
681    }
682    pub fn build(self) -> IdentityService {
683        IdentityService {
684            users: self.users,
685            creds: self.creds,
686            hasher: self.hasher,
687            jwt: self.jwt,
688            refresh: self.refresh,
689            challenges: self.challenges,
690            lockout: self.lockout,
691            mfa: self.mfa,
692            federated: self.federated,
693            risk: self.risk,
694            fed_audit: self.fed_audit,
695            linking: self.linking,
696            password_policy: self.password_policy,
697            breach: self.breach,
698            consent: self.consent,
699            required_consents: self.required_consents,
700            config: self.config,
701        }
702    }
703}
704
705/// How a first-time external identity resolved to a local account.
706enum Resolution {
707    /// Silent-link into this existing (both-verified) account.
708    Existing(Identity),
709    /// A new local account was provisioned (JIT).
710    Provisioned(Identity),
711    /// A verified-email collision needs an explicit, authenticated linking step.
712    LinkRequired(String),
713    /// Not allowed (invite-only, or unverified email with JIT disabled).
714    Denied,
715}
716
717/// Collision-resistant opaque identifier (user ids, challenge ids). 128 bits of
718/// randomness, URL-safe base64.
719pub fn new_id() -> String {
720    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
721    use base64::Engine;
722    let mut b = [0u8; 16];
723    rand::thread_rng().fill_bytes(&mut b);
724    URL_SAFE_NO_PAD.encode(b)
725}