22
33use anyhow:: Result ;
44use atproto_identity:: key:: { KeyData , identify_key, generate_key, KeyType } ;
5+ use atproto_oauth:: scopes:: Scope ;
56use std:: time:: Duration ;
67
78use crate :: errors:: ConfigError ;
89
10+ /// ATProtocol OAuth client metadata endpoint path
11+ /// This is the path where the ATProtocol client metadata document is served.
12+ /// The full URL is constructed by prepending the external_base URL.
13+ pub const ATPROTO_CLIENT_METADATA_PATH : & str = "/oauth-client-metadata.json" ;
14+
915/// HTTP server port configuration
1016#[ derive( Clone ) ]
1117pub struct HttpPort ( u16 ) ;
@@ -28,7 +34,7 @@ pub struct PrivateKeys(Vec<KeyData>);
2834
2935/// OAuth supported scopes configuration
3036#[ derive( Clone ) ]
31- pub struct OAuthSupportedScopes ( Vec < String > ) ;
37+ pub struct OAuthSupportedScopes ( Vec < Scope > ) ;
3238
3339/// Client default access token expiration configuration
3440#[ derive( Clone ) ]
@@ -115,7 +121,7 @@ impl Config {
115121 let oauth_signing_keys: PrivateKeys = optional_env ( "OAUTH_SIGNING_KEYS" ) . try_into ( ) ?;
116122 let oauth_supported_scopes: OAuthSupportedScopes = default_env (
117123 "OAUTH_SUPPORTED_SCOPES" ,
118- "openid profile email atproto:atproto atproto: transition:generic atproto: transition:email" ,
124+ "openid profile email atproto transition:generic transition:email" ,
119125 )
120126 . try_into ( ) ?;
121127 let plc_hostname = default_env ( "PLC_HOSTNAME" , "plc.directory" ) ;
@@ -370,17 +376,23 @@ impl TryFrom<Option<String>> for OAuthSupportedScopes {
370376 fn try_from ( value : Option < String > ) -> Result < Self , Self :: Error > {
371377 let value = value. unwrap_or_default ( ) ;
372378 if value. is_empty ( ) {
373- return Ok ( Self ( vec ! [
374- "atproto:atproto" . to_string( ) ,
375- "atproto:transition:generic" . to_string( ) ,
376- "atproto:transition:email" . to_string( ) ,
377- ] ) ) ;
379+ // Parse default scopes
380+ let default_scopes = "atproto transition:generic transition:email" ;
381+ let scopes = Scope :: parse_multiple_reduced ( default_scopes) . map_err ( |e| {
382+ ConfigError :: InvalidScope ( format ! ( "Failed to parse default scopes: {}" , e) )
383+ } ) ?;
384+ return Ok ( Self ( scopes) ) ;
378385 }
379386
380- let scopes = value
381- . split_whitespace ( )
382- . map ( |s| s. to_string ( ) )
383- . collect :: < Vec < String > > ( ) ;
387+ // Apply compat_scopes to normalize scope format before parsing
388+ let normalized_value = crate :: oauth:: scope_validation:: compat_scopes ( & value) ;
389+
390+ // Parse the provided scopes
391+ let scopes = Scope :: parse_multiple ( & normalized_value)
392+ . map_err ( |e| ConfigError :: InvalidScope ( format ! ( "Failed to parse scopes: {}" , e) ) ) ?;
393+
394+ // Validate scope requirements
395+ Self :: validate_scope_requirements ( & scopes) ?;
384396
385397 Ok ( Self ( scopes) )
386398 }
@@ -394,12 +406,26 @@ impl TryFrom<String> for OAuthSupportedScopes {
394406 }
395407}
396408
397- impl AsRef < Vec < String > > for OAuthSupportedScopes {
398- fn as_ref ( & self ) -> & Vec < String > {
409+ impl AsRef < Vec < Scope > > for OAuthSupportedScopes {
410+ fn as_ref ( & self ) -> & Vec < Scope > {
399411 & self . 0
400412 }
401413}
402414
415+ impl OAuthSupportedScopes {
416+ /// Validate that scopes contain required AT Protocol scopes when certain OAuth scopes are present
417+ /// This delegates to the centralized validation in scope_validation module
418+ pub fn validate_scope_requirements ( scopes : & [ Scope ] ) -> Result < ( ) , ConfigError > {
419+ crate :: oauth:: scope_validation:: validate_oauth_scope_requirements ( scopes)
420+ . map_err ( |e| ConfigError :: InvalidScope ( e. to_string ( ) ) )
421+ }
422+
423+ /// Get scopes as a Vec of strings for serialization
424+ pub fn as_strings ( & self ) -> Vec < String > {
425+ self . 0 . iter ( ) . map ( |s| s. to_string_normalized ( ) ) . collect ( )
426+ }
427+ }
428+
403429impl TryFrom < String > for ClientDefaultAccessTokenExpiration {
404430 type Error = anyhow:: Error ;
405431
@@ -491,6 +517,109 @@ impl TryFrom<String> for AtprotoClientName {
491517 }
492518}
493519
520+ #[ cfg( test) ]
521+ mod tests {
522+ use super :: * ;
523+
524+ #[ test]
525+ fn test_oauth_supported_scopes_validation ( ) {
526+ // Test 0: Invalid scopes - missing required atproto scope
527+ let missing_atproto =
528+ OAuthSupportedScopes :: try_from ( "openid transition:generic" . to_string ( ) ) ;
529+ assert ! (
530+ missing_atproto. is_err( ) ,
531+ "Configuration without atproto scope should fail"
532+ ) ;
533+ if let Err ( e) = missing_atproto {
534+ let error_msg = e. to_string ( ) ;
535+ assert ! ( error_msg. contains( "atproto" ) && error_msg. contains( "required" ) ) ;
536+ }
537+
538+ // Test 1: Valid scopes with openid (no transition:generic required)
539+ let valid_openid =
540+ OAuthSupportedScopes :: try_from ( "atproto openid" . to_string ( ) ) ;
541+ if let Err ( ref e) = valid_openid {
542+ eprintln ! ( "Test 1 failed with error: {}" , e) ;
543+ }
544+ assert ! (
545+ valid_openid. is_ok( ) ,
546+ "openid with atproto should be valid"
547+ ) ;
548+
549+ // Test 2: Valid scopes with email (requires openid and email capability)
550+ let valid_email =
551+ OAuthSupportedScopes :: try_from ( "atproto openid email transition:email" . to_string ( ) ) ;
552+ assert ! (
553+ valid_email. is_ok( ) ,
554+ "email with openid and transition:email should be valid"
555+ ) ;
556+
557+ // Test 3: Valid scopes - profile with openid
558+ let valid_profile =
559+ OAuthSupportedScopes :: try_from ( "atproto openid profile" . to_string ( ) ) ;
560+ assert ! (
561+ valid_profile. is_ok( ) ,
562+ "profile with openid should be valid"
563+ ) ;
564+
565+ // Test 4: Invalid scopes - email and profile without openid
566+ let invalid_email = OAuthSupportedScopes :: try_from ( "atproto email profile" . to_string ( ) ) ;
567+ assert ! (
568+ invalid_email. is_err( ) ,
569+ "email and profile without openid should fail"
570+ ) ;
571+ if let Err ( e) = invalid_email {
572+ let error_msg = e. to_string ( ) ;
573+ // Should fail on profile or email requiring openid
574+ assert ! (
575+ error_msg. contains( "openid" ) ,
576+ "Expected error about openid requirement, got: {}" ,
577+ error_msg
578+ ) ;
579+ }
580+
581+ // Test 5: Invalid scopes - email without openid
582+ let invalid_email_no_openid = OAuthSupportedScopes :: try_from ( "atproto email transition:email" . to_string ( ) ) ;
583+ assert ! (
584+ invalid_email_no_openid. is_err( ) ,
585+ "email without openid should be invalid"
586+ ) ;
587+ if let Err ( e) = invalid_email_no_openid {
588+ let error_msg = e. to_string ( ) ;
589+ assert ! ( error_msg. contains( "email" ) && error_msg. contains( "openid" ) ) ;
590+ }
591+
592+ // Test 6: Valid email with account:email?action=read
593+ let valid_email_alt =
594+ OAuthSupportedScopes :: try_from ( "atproto openid email account:email?action=read" . to_string ( ) ) ;
595+ assert ! (
596+ valid_email_alt. is_ok( ) ,
597+ "email with openid and account:email?action=read should be valid"
598+ ) ;
599+
600+ // Test 6: Valid scopes with both openid and email with all requirements
601+ let valid_both = OAuthSupportedScopes :: try_from (
602+ "atproto openid email transition:generic transition:email" . to_string ( ) ,
603+ ) ;
604+ assert ! (
605+ valid_both. is_ok( ) ,
606+ "openid and email with all requirements should be valid"
607+ ) ;
608+
609+ // Test 7: Invalid scopes - email with transition:generic (doesn't grant email)
610+ let invalid_email_with_generic =
611+ OAuthSupportedScopes :: try_from ( "atproto openid email transition:generic" . to_string ( ) ) ;
612+ assert ! (
613+ invalid_email_with_generic. is_err( ) ,
614+ "email with only transition:generic should be invalid (doesn't grant email access)"
615+ ) ;
616+ if let Err ( e) = invalid_email_with_generic {
617+ let error_msg = e. to_string ( ) ;
618+ assert ! ( error_msg. contains( "email" ) && error_msg. contains( "requires" ) ) ;
619+ }
620+ }
621+ }
622+
494623impl AsRef < String > for AtprotoClientName {
495624 fn as_ref ( & self ) -> & String {
496625 & self . 0
0 commit comments