Skip to content

Commit 32ab683

Browse files
ngerakinesbigmoves
authored andcommitted
Oauth scopes (graze-social#49)
1 parent 5bbc55c commit 32ab683

26 files changed

Lines changed: 1270 additions & 513 deletions

Cargo.lock

Lines changed: 234 additions & 262 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ path = "src/lib.rs"
1919
[dependencies]
2020
axum = { version = "0.8" }
2121

22-
atproto-identity = { version = "0.11.0", features = ["lru", "zeroize", "hickory-dns"] }
23-
atproto-oauth = { version = "0.11.0", features = ["lru", "zeroize", "hickory-dns"] }
24-
atproto-oauth-axum = { version = "0.11.0", features = ["zeroize"] }
25-
atproto-client = { version = "0.11.0" }
26-
atproto-xrpcs = { version = "0.11.0", features = ["hickory-dns"] }
22+
atproto-identity = { version = "0.11.3", features = ["lru", "zeroize", "hickory-dns"] }
23+
atproto-oauth = { version = "0.11.3", features = ["lru", "zeroize", "hickory-dns"] }
24+
atproto-oauth-axum = { version = "0.11.3", features = ["zeroize"] }
25+
atproto-client = { version = "0.11.3" }
26+
atproto-xrpcs = { version = "0.11.3", features = ["hickory-dns"] }
2727

2828
axum-template = { version = "3.0", features = ["minijinja"] }
2929
minijinja = { version = "2.7", features = ["builtins"] }

src/config.rs

Lines changed: 142 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@
22
33
use anyhow::Result;
44
use atproto_identity::key::{KeyData, identify_key, generate_key, KeyType};
5+
use atproto_oauth::scopes::Scope;
56
use std::time::Duration;
67

78
use 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)]
1117
pub 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+
403429
impl 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+
494623
impl AsRef<String> for AtprotoClientName {
495624
fn as_ref(&self) -> &String {
496625
&self.0

src/errors.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ pub enum ConfigError {
3636
"error-aip-config-7 Failed to parse boolean '{0}': expected true/false/1/0/yes/no/on/off"
3737
)]
3838
BoolParsingFailed(String),
39+
40+
/// Error when OAuth scopes don't meet requirements
41+
#[error("error-aip-config-8 Invalid scope configuration: {0}")]
42+
InvalidScope(String),
3943
}
4044

4145
/// HTTP server errors

src/http/handler_atprotocol_client_metadata.rs

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
//! Handles GET /oauth/atp/client-metadata - Provides ATProtocol OAuth client metadata per RFC 7591
1+
//! Handles GET /oauth-client-metadata.json - Provides ATProtocol OAuth client metadata per RFC 7591
22
3+
use atproto_oauth::scopes::Scope;
34
use atproto_oauth_axum::{handler_metadata::handle_oauth_metadata, state::OAuthClientConfig};
45
use axum::{extract::State, response::IntoResponse};
56

67
use super::context::AppState;
8+
use crate::config::ATPROTO_CLIENT_METADATA_PATH;
79

810
/// Handles requests for ATProtocol OAuth client metadata.
911
///
@@ -13,10 +15,31 @@ pub async fn handle_atpoauth_client_metadata(
1315
State(app_state): State<AppState>,
1416
) -> impl IntoResponse {
1517
// Convert AppState configuration to OAuthClientConfig
18+
// Filter scopes to only include ATProtocol-compatible scopes for client metadata
19+
// Parse the configured scopes
20+
let all_scopes = app_state
21+
.config
22+
.oauth_supported_scopes
23+
.as_ref()
24+
.clone();
25+
26+
// Filter the scopes using the same function as in atprotocol_bridge
27+
// This removes non-ATProtocol scopes and validates requirements
28+
let filtered_scopes = match crate::oauth::scope_validation::filter_atprotocol_scopes(&all_scopes) {
29+
Ok(scopes) => scopes,
30+
Err(_) => {
31+
// If filtering fails (e.g., missing required scopes), default to just atproto
32+
vec![Scope::Atproto]
33+
}
34+
};
35+
36+
// Serialize the filtered scopes
37+
let scopes = Scope::serialize_multiple(&filtered_scopes);
38+
1639
let oauth_client_config = OAuthClientConfig {
1740
client_id: format!(
18-
"{}/oauth/atp/client-metadata",
19-
app_state.config.external_base
41+
"{}{}",
42+
app_state.config.external_base, ATPROTO_CLIENT_METADATA_PATH
2043
),
2144
redirect_uris: format!("{}/oauth/atp/callback", app_state.config.external_base),
2245
jwks_uri: None, // Use inline JWKS instead of external URI
@@ -26,7 +49,7 @@ pub async fn handle_atpoauth_client_metadata(
2649
logo_uri: app_state.config.atproto_client_logo.as_ref().clone(),
2750
tos_uri: app_state.config.atproto_client_tos.as_ref().clone(),
2851
policy_uri: app_state.config.atproto_client_policy.as_ref().clone(),
29-
scope: Some("atproto transition:generic transition:email".to_string()),
52+
scope: Some(scopes),
3053
};
3154

3255
// Use the atproto-oauth-axum handler

src/http/handler_atprotocol_oauth_authorize.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,14 @@ async fn process_authorization_query(
174174
None
175175
};
176176

177+
// Apply compat_scopes to normalize scope format if present
178+
let normalized_scope = query.scope.as_ref().map(|s| crate::oauth::scope_validation::compat_scopes(s));
179+
177180
let request = AuthorizationRequest {
178181
response_type: vec![crate::oauth::types::ResponseType::Code],
179182
client_id: query.client_id.clone(),
180183
redirect_uri,
181-
scope: query.scope.clone(),
184+
scope: normalized_scope,
182185
state: query.state.clone(),
183186
code_challenge: query.code_challenge.clone(),
184187
code_challenge_method: query.code_challenge_method.clone(),
@@ -190,7 +193,7 @@ async fn process_authorization_query(
190193
if let Some(ref requested_scope) = request.scope {
191194
let requested_scopes = crate::oauth::types::parse_scope(requested_scope);
192195
let supported_scopes =
193-
crate::oauth::types::parse_scope(&config.oauth_supported_scopes.as_ref().join(" "));
196+
crate::oauth::types::parse_scope(&config.oauth_supported_scopes.as_strings().join(" "));
194197

195198
if !requested_scopes.is_subset(&supported_scopes) {
196199
return Err(serde_json::json!({
@@ -284,7 +287,7 @@ mod tests {
284287
atproto_oauth_signing_keys: Default::default(),
285288
oauth_signing_keys: Default::default(),
286289
oauth_supported_scopes: crate::config::OAuthSupportedScopes::try_from(
287-
"read write atproto:atproto".to_string(),
290+
"atproto transition:generic transition:email".to_string(),
288291
)
289292
.unwrap(),
290293
dpop_nonce_seed: "seed".to_string(),
@@ -312,7 +315,7 @@ mod tests {
312315
client_id: "test-client".to_string(),
313316
redirect_uri: Some("https://example.com/callback".to_string()),
314317
response_type: Some("code".to_string()),
315-
scope: Some("read write".to_string()),
318+
scope: Some("atproto transition:generic".to_string()),
316319
state: Some("test-state".to_string()),
317320
code_challenge: None,
318321
code_challenge_method: None,
@@ -346,7 +349,7 @@ mod tests {
346349
client_id: "test-client".to_string(),
347350
redirect_uri: Some("https://example.com/callback".to_string()),
348351
response_type: Some("code".to_string()),
349-
scope: Some("read".to_string()),
352+
scope: Some("atproto".to_string()),
350353
state: Some("test-state".to_string()),
351354
code_challenge: Some("test-challenge".to_string()),
352355
code_challenge_method: Some("S256".to_string()),

src/http/handler_index.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ mod tests {
7373
atproto_oauth_signing_keys: Default::default(),
7474
oauth_signing_keys: Default::default(),
7575
oauth_supported_scopes: crate::config::OAuthSupportedScopes::try_from(
76-
"read write atproto:atproto".to_string(),
76+
"atproto transition:generic transition:email".to_string(),
7777
)
7878
.unwrap(),
7979
dpop_nonce_seed: "seed".to_string(),

src/http/handler_oauth.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ mod tests {
166166
atproto_oauth_signing_keys: Default::default(),
167167
oauth_signing_keys: Default::default(),
168168
oauth_supported_scopes: crate::config::OAuthSupportedScopes::try_from(
169-
"read write atproto:atproto".to_string(),
169+
"atproto transition:generic transition:email".to_string(),
170170
)
171171
.unwrap(),
172172
dpop_nonce_seed: "seed".to_string(),

0 commit comments

Comments
 (0)