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}