refactor(oidc): migrate provider into issuer

this make OIDC compliant with the invariant binding (issuer and subject)
admin can now rename their provider without breaking

clarifing federation_kind: report the kind of federation wired not the allowed login method
hybryd login method are still allowed
This commit is contained in:
Edouard Vanbelle
2026-08-08 15:24:06 +02:00
parent 10a8dd7d8b
commit d8b3f2e026
15 changed files with 390 additions and 61 deletions
@@ -783,6 +783,7 @@ impl UserRepository for UserPgRepository {
Option<chrono::DateTime<chrono::Utc>>,
bool,
Option<String>,
Option<String>,
bool,
bool,
bool,
@@ -797,13 +798,15 @@ impl UserRepository for UserPgRepository {
// pays. `has_password` on the password_hash column tells
// the admin table whether a server-verifiable password is
// on file; combined with the two OPAQUE flags and
// federation_issuer, the SPA derives the full "capability
// set" per user (password / OPAQUE / SSO / passwordless).
// federation_kind / federation_issuer, the SPA derives the
// full "capability set" per user (password / OPAQUE / SSO /
// passwordless).
r#"
SELECT
id, username, email, role::text,
storage_quota_bytes, storage_used_bytes,
last_login_at, active, federation_issuer, is_external,
last_login_at, active,
federation_kind, federation_issuer, is_external,
(password_hash IS NOT NULL) AS has_password,
(opaque_envelope IS NOT NULL) AS opaque_registered,
(opaque_migrated_at IS NOT NULL) AS opaque_migrated
@@ -832,6 +835,7 @@ impl UserRepository for UserPgRepository {
storage_used_bytes,
last_login_at,
active,
federation_kind,
federation_issuer,
is_external,
has_password,
@@ -850,6 +854,7 @@ impl UserRepository for UserPgRepository {
storage_used_bytes,
last_login_at,
active,
federation_kind,
federation_issuer,
is_external,
has_password,
@@ -1369,6 +1374,34 @@ impl UserStoragePort for UserPgRepository {
Ok(())
}
async fn rebind_federation_issuer(
&self,
user_id: Uuid,
new_issuer: &str,
) -> Result<(), DomainError> {
// Same `IS DISTINCT FROM` guard as sync_oidc_login_profile: this
// fires on every OIDC login, so the common already-migrated case
// must be a zero-write no-op. Only actually flips the column
// when the stored value is stale (legacy display label vs the
// real issuer URL from the id_token's `iss` claim).
sqlx::query(
r#"
UPDATE auth.users
SET federation_issuer = $2,
updated_at = NOW()
WHERE id = $1
AND federation_issuer IS DISTINCT FROM $2
"#,
)
.bind(user_id)
.bind(new_issuer)
.execute(&*self.pool)
.await
.map_err(Self::map_sqlx_error)
.map_err(DomainError::from)?;
Ok(())
}
async fn list_users_by_role(&self, role: &str) -> Result<Vec<User>, DomainError> {
UserRepository::list_users_by_role(self, role)
.await
@@ -180,6 +180,39 @@ impl OidcService {
}
}
/// Authoritative issuer URL from the IdP's discovery document —
/// **cache-only, non-async, non-blocking**. Returns `Some(issuer)`
/// when discovery has been fetched successfully before AND is not
/// expired; returns `None` otherwise (cold cache OR expired without
/// re-fetch).
///
/// Deliberately does NOT trigger a network fetch — this is the
/// accessor that public endpoints (`/api/auth/oidc/providers`)
/// use, and driving IdP HTTP off every unauthenticated request is
/// a DoS amplifier. The cache gets warmed as a side-effect of
/// every real OIDC flow (authorize / callback / login /
/// validate_id_token all call `get_discovery`), so within seconds
/// of the first legit login this returns `Some`.
///
/// Callers that need the definitive answer (validate_id_token, JIT
/// provisioning) should keep going through the async
/// discovery-fetching path. Callers that need a display hint
/// (providers endpoint) MUST use this non-async path and fall back
/// to a config value when it returns `None`.
pub fn cached_issuer(&self) -> Option<String> {
// try_read is non-blocking; if the cache write lock is held
// (extremely rare, only during a discovery refresh), we return
// None rather than block on public traffic.
let cache = self.discovery.try_read().ok()?;
cache.as_ref().and_then(|cached| {
if cached.is_expired() {
None
} else {
Some(cached.value.issuer.clone())
}
})
}
/// Fetch and cache the OIDC discovery document (TTL: 1 hour)
async fn get_discovery(&self) -> Result<OidcDiscovery, DomainError> {
// Check cache first (return cached value only if not expired)
@@ -495,6 +528,13 @@ impl OidcServicePort for OidcService {
Ok(OidcIdClaims {
sub: claims.sub,
// Safe echo: jsonwebtoken::decode with
// `validation.set_issuer(&[&discovery.issuer])` above already
// enforced iss == discovery.issuer, so the discovery value
// IS the validated iss claim. The caller (auth service uses
// it for Phase B lazy-rebind) can trust this without a
// second validation pass.
iss: discovery.issuer.clone(),
email: claims.email,
email_verified: claims.email_verified,
preferred_username: claims.preferred_username,
@@ -510,6 +550,11 @@ impl OidcServicePort for OidcService {
async fn fetch_user_info(&self, access_token: &str) -> Result<OidcIdClaims, DomainError> {
let discovery = self.get_discovery().await?;
// Capture issuer before we move `userinfo_endpoint` out below.
// Same rationale as validate_id_token: discovery.issuer IS the
// authoritative iss for this deployment; fetch_user_info is only
// called after a successful token exchange with this same issuer.
let iss = discovery.issuer.clone();
let userinfo_url = discovery.userinfo_endpoint.ok_or_else(|| {
DomainError::new(
@@ -551,6 +596,7 @@ impl OidcServicePort for OidcService {
Ok(OidcIdClaims {
sub: info.sub,
iss,
email: info.email,
email_verified: info.email_verified,
preferred_username: info.preferred_username,
@@ -741,6 +787,7 @@ impl OidcServicePort for OidcService {
sub: claims.sub,
sid: claims.sid,
jti: claims.jti,
iss: claims.iss,
})
}
}