Skip to content

Commit 42d4fff

Browse files
mrrajanclaude
andcommitted
Fix and improve scope extraction implementation
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Signed-off-by: mrrajan <86094767+mrrajan@users.noreply.github.com.>
1 parent ceffeae commit 42d4fff

File tree

6 files changed

+108
-99
lines changed

6 files changed

+108
-99
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/auth/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ url = { workspace = true }
3737
utoipa = { workspace = true, features = ["actix_extras"], optional = true }
3838
utoipa-swagger-ui = { workspace = true, features = ["actix-web"], optional = true }
3939

40+
[dev-dependencies]
41+
rstest = { workspace = true }
42+
4043
[features]
4144
actix = ["actix-web", "actix-http", "actix-web-httpauth"]
4245
swagger = ["utoipa", "utoipa-swagger-ui", "actix"]

common/auth/schema/auth.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@
7777
],
7878
"default": null
7979
},
80+
"scopeSelector": {
81+
"description": "JSON path extracting scopes from the access token (default: $['scope','scp'])",
82+
"type": [
83+
"string",
84+
"null"
85+
],
86+
"default": null
87+
},
8088
"groupMappings": {
8189
"description": "Mapping table for groups returned found through the `groups_selector` to permissions.",
8290
"type": "object",

common/auth/src/authenticator/claims.rs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,6 @@ pub struct AccessTokenClaims {
2727

2828
#[serde(default, skip_serializing_if = "String::is_empty")]
2929
pub scope: String,
30-
31-
/// Azure Entra ID uses 'scp' instead of 'scope'
32-
/// This can be either a single string or an array of strings
33-
#[serde(default, skip_serializing_if = "Option::is_none")]
34-
pub scp: Option<SingleOrMultiple<String>>,
3530
}
3631

3732
impl CompactJson for AccessTokenClaims {}

common/auth/src/authenticator/config.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ impl AuthenticatorConfig {
4444
additional_permissions: Default::default(),
4545
required_audience: None,
4646
group_selector: None,
47+
scope_selector: None,
4748
group_mappings: Default::default(),
4849
tls_insecure: false,
4950
tls_ca_certificates: Default::default(),
@@ -132,6 +133,10 @@ pub struct AuthenticatorClientConfig {
132133
#[serde(default)]
133134
pub group_selector: Option<String>,
134135

136+
/// JSON path extracting scopes from the access token (default: $['scope','scp'])
137+
#[serde(default)]
138+
pub scope_selector: Option<String>,
139+
135140
/// Mapping table for groups returned found through the `groups_selector` to permissions.
136141
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
137142
pub group_mappings: HashMap<String, Vec<String>>,
@@ -157,6 +162,7 @@ impl SingleAuthenticatorClientConfig {
157162
required_audience: self.required_audience.clone(),
158163
scope_mappings: default_scope_mappings(),
159164
group_selector: None,
165+
scope_selector: None,
160166
group_mappings: Default::default(),
161167
additional_permissions: Default::default(),
162168
})

common/auth/src/authenticator/mod.rs

Lines changed: 90 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use crate::{
1616
authenticator::claims::ValidatedAccessToken, authenticator::config::AuthenticatorConfig,
1717
};
1818
use anyhow::anyhow;
19-
use biscuit::{SingleOrMultiple, jws::Compact};
19+
use biscuit::jws::Compact;
2020
use claims::AccessTokenClaims;
2121
use config::AuthenticatorClientConfig;
2222
use error::AuthenticationError;
@@ -31,6 +31,8 @@ use std::{collections::HashMap, ops::Deref};
3131
use tracing::instrument;
3232
use trustify_common::reqwest::ClientFactory;
3333

34+
const DEFAULT_SCOPE_SELECTOR: &str = "$['scope','scp']";
35+
3436
/// An authenticator to authenticate incoming requests.
3537
#[derive(Clone)]
3638
pub struct Authenticator {
@@ -192,13 +194,28 @@ async fn create_client(config: AuthenticatorClientConfig) -> anyhow::Result<Auth
192194
})
193195
.transpose()?;
194196

197+
let scope_selector = parse_json_path(
198+
config
199+
.scope_selector
200+
.as_deref()
201+
.unwrap_or(DEFAULT_SCOPE_SELECTOR),
202+
)
203+
.map_err(|err| {
204+
anyhow!(
205+
"Unable to parse JSON path scope selector for client '{}' / '{}': {err}",
206+
config.issuer_url,
207+
client.client_id,
208+
)
209+
})?;
210+
195211
Ok(AuthenticatorClient {
196212
client,
197213
audience: config.required_audience,
198214
scope_mappings: config.scope_mappings,
199215
additional_permissions: config.additional_permissions,
200216
group_selector,
201217
group_mappings: config.group_mappings,
218+
scope_selector,
202219
})
203220
}
204221

@@ -210,14 +227,17 @@ pub struct AuthenticatorClient {
210227
additional_permissions: Vec<String>,
211228
group_selector: Option<JpQuery>,
212229
group_mappings: HashMap<String, Vec<String>>,
230+
scope_selector: JpQuery,
213231
}
214232

215233
impl AuthenticatorClient {
216234
/// Convert from a set of (verified!) access token claims into a [`ValidatedAccessToken`] struct.
217235
pub fn convert_token(&self, access_token: AccessTokenClaims) -> ValidatedAccessToken {
218-
// Combine scopes from both 'scope' and 'scp' claims
219-
// Azure Entra ID uses 'scp', while other providers use 'scope'
220-
let scopes = Self::extract_scopes(&access_token);
236+
let token_value = serde_json::to_value(&access_token).unwrap_or_else(|err| {
237+
log::warn!("Failed to serialize access token for scope extraction: {err}");
238+
Value::Null
239+
});
240+
let scopes = Self::extract_scopes(&token_value, &self.scope_selector);
221241
let mut permissions = Self::map_scopes(&scopes, &self.scope_mappings);
222242
permissions.extend(self.additional_permissions.clone());
223243
let groups = self
@@ -234,18 +254,27 @@ impl AuthenticatorClient {
234254
}
235255
}
236256

237-
/// Extract scopes from both 'scope' and 'scp' claims
238-
fn extract_scopes(access_token: &AccessTokenClaims) -> String {
239-
let scp_part = access_token.scp.as_ref().map(|scp| match scp {
240-
SingleOrMultiple::Single(s) => s.clone(),
241-
SingleOrMultiple::Multiple(list) => list.join(" "),
242-
});
243-
244-
match (access_token.scope.is_empty(), scp_part) {
245-
(true, Some(scp)) => scp,
246-
(false, Some(scp)) => format!("{} {}", access_token.scope, scp),
247-
(_, None) => access_token.scope.clone(),
248-
}
257+
/// Extract scopes from the value/access token
258+
fn extract_scopes(value: &Value, selector: &JpQuery) -> String {
259+
js_path_process(selector, value)
260+
.ok()
261+
.into_iter()
262+
.flatten()
263+
.flat_map(|qr| match qr.val() {
264+
Value::String(s) => s
265+
.split_ascii_whitespace()
266+
.map(|s| s.to_string())
267+
.collect::<Vec<_>>(),
268+
Value::Array(arr) => arr
269+
.iter()
270+
.filter_map(|v| v.as_str())
271+
.flat_map(|s| s.split_ascii_whitespace())
272+
.map(|s| s.to_string())
273+
.collect(),
274+
_ => vec![],
275+
})
276+
.collect::<Vec<_>>()
277+
.join(" ")
249278
}
250279

251280
/// Extract the groups from the value/access token
@@ -298,7 +327,8 @@ impl Deref for AuthenticatorClient {
298327
#[cfg(test)]
299328
mod test {
300329
use super::*;
301-
use biscuit::SingleOrMultiple;
330+
use rstest::rstest;
331+
use serde_json::json;
302332

303333
fn assert_scope_mapping(scopes: &str, mappings: &[(&str, &[&str])], expected: &[&str]) {
304334
let mappings = mappings
@@ -356,89 +386,55 @@ mod test {
356386
assert_eq!(&groups, &["manager", "reader"]);
357387
}
358388

359-
#[test]
360-
fn test_extract_scopes_from_scope_claim() {
361-
let claims = AccessTokenClaims {
362-
azp: None,
363-
sub: "user123".to_string(),
364-
iss: "https://example.com".parse().unwrap(),
365-
aud: None,
366-
exp: 0,
367-
iat: 0,
368-
auth_time: None,
369-
extended_claims: Value::Object(Default::default()),
370-
scope: "read:document create:document".to_string(),
371-
scp: None,
372-
};
373-
374-
let scopes = AuthenticatorClient::extract_scopes(&claims);
375-
assert_eq!(scopes, "read:document create:document");
389+
#[rstest]
390+
#[case::scope_only("read:document create:document", json!({}), "read:document create:document")]
391+
#[case::scp_string("", json!({"scp": "read:document"}), "read:document")]
392+
#[case::scp_array("", json!({"scp": ["api://app/read:document", "api://app/create:document"]}), "api://app/read:document api://app/create:document")]
393+
#[case::both_claims("openid profile", json!({"scp": ["api://app/read:document", "api://app/create:document"]}), "openid profile api://app/read:document api://app/create:document")]
394+
fn test_extract_scopes(#[case] scope: &str, #[case] mut extra: Value, #[case] expected: &str) {
395+
let selector = parse_json_path(DEFAULT_SCOPE_SELECTOR).unwrap();
396+
if !scope.is_empty() {
397+
extra["scope"] = json!(scope);
398+
}
399+
assert_eq!(
400+
AuthenticatorClient::extract_scopes(&extra, &selector),
401+
expected
402+
);
376403
}
377404

378405
#[test]
379-
fn test_extract_scopes_from_scp_claim_single() {
380-
let claims = AccessTokenClaims {
381-
azp: None,
382-
sub: "user123".to_string(),
383-
iss: "https://example.com".parse().unwrap(),
384-
aud: None,
385-
exp: 0,
386-
iat: 0,
387-
auth_time: None,
388-
extended_claims: Value::Object(Default::default()),
389-
scope: "".to_string(),
390-
scp: Some(SingleOrMultiple::Single("read:document".to_string())),
391-
};
392-
393-
let scopes = AuthenticatorClient::extract_scopes(&claims);
394-
assert_eq!(scopes, "read:document");
406+
fn test_extract_scopes_custom_selector() {
407+
let selector = parse_json_path("$.scp").unwrap();
408+
let value = json!({
409+
"scope": "openid profile",
410+
"scp": ["read:document", "create:document"]
411+
});
412+
// Should extract only from scp, ignoring scope
413+
assert_eq!(
414+
AuthenticatorClient::extract_scopes(&value, &selector),
415+
"read:document create:document"
416+
);
395417
}
396418

397-
#[test]
398-
fn test_extract_scopes_from_scp_claim_multiple() {
399-
let claims = AccessTokenClaims {
400-
azp: None,
401-
sub: "user123".to_string(),
402-
iss: "https://example.com".parse().unwrap(),
403-
aud: None,
404-
exp: 0,
405-
iat: 0,
406-
auth_time: None,
407-
extended_claims: Value::Object(Default::default()),
408-
scope: "".to_string(),
409-
scp: Some(SingleOrMultiple::Multiple(vec![
410-
"api://app/read:document".to_string(),
411-
"api://app/create:document".to_string(),
412-
])),
413-
};
414-
415-
let scopes = AuthenticatorClient::extract_scopes(&claims);
416-
assert_eq!(scopes, "api://app/read:document api://app/create:document");
419+
#[rstest]
420+
#[case::null_value(json!({"scope": null}), "")]
421+
#[case::number_value(json!({"scope": 42}), "")]
422+
#[case::boolean_value(json!({"scope": true}), "")]
423+
#[case::object_value(json!({"scope": {"nested": "value"}}), "")]
424+
#[case::empty_string(json!({"scope": ""}), "")]
425+
#[case::empty_array(json!({"scp": []}), "")]
426+
#[case::no_matching_fields(json!({"other": "value"}), "")]
427+
fn test_extract_scopes_non_string_and_empty(#[case] value: Value, #[case] expected: &str) {
428+
let selector = parse_json_path(DEFAULT_SCOPE_SELECTOR).unwrap();
429+
assert_eq!(AuthenticatorClient::extract_scopes(&value, &selector), expected);
417430
}
418431

419-
// Combine scopes from both claims (additive, not fallback)
420-
#[test]
421-
fn test_extract_scopes_from_both_claims() {
422-
let claims = AccessTokenClaims {
423-
azp: None,
424-
sub: "user123".to_string(),
425-
iss: "https://example.com".parse().unwrap(),
426-
aud: None,
427-
exp: 0,
428-
iat: 0,
429-
auth_time: None,
430-
extended_claims: Value::Object(Default::default()),
431-
scope: "openid profile".to_string(),
432-
scp: Some(SingleOrMultiple::Multiple(vec![
433-
"api://app/read:document".to_string(),
434-
"api://app/create:document".to_string(),
435-
])),
436-
};
437-
438-
let scopes = AuthenticatorClient::extract_scopes(&claims);
439-
assert_eq!(
440-
scopes,
441-
"openid profile api://app/read:document api://app/create:document"
442-
);
432+
#[rstest]
433+
#[case::mixed_types(json!({"scp": ["read:document", 42, null, "write:document", true]}), "read:document write:document")]
434+
#[case::string_multiple_whitespace(json!({"scope": " read:document write:document "}), "read:document write:document")]
435+
#[case::array_with_whitespace(json!({"scp": [" read:document write:document ", " delete:document "]}), "read:document write:document delete:document")]
436+
fn test_extract_scopes_edge_cases(#[case] value: Value, #[case] expected: &str) {
437+
let selector = parse_json_path(DEFAULT_SCOPE_SELECTOR).unwrap();
438+
assert_eq!(AuthenticatorClient::extract_scopes(&value, &selector), expected);
443439
}
444440
}

0 commit comments

Comments
 (0)