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
25 changes: 13 additions & 12 deletions Cargo.lock

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

10 changes: 5 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ path = "src/lib.rs"
[dependencies]
axum = { version = "0.8" }

atproto-identity = { version = "0.11.3", features = ["lru", "zeroize", "hickory-dns"] }
atproto-oauth = { version = "0.11.3", features = ["lru", "zeroize", "hickory-dns"] }
atproto-oauth-axum = { version = "0.11.3", features = ["zeroize"] }
atproto-client = { version = "0.11.3" }
atproto-xrpcs = { version = "0.11.3", features = ["hickory-dns"] }
atproto-identity = { version = "0.12.0", features = ["lru", "zeroize", "hickory-dns"] }
atproto-oauth = { version = "0.12.0", features = ["lru", "zeroize", "hickory-dns"] }
atproto-oauth-axum = { version = "0.12.0", features = ["zeroize"] }
atproto-client = { version = "0.12.0" }
atproto-xrpcs = { version = "0.12.0", features = ["hickory-dns"] }

axum-template = { version = "3.0", features = ["minijinja"] }
minijinja = { version = "2.7", features = ["builtins"] }
Expand Down
23 changes: 15 additions & 8 deletions src/bin/aip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,10 +350,12 @@ async fn ensure_internal_device_auth_client(
oauth_storage: &Arc<dyn aip::storage::traits::OAuthStorage>,
config: &Config,
) -> Result<()> {
use aip::oauth::types::{OAuthClient, ApplicationType, GrantType, ResponseType, ClientAuthMethod, ClientType};

use aip::oauth::types::{
ApplicationType, ClientAuthMethod, ClientType, GrantType, OAuthClient, ResponseType,
};

let client_id = config.internal_device_auth_client_id.as_ref();

// Check if client already exists
match oauth_storage.get_client(client_id).await {
Ok(Some(_)) => {
Expand All @@ -368,7 +370,7 @@ async fn ensure_internal_device_auth_client(
// Continue to try creating the client
}
}

// Create the internal client
let redirect_uri = format!("{}/device/callback", config.external_base);
let now = chrono::Utc::now();
Expand All @@ -394,10 +396,15 @@ async fn ensure_internal_device_auth_client(
registration_access_token: None,
jwks: None,
};

oauth_storage.store_client(&client).await

oauth_storage
.store_client(&client)
.await
.map_err(|e| anyhow::anyhow!("Failed to create internal device auth client: {}", e))?;

tracing::info!("Internal device auth client created successfully: {}", client_id);

tracing::info!(
"Internal device auth client created successfully: {}",
client_id
);
Ok(())
}
26 changes: 10 additions & 16 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ impl TryFrom<Option<String>> for OAuthSupportedScopes {

// Apply compat_scopes to normalize scope format before parsing
let normalized_value = crate::oauth::scope_validation::compat_scopes(&value);

// Parse the provided scopes
let scopes = Scope::parse_multiple(&normalized_value)
.map_err(|e| ConfigError::InvalidScope(format!("Failed to parse scopes: {}", e)))?;
Expand Down Expand Up @@ -546,15 +546,11 @@ mod tests {
}

// Test 1: Valid scopes with openid (no transition:generic required)
let valid_openid =
OAuthSupportedScopes::try_from("atproto openid".to_string());
let valid_openid = OAuthSupportedScopes::try_from("atproto openid".to_string());
if let Err(ref e) = valid_openid {
eprintln!("Test 1 failed with error: {}", e);
}
assert!(
valid_openid.is_ok(),
"openid with atproto should be valid"
);
assert!(valid_openid.is_ok(), "openid with atproto should be valid");

// Test 2: Valid scopes with email (requires openid and email capability)
let valid_email =
Expand All @@ -565,12 +561,8 @@ mod tests {
);

// Test 3: Valid scopes - profile with openid
let valid_profile =
OAuthSupportedScopes::try_from("atproto openid profile".to_string());
assert!(
valid_profile.is_ok(),
"profile with openid should be valid"
);
let valid_profile = OAuthSupportedScopes::try_from("atproto openid profile".to_string());
assert!(valid_profile.is_ok(), "profile with openid should be valid");

// Test 4: Invalid scopes - email and profile without openid
let invalid_email = OAuthSupportedScopes::try_from("atproto email profile".to_string());
Expand All @@ -589,7 +581,8 @@ mod tests {
}

// Test 5: Invalid scopes - email without openid
let invalid_email_no_openid = OAuthSupportedScopes::try_from("atproto email transition:email".to_string());
let invalid_email_no_openid =
OAuthSupportedScopes::try_from("atproto email transition:email".to_string());
assert!(
invalid_email_no_openid.is_err(),
"email without openid should be invalid"
Expand All @@ -600,8 +593,9 @@ mod tests {
}

// Test 6: Valid email with account:email?action=read
let valid_email_alt =
OAuthSupportedScopes::try_from("atproto openid email account:email?action=read".to_string());
let valid_email_alt = OAuthSupportedScopes::try_from(
"atproto openid email account:email?action=read".to_string(),
);
assert!(
valid_email_alt.is_ok(),
"email with openid and account:email?action=read should be valid"
Expand Down
116 changes: 79 additions & 37 deletions src/http/handler_app_password.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,48 @@ pub struct AppPasswordResponse {
pub timestamp: String,
}

/// Check if app password exists
/// GET /api/atprotocol/app-password
///
/// Returns 204 No Content if an app password exists for the authenticated user
/// and the OAuth client, or 404 Not Found if it doesn't exist.
pub async fn get_app_password_handler(
State(state): State<AppState>,
ExtractedAuth(access_token): ExtractedAuth,
) -> Result<StatusCode, (StatusCode, Json<Value>)> {
// Extract DID from the access token
let did = access_token.user_id.as_ref().ok_or_else(|| {
let error_response = json!({
"error": "invalid_token",
"error_description": "Token missing user_id (DID)"
});
(StatusCode::UNAUTHORIZED, Json(error_response))
})?;

// Check if app password exists
let existing = state
.oauth_storage
.get_app_password(&access_token.client_id, did)
.await
.map_err(|e| {
let error_response = json!({
"error": "server_error",
"error_description": format!("Failed to check app password: {}", e)
});
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
})?;

if existing.is_some() {
Ok(StatusCode::NO_CONTENT)
} else {
let error_response = json!({
"error": "not_found",
"error_description": "No app password found for this client and user"
});
Err((StatusCode::NOT_FOUND, Json(error_response)))
}
}

/// Create or update app password
/// POST /api/atprotocol/app-password
///
Expand Down Expand Up @@ -85,6 +127,43 @@ pub async fn create_app_password_handler(

let is_update = existing.is_some();

// If this was an update, delete all associated sessions
if is_update {
state
.oauth_storage
.delete_app_password_sessions(&access_token.client_id, did)
.await
.map_err(|e| {
let error_response = json!({
"error": "server_error",
"error_description": format!("Failed to delete existing sessions: {}", e)
});
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
})?;
}

// Create app password entry
let app_password_entry = AppPassword {
client_id: access_token.client_id.clone(),
did: did.clone(),
app_password: app_password.clone(),
created_at: now,
updated_at: now,
};

// Store the app password
state
.oauth_storage
.store_app_password(&app_password_entry)
.await
.map_err(|e| {
let error_response = json!({
"error": "server_error",
"error_description": format!("Failed to store app password: {}", e)
});
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
})?;

// Get the DID document to extract PDS endpoint for session creation
let document = state
.document_storage
Expand Down Expand Up @@ -137,43 +216,6 @@ pub async fn create_app_password_handler(
(StatusCode::UNAUTHORIZED, Json(error_response))
})?;

// Create app password entry
let app_password_entry = AppPassword {
client_id: access_token.client_id.clone(),
did: did.clone(),
app_password,
created_at: existing.as_ref().map(|e| e.created_at).unwrap_or(now),
updated_at: now,
};

// Store the app password
state
.oauth_storage
.store_app_password(&app_password_entry)
.await
.map_err(|e| {
let error_response = json!({
"error": "server_error",
"error_description": format!("Failed to store app password: {}", e)
});
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
})?;

// If this was an update, delete all associated sessions
if is_update {
state
.oauth_storage
.delete_app_password_sessions(&access_token.client_id, did)
.await
.map_err(|e| {
let error_response = json!({
"error": "server_error",
"error_description": format!("Failed to delete existing sessions: {}", e)
});
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
})?;
}

let response = AppPasswordResponse {
client_id: access_token.client_id,
did: did.clone(),
Expand Down
25 changes: 11 additions & 14 deletions src/http/handler_atprotocol_client_metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,19 @@ pub async fn handle_atpoauth_client_metadata(
// Convert AppState configuration to OAuthClientConfig
// Filter scopes to only include ATProtocol-compatible scopes for client metadata
// Parse the configured scopes
let all_scopes = app_state
.config
.oauth_supported_scopes
.as_ref()
.clone();

let all_scopes = app_state.config.oauth_supported_scopes.as_ref().clone();

// Filter the scopes using the same function as in atprotocol_bridge
// This removes non-ATProtocol scopes and validates requirements
let filtered_scopes = match crate::oauth::scope_validation::filter_atprotocol_scopes(&all_scopes) {
Ok(scopes) => scopes,
Err(_) => {
// If filtering fails (e.g., missing required scopes), default to just atproto
vec![Scope::Atproto]
}
};

let filtered_scopes =
match crate::oauth::scope_validation::filter_atprotocol_scopes(&all_scopes) {
Ok(scopes) => scopes,
Err(_) => {
// If filtering fails (e.g., missing required scopes), default to just atproto
vec![Scope::Atproto]
}
};

// Serialize the filtered scopes
let scopes = Scope::serialize_multiple(&filtered_scopes);

Expand Down
Loading