Skip to main content

arcly_http_identity/federation/
mod.rs

1//! Secure federated login ("Sign in with Google / Facebook / an enterprise
2//! IdP"). This is the **relying-party** side — the app consumes an external
3//! identity — as opposed to [`crate::oidc`], which *is* an identity provider.
4//!
5//! The design closes the two account-takeover holes a naive "find user by
6//! email → mint token" callback opens:
7//!
8//! 1. **Untrusted provider email** — a provider that returns an *unverified*
9//!    email must never be allowed to claim an existing account. We only ever
10//!    consider `email_verified == true` for linking.
11//! 2. **Pre-hijacking** — an attacker pre-registers a local (password) account
12//!    with the victim's address, then the victim signs in with a federated
13//!    provider. We refuse to auto-link into a local account whose *own* email
14//!    is unverified, and — by default — require an explicit, logged-in linking
15//!    step even when both sides are verified ([`LinkingConfig`]).
16//!
17//! Every decision flows through the **same minting path** as password login
18//! (`IdentityService::mint`), so scopes, TTLs, tenant binding, and refresh
19//! rotation can never diverge between login methods.
20
21pub mod audit;
22pub mod homerealm;
23mod idtoken;
24pub mod slo;
25
26#[cfg(feature = "saml")]
27pub mod saml;
28#[cfg(feature = "scim")]
29pub mod scim;
30
31pub use audit::AuditPipelineFederationSink;
32pub use homerealm::{IdpConfig, IdpKind, IdpRegistry};
33pub use idtoken::{IdTokenVerifier, JwtIdTokenVerifier, VerifiedIdToken};
34pub use slo::BackChannelLogout;
35
36use std::collections::HashMap;
37use std::sync::RwLock;
38
39use async_trait::async_trait;
40use serde::{Deserialize, Serialize};
41
42use crate::error::Result;
43use crate::model::TokenResponse;
44
45/// A normalised profile from an external identity provider, ready to feed
46/// [`IdentityService::login_federated`](crate::identity::IdentityService::login_federated).
47///
48/// Build it from your `OAuth2Provider`'s user-info **and** the provider's
49/// verified-email signal (never assume verified), or from a
50/// [`VerifiedIdToken`] via [`FederatedProfile::from_id_token`].
51#[derive(Debug, Clone)]
52pub struct FederatedProfile {
53    pub provider: String,
54    /// The provider's stable unique id for this account (Google `sub`, GitHub
55    /// numeric id, …). **Primary** linking key — never the email.
56    pub provider_id: String,
57    pub email: Option<String>,
58    /// Whether the *provider* asserts the email is verified. Gate all
59    /// email-based linking on this.
60    pub email_verified: bool,
61    pub name: Option<String>,
62    /// Extra claims to carry onto the identity's attributes (e.g. IdP groups).
63    pub attributes: serde_json::Map<String, serde_json::Value>,
64}
65
66impl FederatedProfile {
67    /// Build from the framework's OAuth2 user-info. `email_verified` must be
68    /// supplied explicitly — the OAuth2 user-info type carries no such signal,
69    /// and defaulting it to `true` is exactly the takeover bug.
70    pub fn from_oauth(
71        provider: impl Into<String>,
72        provider_id: impl Into<String>,
73        email: Option<String>,
74        email_verified: bool,
75    ) -> Self {
76        Self {
77            provider: provider.into(),
78            provider_id: provider_id.into(),
79            email,
80            email_verified,
81            name: None,
82            attributes: Default::default(),
83        }
84    }
85
86    /// Build from a verified inbound OIDC `id_token`. `email_verified` is taken
87    /// from the token's `email_verified` claim.
88    pub fn from_id_token(provider: impl Into<String>, tok: &VerifiedIdToken) -> Self {
89        Self {
90            provider: provider.into(),
91            provider_id: tok.sub.clone(),
92            email: tok.email.clone(),
93            email_verified: tok.email_verified,
94            name: tok.name.clone(),
95            attributes: tok.extra.clone(),
96        }
97    }
98}
99
100/// A persisted link between an external identity and a local user.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct FederatedIdentity {
103    pub provider: String,
104    pub provider_id: String,
105    pub user_id: String,
106    pub linked_at: u64,
107}
108
109/// Storage for federated-identity links. Back with a table carrying a unique
110/// constraint on `(provider, provider_id)`.
111#[async_trait]
112pub trait FederatedIdentityStore: Send + Sync + 'static {
113    /// Resolve the local `user_id` a given external identity is linked to.
114    async fn find_user(&self, provider: &str, provider_id: &str) -> Result<Option<String>>;
115    /// Create a link. Must be idempotent / reject duplicates at the store level.
116    async fn link(&self, link: FederatedIdentity) -> Result<()>;
117    /// All external identities linked to a user (for account-management UIs).
118    async fn list_for_user(&self, user_id: &str) -> Result<Vec<FederatedIdentity>>;
119    /// Remove a link (user unlinks a provider).
120    async fn unlink(&self, provider: &str, provider_id: &str) -> Result<()>;
121}
122
123/// Account-linking + provisioning policy. The defaults are the **enterprise-safe**
124/// posture: JIT-provision brand-new users, but never silently merge a federated
125/// login into a pre-existing local account.
126#[derive(Debug, Clone)]
127pub struct LinkingConfig {
128    /// Create a new local user the first time an unknown external identity signs
129    /// in (Just-In-Time provisioning). When `false`, unknown users are rejected
130    /// (pre-provisioned / invited orgs).
131    pub jit_provisioning: bool,
132    /// Automatically link a first-time federated identity to an existing local
133    /// account when **both** the provider email and the local account email are
134    /// verified. **Default `false`**: instead return
135    /// [`FederatedOutcome::LinkRequired`] so the app runs an explicit,
136    /// authenticated "connect your account" step — the only takeover-proof flow.
137    pub auto_link_verified_email: bool,
138}
139
140impl Default for LinkingConfig {
141    fn default() -> Self {
142        Self {
143            jit_provisioning: true,
144            auto_link_verified_email: false,
145        }
146    }
147}
148
149/// The outcome of a federated login attempt.
150#[derive(Debug, Clone, Serialize)]
151#[serde(untagged)]
152pub enum FederatedOutcome {
153    /// Fully authenticated — tokens issued.
154    Authenticated(TokenResponse),
155    /// A second factor (or risk step-up) is required; complete via
156    /// `IdentityService::verify_mfa` with `challenge_id`.
157    MfaChallenge {
158        mfa_required: bool,
159        challenge_id: String,
160        methods: Vec<String>,
161    },
162    /// The verified email matches an existing local account but policy forbids
163    /// silent linking. The app must have the user authenticate to that account
164    /// (or verify ownership) and then call
165    /// `IdentityService::link_federated` before this provider can sign them in.
166    LinkRequired { email: String },
167}
168
169// ─── Audit ──────────────────────────────────────────────────────────────────
170
171/// What the engine decided for one federated attempt — the audit-relevant fact.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum FederationDecision {
174    /// Known external identity signed in.
175    ReturningLogin,
176    /// New local user created and linked (JIT).
177    Provisioned,
178    /// Linked to an existing local account (only when policy allows).
179    AutoLinked,
180    /// Held for an explicit linking step.
181    LinkRequired,
182    /// Rejected (invite-only, unverified email with no JIT, risk deny).
183    Denied,
184}
185
186/// One federated-login audit event. Bridge this to the framework's `AuditSink`
187/// (hash-chained) in your app so every social/SSO login is on the trail.
188#[derive(Debug, Clone)]
189pub struct FederationEvent {
190    pub provider: String,
191    pub provider_id: String,
192    pub user_id: Option<String>,
193    pub email: Option<String>,
194    pub tenant: Option<String>,
195    pub decision: FederationDecision,
196    pub ip: Option<String>,
197    /// The originating request's W3C trace id (`ctx.trace_id_hex()`), when the
198    /// caller can supply it, so the audit record correlates to the request span
199    /// instead of abusing `trace_id` for something else. `None` off-request.
200    pub trace_id: Option<String>,
201}
202
203/// Sink for [`FederationEvent`]s. Optional — provide via the builder to record
204/// every federated decision.
205#[async_trait]
206pub trait FederationAudit: Send + Sync + 'static {
207    async fn record(&self, event: FederationEvent);
208}
209
210// ─── In-memory store (tests / dev) ──────────────────────────────────────────
211
212/// In-memory [`FederatedIdentityStore`] — tests / dev only.
213#[derive(Default)]
214pub struct MemoryFederatedStore {
215    // (provider, provider_id) -> FederatedIdentity
216    inner: RwLock<HashMap<(String, String), FederatedIdentity>>,
217}
218
219impl MemoryFederatedStore {
220    pub fn new() -> Self {
221        Self::default()
222    }
223}
224
225#[async_trait]
226impl FederatedIdentityStore for MemoryFederatedStore {
227    async fn find_user(&self, provider: &str, provider_id: &str) -> Result<Option<String>> {
228        Ok(self
229            .inner
230            .read()
231            .unwrap()
232            .get(&(provider.to_owned(), provider_id.to_owned()))
233            .map(|l| l.user_id.clone()))
234    }
235    async fn link(&self, link: FederatedIdentity) -> Result<()> {
236        self.inner
237            .write()
238            .unwrap()
239            .insert((link.provider.clone(), link.provider_id.clone()), link);
240        Ok(())
241    }
242    async fn list_for_user(&self, user_id: &str) -> Result<Vec<FederatedIdentity>> {
243        Ok(self
244            .inner
245            .read()
246            .unwrap()
247            .values()
248            .filter(|l| l.user_id == user_id)
249            .cloned()
250            .collect())
251    }
252    async fn unlink(&self, provider: &str, provider_id: &str) -> Result<()> {
253        self.inner
254            .write()
255            .unwrap()
256            .remove(&(provider.to_owned(), provider_id.to_owned()));
257        Ok(())
258    }
259}