refactor(iam): introduce federated identity boundary (#5018)

This commit is contained in:
GatewayJ
2026-07-19 14:28:08 +08:00
committed by GitHub
parent 7f5873dac8
commit f9e8440a04
29 changed files with 1707 additions and 554 deletions
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::{FederatedSessionBindingError, FederatedSessionTransaction};
use rustfs_credentials::Credentials;
#[async_trait::async_trait]
pub trait FederatedSessionBinding: Send + Sync {
async fn bind(
&self,
transaction: &FederatedSessionTransaction,
) -> core::result::Result<Credentials, FederatedSessionBindingError>;
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
pub type Result<T> = core::result::Result<T, FederationError>;
#[derive(Debug, thiserror::Error)]
pub enum FederatedSessionBindingError {
#[error("{0}")]
InvalidRequest(String),
#[error("{0}")]
Internal(String),
}
#[derive(Debug, thiserror::Error)]
pub enum FederationError {
#[error("{0}")]
Authorization(String),
#[error("{0}")]
CodeExchange(String),
#[error("{0}")]
TokenVerification(String),
#[error("{0}")]
Logout(String),
#[error("no policies are available for this OIDC token")]
NoAuthorizationContext,
#[error(transparent)]
Binding(#[from] FederatedSessionBindingError),
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
mod binding;
mod error;
mod model;
pub mod oidc;
mod provider;
mod registry;
mod transaction;
pub use binding::FederatedSessionBinding;
pub use error::{FederatedSessionBindingError, FederationError, Result};
pub use model::{
FederatedAuthorization, FederatedClaims, FederatedCodeExchange, FederatedLoginSession, FederatedSession,
FederatedSessionTransaction,
};
pub use provider::FederatedIdentityProvider;
pub use registry::FederatedIdentityRegistry;
pub use transaction::FederatedIdentityService;
+124
View File
@@ -0,0 +1,124 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use rustfs_credentials::Credentials;
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct FederatedClaims {
pub sub: String,
pub email: String,
pub username: String,
pub groups: Vec<String>,
pub raw: HashMap<String, Value>,
}
impl FederatedClaims {
pub fn session_identity(&self) -> String {
if !self.username.is_empty() {
self.username.clone()
} else if !self.email.is_empty() {
self.email.clone()
} else if !self.sub.is_empty() {
self.sub.clone()
} else {
"oidc-user-unknown".to_string()
}
}
}
#[derive(Debug, Clone)]
pub struct FederatedAuthorization {
pub provider_id: String,
pub claims: FederatedClaims,
pub policies: Vec<String>,
pub groups: Vec<String>,
pub roles_claim_key: Option<String>,
pub roles: Vec<String>,
}
impl FederatedAuthorization {
pub fn has_authorization_context(&self) -> bool {
!self.policies.is_empty() || !self.groups.is_empty()
}
}
#[derive(Debug)]
pub struct FederatedCodeExchange {
pub authorization: FederatedAuthorization,
pub redirect_after: Option<String>,
pub id_token: String,
}
#[derive(Debug)]
pub struct FederatedSessionTransaction {
pub authorization: FederatedAuthorization,
pub duration_seconds: usize,
pub session_policy: Option<String>,
}
#[derive(Debug)]
pub struct FederatedSession {
pub credentials: Credentials,
pub authorization: FederatedAuthorization,
}
#[derive(Debug)]
pub struct FederatedLoginSession {
pub session: FederatedSession,
pub redirect_after: Option<String>,
pub logout_token: String,
}
#[cfg(test)]
mod tests {
use super::*;
fn claims(username: &str, email: &str, sub: &str) -> FederatedClaims {
FederatedClaims {
sub: sub.to_string(),
email: email.to_string(),
username: username.to_string(),
groups: Vec::new(),
raw: HashMap::new(),
}
}
fn authorization(policies: Vec<String>, groups: Vec<String>) -> FederatedAuthorization {
FederatedAuthorization {
provider_id: "standard_oidc".to_string(),
claims: claims("", "", "subject"),
policies,
groups,
roles_claim_key: None,
roles: Vec::new(),
}
}
#[test]
fn session_identity_preserves_existing_fallback_order() {
assert_eq!(claims("john", "john@example.com", "sub-1").session_identity(), "john");
assert_eq!(claims("", "john@example.com", "sub-1").session_identity(), "john@example.com");
assert_eq!(claims("", "", "sub-1").session_identity(), "sub-1");
assert_eq!(claims("", "", "").session_identity(), "oidc-user-unknown");
}
#[test]
fn authorization_context_accepts_policy_or_group() {
assert!(!authorization(Vec::new(), Vec::new()).has_authorization_context());
assert!(authorization(vec!["consoleAdmin".to_string()], Vec::new()).has_authorization_context());
assert!(authorization(Vec::new(), vec!["RustFS.ConsoleAdmin".to_string()]).has_authorization_context());
}
}
+102
View File
@@ -0,0 +1,102 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::config::roles_claim_key;
use crate::{
federation::{FederatedAuthorization, FederatedClaims},
oidc::{OidcClaims, OidcSys},
};
use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive};
fn string_list_claim(claims: &OidcClaims, claim_name: &str) -> Vec<String> {
match get_claim_case_insensitive(&claims.raw, claim_name) {
ClaimLookup::Found(serde_json::Value::Array(values)) => values
.iter()
.filter_map(|value| value.as_str().map(ToOwned::to_owned))
.collect(),
ClaimLookup::Found(serde_json::Value::String(value)) => value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect(),
ClaimLookup::Missing | ClaimLookup::Ambiguous | ClaimLookup::Found(_) => Vec::new(),
}
}
pub(super) fn authorization(oidc: &OidcSys, provider_id: String, claims: OidcClaims) -> FederatedAuthorization {
let (policies, groups) = oidc.map_claims_to_policies(&provider_id, &claims);
let roles_claim_key = roles_claim_key(oidc, &provider_id);
let roles = roles_claim_key
.as_deref()
.map(|claim_name| string_list_claim(&claims, claim_name))
.unwrap_or_default();
FederatedAuthorization {
provider_id,
claims: FederatedClaims {
sub: claims.sub,
email: claims.email,
username: claims.username,
groups: claims.groups,
raw: claims.raw,
},
policies,
groups,
roles_claim_key,
roles,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::HashMap;
#[test]
fn string_list_claim_matches_existing_array_and_csv_behavior() {
let claims = OidcClaims {
raw: HashMap::from([
("array_roles".to_string(), json!(["reader", 7, "writer"])),
("csv_roles".to_string(), json!("reader, writer, ,auditor")),
]),
..Default::default()
};
assert_eq!(string_list_claim(&claims, "array_roles"), ["reader", "writer"]);
assert_eq!(string_list_claim(&claims, "csv_roles"), ["reader", "writer", "auditor"]);
}
#[test]
fn string_list_claim_preserves_exact_and_ambiguous_match_behavior() {
let exact = OidcClaims {
raw: HashMap::from([
("Roles".to_string(), json!(["mixed-case"])),
("roles".to_string(), json!(["exact-match"])),
]),
..Default::default()
};
assert_eq!(string_list_claim(&exact, "roles"), ["exact-match"]);
let ambiguous = OidcClaims {
raw: HashMap::from([
("Roles".to_string(), json!(["mixed-case"])),
("ROLES".to_string(), json!(["upper-case"])),
]),
..Default::default()
};
assert!(string_list_claim(&ambiguous, "roles").is_empty());
}
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::oidc::{OidcProviderConfig, OidcSys};
pub(super) fn provider_config<'a>(oidc: &'a OidcSys, id: &str) -> Option<&'a OidcProviderConfig> {
oidc.get_provider_config(id)
}
pub(super) fn roles_claim_key(oidc: &OidcSys, provider_id: &str) -> Option<String> {
provider_config(oidc, provider_id)
.map(|config| config.roles_claim.trim().to_string())
.filter(|claim| !claim.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roles_claim_key_requires_explicit_provider_config() {
let oidc = OidcSys::empty().expect("empty OIDC configuration should be valid");
assert_eq!(roles_claim_key(&oidc, "default"), None);
}
}
@@ -0,0 +1,23 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::oidc::{OidcProviderSummary, OidcSys};
pub(super) fn list_providers(oidc: &OidcSys) -> Vec<OidcProviderSummary> {
oidc.list_providers()
}
pub(super) fn list_visible_providers(oidc: &OidcSys) -> Vec<OidcProviderSummary> {
oidc.list_visible_providers()
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::{claims, config, discovery, http};
use crate::{
federation::{FederatedAuthorization, FederatedCodeExchange, FederatedIdentityProvider, FederationError, Result},
oidc::{OidcProviderConfig, OidcProviderSummary, OidcSys},
};
use std::sync::Arc;
pub struct StandardOidcAdapter {
oidc: Arc<OidcSys>,
}
impl StandardOidcAdapter {
pub fn new(oidc: Arc<OidcSys>) -> Self {
Self { oidc }
}
}
#[async_trait::async_trait]
impl FederatedIdentityProvider for StandardOidcAdapter {
fn has_providers(&self) -> bool {
self.oidc.has_providers()
}
fn list_providers(&self) -> Vec<OidcProviderSummary> {
discovery::list_providers(&self.oidc)
}
fn list_visible_providers(&self) -> Vec<OidcProviderSummary> {
discovery::list_visible_providers(&self.oidc)
}
fn provider_config(&self, id: &str) -> Option<&OidcProviderConfig> {
config::provider_config(&self.oidc, id)
}
async fn authorize_url(&self, provider_id: &str, redirect_uri: &str, redirect_after: Option<String>) -> Result<String> {
http::authorize_url(&self.oidc, provider_id, redirect_uri, redirect_after).await
}
async fn exchange_code(&self, state: &str, code: &str, redirect_uri: &str) -> Result<FederatedCodeExchange> {
let (oidc_claims, provider_id, session, id_token) = http::exchange_code(&self.oidc, state, code, redirect_uri).await?;
Ok(FederatedCodeExchange {
authorization: claims::authorization(&self.oidc, provider_id, oidc_claims),
redirect_after: session.redirect_after,
id_token,
})
}
async fn verify_web_identity_token(&self, jwt: &str) -> Result<FederatedAuthorization> {
let (oidc_claims, provider_id) = self
.oidc
.verify_web_identity_token(jwt)
.await
.map_err(FederationError::TokenVerification)?;
Ok(claims::authorization(&self.oidc, provider_id, oidc_claims))
}
async fn create_logout_token(&self, provider_id: &str, id_token: &str) -> Result<String> {
http::create_logout_token(&self.oidc, provider_id, id_token).await
}
async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result<Option<String>> {
http::build_logout_url(&self.oidc, logout_token, post_logout_redirect_uri).await
}
}
+57
View File
@@ -0,0 +1,57 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::{
federation::{FederationError, Result},
oidc::{OidcClaims, OidcSys},
oidc_state::OidcAuthSession,
};
pub(super) async fn authorize_url(
oidc: &OidcSys,
provider_id: &str,
redirect_uri: &str,
redirect_after: Option<String>,
) -> Result<String> {
oidc.authorize_url(provider_id, redirect_uri, redirect_after)
.await
.map_err(FederationError::Authorization)
}
pub(super) async fn exchange_code(
oidc: &OidcSys,
state: &str,
code: &str,
redirect_uri: &str,
) -> Result<(OidcClaims, String, OidcAuthSession, String)> {
oidc.exchange_code(state, code, redirect_uri)
.await
.map_err(FederationError::CodeExchange)
}
pub(super) async fn create_logout_token(oidc: &OidcSys, provider_id: &str, id_token: &str) -> Result<String> {
oidc.create_logout_token(provider_id, id_token)
.await
.map_err(FederationError::Logout)
}
pub(super) async fn build_logout_url(
oidc: &OidcSys,
logout_token: &str,
post_logout_redirect_uri: &str,
) -> Result<Option<String>> {
oidc.build_logout_url(logout_token, post_logout_redirect_uri)
.await
.map_err(FederationError::Logout)
}
+21
View File
@@ -0,0 +1,21 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
mod claims;
mod config;
mod discovery;
mod flow;
mod http;
pub use flow::StandardOidcAdapter;
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::{FederatedAuthorization, FederatedCodeExchange, Result};
use crate::oidc::{OidcProviderConfig, OidcProviderSummary};
#[async_trait::async_trait]
pub trait FederatedIdentityProvider: Send + Sync {
fn has_providers(&self) -> bool;
fn list_providers(&self) -> Vec<OidcProviderSummary>;
fn list_visible_providers(&self) -> Vec<OidcProviderSummary>;
fn provider_config(&self, id: &str) -> Option<&OidcProviderConfig>;
async fn authorize_url(&self, provider_id: &str, redirect_uri: &str, redirect_after: Option<String>) -> Result<String>;
async fn exchange_code(&self, state: &str, code: &str, redirect_uri: &str) -> Result<FederatedCodeExchange>;
async fn verify_web_identity_token(&self, jwt: &str) -> Result<FederatedAuthorization>;
async fn create_logout_token(&self, provider_id: &str, id_token: &str) -> Result<String>;
async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result<Option<String>>;
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::FederatedIdentityProvider;
use std::sync::Arc;
/// Immutable registry of built-in federation adapters.
pub struct FederatedIdentityRegistry {
standard_oidc: Arc<dyn FederatedIdentityProvider>,
}
impl FederatedIdentityRegistry {
pub fn new(standard_oidc: Arc<dyn FederatedIdentityProvider>) -> Self {
Self { standard_oidc }
}
pub(crate) fn standard_oidc(&self) -> &dyn FederatedIdentityProvider {
self.standard_oidc.as_ref()
}
}
+307
View File
@@ -0,0 +1,307 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::{
FederatedIdentityRegistry, FederatedLoginSession, FederatedSession, FederatedSessionBinding, FederatedSessionTransaction,
FederationError, Result,
};
use crate::oidc::{OidcProviderConfig, OidcProviderSummary};
pub struct FederatedIdentityService {
registry: FederatedIdentityRegistry,
}
impl FederatedIdentityService {
pub fn new(registry: FederatedIdentityRegistry) -> Self {
Self { registry }
}
pub fn has_providers(&self) -> bool {
self.registry.standard_oidc().has_providers()
}
pub fn list_providers(&self) -> Vec<OidcProviderSummary> {
self.registry.standard_oidc().list_providers()
}
pub fn list_visible_providers(&self) -> Vec<OidcProviderSummary> {
self.registry.standard_oidc().list_visible_providers()
}
pub fn get_provider_config(&self, id: &str) -> Option<&OidcProviderConfig> {
self.registry.standard_oidc().provider_config(id)
}
pub async fn authorize_url(&self, provider_id: &str, redirect_uri: &str, redirect_after: Option<String>) -> Result<String> {
self.registry
.standard_oidc()
.authorize_url(provider_id, redirect_uri, redirect_after)
.await
}
pub async fn complete_authorization_code(
&self,
state: &str,
code: &str,
redirect_uri: &str,
duration_seconds: usize,
binding: &dyn FederatedSessionBinding,
) -> Result<FederatedLoginSession> {
let exchange = self.registry.standard_oidc().exchange_code(state, code, redirect_uri).await?;
let provider_id = exchange.authorization.provider_id.clone();
let transaction = FederatedSessionTransaction {
authorization: exchange.authorization,
duration_seconds,
session_policy: None,
};
let credentials = binding.bind(&transaction).await?;
let logout_token = self
.registry
.standard_oidc()
.create_logout_token(&provider_id, &exchange.id_token)
.await?;
Ok(FederatedLoginSession {
session: FederatedSession {
credentials,
authorization: transaction.authorization,
},
redirect_after: exchange.redirect_after,
logout_token,
})
}
pub async fn assume_role_with_web_identity(
&self,
jwt: &str,
duration_seconds: usize,
session_policy: Option<String>,
binding: &dyn FederatedSessionBinding,
) -> Result<FederatedSession> {
let authorization = self.registry.standard_oidc().verify_web_identity_token(jwt).await?;
if !authorization.has_authorization_context() {
tracing::warn!(
provider_id = %authorization.provider_id,
username = %authorization.claims.username,
sub = %authorization.claims.sub,
policy_count = authorization.policies.len(),
group_count = authorization.groups.len(),
"AssumeRoleWithWebIdentity has no mapped policies or groups"
);
return Err(FederationError::NoAuthorizationContext);
}
tracing::debug!(
provider_id = %authorization.provider_id,
username = %authorization.claims.username,
policy_count = authorization.policies.len(),
group_count = authorization.groups.len(),
policies = ?authorization.policies,
groups = ?authorization.groups,
"AssumeRoleWithWebIdentity mapped OIDC policies and groups"
);
let transaction = FederatedSessionTransaction {
authorization,
duration_seconds,
session_policy,
};
let credentials = binding.bind(&transaction).await?;
Ok(FederatedSession {
credentials,
authorization: transaction.authorization,
})
}
pub async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result<Option<String>> {
self.registry
.standard_oidc()
.build_logout_url(logout_token, post_logout_redirect_uri)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::federation::{
FederatedAuthorization, FederatedClaims, FederatedCodeExchange, FederatedIdentityProvider, FederatedSessionBindingError,
};
use crate::oidc::{OidcProviderConfig, OidcProviderSummary};
use rustfs_credentials::Credentials;
use std::sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
};
struct TestProvider {
with_policy: bool,
events: Arc<Mutex<Vec<&'static str>>>,
}
impl TestProvider {
fn record(&self, event: &'static str) {
self.events.lock().expect("event log should not be poisoned").push(event);
}
fn authorization(&self) -> FederatedAuthorization {
FederatedAuthorization {
provider_id: "default".to_string(),
claims: FederatedClaims {
sub: "subject".to_string(),
email: String::new(),
username: "user".to_string(),
groups: Vec::new(),
raw: Default::default(),
},
policies: if self.with_policy {
vec!["readwrite".to_string()]
} else {
Vec::new()
},
groups: Vec::new(),
roles_claim_key: None,
roles: Vec::new(),
}
}
}
#[async_trait::async_trait]
impl FederatedIdentityProvider for TestProvider {
fn has_providers(&self) -> bool {
true
}
fn list_providers(&self) -> Vec<OidcProviderSummary> {
Vec::new()
}
fn list_visible_providers(&self) -> Vec<OidcProviderSummary> {
Vec::new()
}
fn provider_config(&self, _id: &str) -> Option<&OidcProviderConfig> {
None
}
async fn authorize_url(
&self,
_provider_id: &str,
_redirect_uri: &str,
_redirect_after: Option<String>,
) -> Result<String> {
Ok("https://identity.example/authorize".to_string())
}
async fn exchange_code(&self, _state: &str, _code: &str, _redirect_uri: &str) -> Result<FederatedCodeExchange> {
self.record("exchange");
Ok(FederatedCodeExchange {
authorization: self.authorization(),
redirect_after: Some("/browser".to_string()),
id_token: "id-token".to_string(),
})
}
async fn verify_web_identity_token(&self, _jwt: &str) -> Result<FederatedAuthorization> {
self.record("verify");
Ok(self.authorization())
}
async fn create_logout_token(&self, _provider_id: &str, _id_token: &str) -> Result<String> {
self.record("logout");
Ok("logout-token".to_string())
}
async fn build_logout_url(&self, _logout_token: &str, _post_logout_redirect_uri: &str) -> Result<Option<String>> {
Ok(Some("https://identity.example/logout".to_string()))
}
}
struct CountingBinding {
calls: AtomicUsize,
events: Arc<Mutex<Vec<&'static str>>>,
}
#[async_trait::async_trait]
impl FederatedSessionBinding for CountingBinding {
async fn bind(
&self,
transaction: &FederatedSessionTransaction,
) -> core::result::Result<Credentials, FederatedSessionBindingError> {
self.calls.fetch_add(1, Ordering::Relaxed);
self.events.lock().expect("event log should not be poisoned").push("bind");
Ok(Credentials {
access_key: transaction.authorization.claims.session_identity(),
..Default::default()
})
}
}
#[tokio::test]
async fn callback_and_web_identity_share_session_binding() {
let events = Arc::new(Mutex::new(Vec::new()));
let provider = Arc::new(TestProvider {
with_policy: true,
events: events.clone(),
});
let binding = Arc::new(CountingBinding {
calls: AtomicUsize::new(0),
events: events.clone(),
});
let service = FederatedIdentityService::new(FederatedIdentityRegistry::new(provider));
let login = service
.complete_authorization_code("state", "code", "https://console.example/callback", 3600, binding.as_ref())
.await
.expect("callback flow should complete");
assert_eq!(login.session.credentials.access_key, "user");
assert_eq!(login.redirect_after.as_deref(), Some("/browser"));
assert_eq!(login.logout_token, "logout-token");
assert_eq!(
events.lock().expect("event log should not be poisoned").as_slice(),
["exchange", "bind", "logout"]
);
events.lock().expect("event log should not be poisoned").clear();
let web_identity = service
.assume_role_with_web_identity("jwt", 3600, None, binding.as_ref())
.await
.expect("web identity flow should complete");
assert_eq!(web_identity.credentials.access_key, "user");
assert_eq!(binding.calls.load(Ordering::Relaxed), 2);
assert_eq!(events.lock().expect("event log should not be poisoned").as_slice(), ["verify", "bind"]);
}
#[tokio::test]
async fn web_identity_without_policy_or_group_is_not_bound() {
let events = Arc::new(Mutex::new(Vec::new()));
let provider = Arc::new(TestProvider {
with_policy: false,
events: events.clone(),
});
let binding = Arc::new(CountingBinding {
calls: AtomicUsize::new(0),
events,
});
let service = FederatedIdentityService::new(FederatedIdentityRegistry::new(provider));
let error = service
.assume_role_with_web_identity("jwt", 3600, None, binding.as_ref())
.await
.expect_err("authorization context is required");
assert!(matches!(error, FederationError::NoAuthorizationContext));
assert_eq!(binding.calls.load(Ordering::Relaxed), 0);
}
}
+1
View File
@@ -28,6 +28,7 @@ const EVENT_OIDC_STATE: &str = "oidc_state";
pub mod cache;
pub mod error;
pub mod federation;
pub mod keyring;
pub mod manager;
pub mod oidc;