Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions common/auth/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ url = { workspace = true }
utoipa = { workspace = true, features = ["actix_extras"], optional = true }
utoipa-swagger-ui = { workspace = true, features = ["actix-web"], optional = true }

[dev-dependencies]
rstest = { workspace = true }

[features]
actix = ["actix-web", "actix-http", "actix-web-httpauth"]
swagger = ["utoipa", "utoipa-swagger-ui", "actix"]
5 changes: 5 additions & 0 deletions common/auth/schema/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@
],
"default": null
},
"scopeSelector": {
"description": "JSON path extracting scopes from the access token (default: $['scope','scp'])",
"type": "string",
"default": "$['scope','scp']"
},
"groupMappings": {
"description": "Mapping table for groups returned found through the `groups_selector` to permissions.",
"type": "object",
Expand Down
3 changes: 0 additions & 3 deletions common/auth/src/authenticator/claims.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@ pub struct AccessTokenClaims {

#[serde(flatten)]
pub extended_claims: Value,

#[serde(default, skip_serializing_if = "String::is_empty")]
pub scope: String,
}

impl CompactJson for AccessTokenClaims {}
Expand Down
12 changes: 12 additions & 0 deletions common/auth/src/authenticator/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ impl AuthenticatorConfig {
additional_permissions: Default::default(),
required_audience: None,
group_selector: None,
scope_selector: default_scope_selector(),
group_mappings: Default::default(),
tls_insecure: false,
tls_ca_certificates: Default::default(),
Expand Down Expand Up @@ -132,6 +133,10 @@ pub struct AuthenticatorClientConfig {
#[serde(default)]
pub group_selector: Option<String>,

/// JSON path extracting scopes from the access token (default: $['scope','scp'])
#[serde(default = "default_scope_selector")]
pub scope_selector: String,

/// Mapping table for groups returned found through the `groups_selector` to permissions.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub group_mappings: HashMap<String, Vec<String>>,
Expand All @@ -145,6 +150,12 @@ pub struct AuthenticatorClientConfig {
pub tls_ca_certificates: Vec<PathBuf>,
}

pub const DEFAULT_SCOPE_SELECTOR: &str = "$['scope','scp']";

fn default_scope_selector() -> String {
DEFAULT_SCOPE_SELECTOR.to_string()
}

impl SingleAuthenticatorClientConfig {
pub fn expand(self) -> impl Iterator<Item = AuthenticatorClientConfig> {
self.client_ids
Expand All @@ -157,6 +168,7 @@ impl SingleAuthenticatorClientConfig {
required_audience: self.required_audience.clone(),
scope_mappings: default_scope_mappings(),
group_selector: None,
scope_selector: default_scope_selector(),
group_mappings: Default::default(),
additional_permissions: Default::default(),
})
Expand Down
136 changes: 108 additions & 28 deletions common/auth/src/authenticator/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,22 @@ async fn create_client(config: AuthenticatorClientConfig) -> anyhow::Result<Auth
})
.transpose()?;

let scope_selector = parse_json_path(&config.scope_selector).map_err(|err| {
anyhow!(
"Unable to parse JSON path scope selector for client '{}' / '{}': {err}",
config.issuer_url,
client.client_id,
)
})?;

Ok(AuthenticatorClient {
client,
audience: config.required_audience,
scope_mappings: config.scope_mappings,
additional_permissions: config.additional_permissions,
group_selector,
group_mappings: config.group_mappings,
scope_selector,
})
}

Expand All @@ -210,27 +219,54 @@ pub struct AuthenticatorClient {
additional_permissions: Vec<String>,
group_selector: Option<JpQuery>,
group_mappings: HashMap<String, Vec<String>>,
scope_selector: JpQuery,
}

impl AuthenticatorClient {
/// Convert from a set of (verified!) access token claims into a [`ValidatedAccessToken`] struct.
pub fn convert_token(&self, access_token: AccessTokenClaims) -> ValidatedAccessToken {
let mut permissions = Self::map_scopes(&access_token.scope, &self.scope_mappings);
let extra_values = &access_token.extended_claims;
let mut permissions = Self::map_items(
Self::extract_scopes(extra_values, &self.scope_selector),
&self.scope_mappings,
);
permissions.extend(self.additional_permissions.clone());
let groups = self
.group_selector
.as_ref()
.map(|selector| Self::extract_groups(&access_token.extended_claims, selector))
.map(|selector| Self::extract_groups(extra_values, selector))
.unwrap_or_default();

permissions.extend(Self::map_groups(groups, &self.group_mappings));
permissions.extend(Self::map_items(groups, &self.group_mappings));

ValidatedAccessToken {
access_token,
permissions,
}
}

/// Extract scopes from the value/access token
fn extract_scopes(value: &Value, selector: &JpQuery) -> Vec<String> {
let mut result = Vec::new();
for qr in js_path_process(selector, value).ok().into_iter().flatten() {
match qr.val() {
Value::String(s) => {
result.extend(s.split_ascii_whitespace().map(str::to_string));
}
Value::Array(arr) => {
result.extend(
arr.iter()
.filter_map(|v| v.as_str())
.flat_map(|s| s.split_ascii_whitespace())
.map(str::to_string),
);
}
_ => {}
}
}
result
}

/// Extract the groups from the value/access token
fn extract_groups(value: &Value, selector: &JpQuery) -> Vec<String> {
js_path_process(selector, value)
Expand All @@ -242,31 +278,18 @@ impl AuthenticatorClient {
.collect()
}

/// Run the scopes through the scope mapping configuration
fn map_scopes(scopes: &str, scope_mappings: &HashMap<String, Vec<String>>) -> Vec<String> {
scopes
.split(' ')
.flat_map(|scope| {
scope_mappings
.get(scope)
.cloned()
.unwrap_or_else(|| vec![scope.to_string()])
})
.collect()
}

/// Run the groups through the group mapping configuration
fn map_groups(
groups: Vec<String>,
group_mappings: &HashMap<String, Vec<String>>,
fn map_items(
items: impl IntoIterator<Item = impl AsRef<str>>,
table: &HashMap<String, Vec<String>>,
) -> Vec<String> {
groups
.into_iter()
.flat_map(|group| match group_mappings.get(&group) {
Some(permissions) => permissions.clone(),
None => vec![group],
})
.collect()
let mut result = Vec::new();
for item in items {
match table.get(item.as_ref()) {
Some(mapped) => result.extend_from_slice(mapped),
None => result.push(item.as_ref().to_string()),
}
}
result
}
}

Expand All @@ -281,6 +304,8 @@ impl Deref for AuthenticatorClient {
#[cfg(test)]
mod test {
use super::*;
use rstest::rstest;
use serde_json::json;

fn assert_scope_mapping(scopes: &str, mappings: &[(&str, &[&str])], expected: &[&str]) {
let mappings = mappings
Expand All @@ -291,7 +316,7 @@ mod test {
.iter()
.map(|item| item.to_string())
.collect::<Vec<_>>();
let result = AuthenticatorClient::map_scopes(scopes, &mappings);
let result = AuthenticatorClient::map_items(scopes.split_whitespace(), &mappings);
assert_eq!(result, expected);
}

Expand Down Expand Up @@ -337,4 +362,59 @@ mod test {
let groups = AuthenticatorClient::extract_groups(&value, &selector);
assert_eq!(&groups, &["manager", "reader"]);
}

#[rstest]
#[case::scope_only(json!({"scope": "read:document create:document"}), vec!["read:document", "create:document"])]
#[case::scp_string(json!({"scp": "read:document"}), vec!["read:document"])]
#[case::scp_array(json!({"scp": ["api://app/read:document", "api://app/create:document"]}), vec!["api://app/read:document", "api://app/create:document"])]
#[case::both_claims(json!({"scope": "openid profile", "scp": ["api://app/read:document", "api://app/create:document"]}), vec!["openid", "profile", "api://app/read:document", "api://app/create:document"])]
fn test_extract_scopes(#[case] value: Value, #[case] expected: Vec<&str>) {
let selector = parse_json_path(config::DEFAULT_SCOPE_SELECTOR).unwrap();
assert_eq!(
AuthenticatorClient::extract_scopes(&value, &selector),
expected
);
}

#[test]
fn test_extract_scopes_custom_selector() {
let selector = parse_json_path("$.scp").unwrap();
let value = json!({
"scope": "openid profile",
"scp": ["read:document", "create:document"]
});
// Should extract only from scp, ignoring scope
assert_eq!(
AuthenticatorClient::extract_scopes(&value, &selector),
&["read:document", "create:document"]
);
}

#[rstest]
#[case::null_value(json!({"scope": null}), vec![])]
#[case::number_value(json!({"scope": 42}), vec![])]
#[case::boolean_value(json!({"scope": true}), vec![])]
#[case::object_value(json!({"scope": {"nested": "value"}}), vec![])]
#[case::empty_string(json!({"scope": ""}), vec![])]
#[case::empty_array(json!({"scp": []}), vec![])]
#[case::no_matching_fields(json!({"other": "value"}), vec![])]
fn test_extract_scopes_non_string_and_empty(#[case] value: Value, #[case] expected: Vec<&str>) {
let selector = parse_json_path(config::DEFAULT_SCOPE_SELECTOR).unwrap();
assert_eq!(
AuthenticatorClient::extract_scopes(&value, &selector),
expected
);
}

#[rstest]
#[case::mixed_types(json!({"scp": ["read:document", 42, null, "write:document", true]}), vec!["read:document", "write:document"])]
#[case::string_multiple_whitespace(json!({"scope": " read:document write:document "}), vec!["read:document", "write:document"])]
#[case::array_with_whitespace(json!({"scp": [" read:document write:document ", " delete:document "]}), vec!["read:document", "write:document", "delete:document"])]
fn test_extract_scopes_edge_cases(#[case] value: Value, #[case] expected: Vec<&str>) {
let selector = parse_json_path(config::DEFAULT_SCOPE_SELECTOR).unwrap();
assert_eq!(
AuthenticatorClient::extract_scopes(&value, &selector),
expected
);
}
}
Loading