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:
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user