@@ -16,7 +16,7 @@ use crate::{
1616 authenticator:: claims:: ValidatedAccessToken , authenticator:: config:: AuthenticatorConfig ,
1717} ;
1818use anyhow:: anyhow;
19- use biscuit:: { SingleOrMultiple , jws:: Compact } ;
19+ use biscuit:: jws:: Compact ;
2020use claims:: AccessTokenClaims ;
2121use config:: AuthenticatorClientConfig ;
2222use error:: AuthenticationError ;
@@ -31,6 +31,8 @@ use std::{collections::HashMap, ops::Deref};
3131use tracing:: instrument;
3232use trustify_common:: reqwest:: ClientFactory ;
3333
34+ const DEFAULT_SCOPE_SELECTOR : & str = "$['scope','scp']" ;
35+
3436/// An authenticator to authenticate incoming requests.
3537#[ derive( Clone ) ]
3638pub 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
215233impl 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) ]
299328mod 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