mod common; mod helpers; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use common::{base_url, client, get_test_db_pool}; use helpers::verify_new_account; use reqwest::{StatusCode, redirect}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn no_redirect_client() -> reqwest::Client { reqwest::Client::builder() .redirect(redirect::Policy::none()) .build() .unwrap() } fn generate_pkce() -> (String, String) { let verifier_bytes: [u8; 32] = rand::random(); let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); let mut hasher = Sha256::new(); hasher.update(code_verifier.as_bytes()); let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); (code_verifier, code_challenge) } async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer { let mock_server = MockServer::start().await; let client_id = mock_server.uri(); let metadata = json!({ "client_id": client_id, "client_name": "Test OAuth Client", "redirect_uris": [redirect_uri], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", "dpop_bound_access_tokens": false }); Mock::given(method("GET")) .and(path("/")) .respond_with(ResponseTemplate::new(200).set_body_json(metadata)) .mount(&mock_server) .await; mock_server } #[tokio::test] async fn test_oauth_metadata_endpoints() { let url = base_url().await; let client = client(); let pr_res = client .get(format!("{}/.well-known/oauth-protected-resource", url)) .send() .await .unwrap(); assert_eq!(pr_res.status(), StatusCode::OK); let pr_body: Value = pr_res.json().await.unwrap(); assert!(pr_body["resource"].is_string()); assert!(pr_body["authorization_servers"].is_array()); assert!( pr_body["bearer_methods_supported"] .as_array() .unwrap() .contains(&json!("header")) ); let as_res = client .get(format!("{}/.well-known/oauth-authorization-server", url)) .send() .await .unwrap(); assert_eq!(as_res.status(), StatusCode::OK); let as_body: Value = as_res.json().await.unwrap(); assert!(as_body["issuer"].is_string()); assert!(as_body["authorization_endpoint"].is_string()); assert!(as_body["token_endpoint"].is_string()); assert!(as_body["jwks_uri"].is_string()); assert!( as_body["response_types_supported"] .as_array() .unwrap() .contains(&json!("code")) ); assert!( as_body["grant_types_supported"] .as_array() .unwrap() .contains(&json!("authorization_code")) ); assert!( as_body["code_challenge_methods_supported"] .as_array() .unwrap() .contains(&json!("S256")) ); assert_eq!( as_body["require_pushed_authorization_requests"], json!(true) ); assert!( as_body["dpop_signing_alg_values_supported"] .as_array() .unwrap() .contains(&json!("ES256")) ); let jwks_res = client .get(format!("{}/oauth/jwks", url)) .send() .await .unwrap(); assert_eq!(jwks_res.status(), StatusCode::OK); let jwks_body: Value = jwks_res.json().await.unwrap(); assert!(jwks_body["keys"].is_array()); } #[tokio::test] async fn test_par_and_authorize() { let url = base_url().await; let client = client(); let redirect_uri = "https://example.com/callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); let (_, code_challenge) = generate_pkce(); let par_res = client .post(format!("{}/oauth/par", url)) .form(&[ ("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri), ("code_challenge", &code_challenge), ("code_challenge_method", "S256"), ("scope", "atproto"), ("state", "test-state"), ]) .send() .await .unwrap(); assert_eq!(par_res.status(), StatusCode::CREATED, "PAR should succeed"); let par_body: Value = par_res.json().await.unwrap(); assert!(par_body["request_uri"].is_string()); assert!(par_body["expires_in"].is_number()); let request_uri = par_body["request_uri"].as_str().unwrap(); assert!(request_uri.starts_with("urn:ietf:params:oauth:request_uri:")); let auth_res = client .get(format!("{}/oauth/authorize", url)) .header("Accept", "application/json") .query(&[("request_uri", request_uri)]) .send() .await .unwrap(); assert_eq!(auth_res.status(), StatusCode::OK); let auth_body: Value = auth_res.json().await.unwrap(); assert_eq!(auth_body["client_id"], client_id); assert_eq!(auth_body["redirect_uri"], redirect_uri); assert_eq!(auth_body["scope"], "atproto"); let invalid_res = client .get(format!("{}/oauth/authorize", url)) .header("Accept", "application/json") .query(&[( "request_uri", "urn:ietf:params:oauth:request_uri:nonexistent", )]) .send() .await .unwrap(); assert_eq!(invalid_res.status(), StatusCode::BAD_REQUEST); let missing_client = no_redirect_client(); let missing_res = missing_client .get(format!("{}/oauth/authorize", url)) .send() .await .unwrap(); assert!( missing_res.status().is_redirection(), "Should redirect to error page" ); let error_location = missing_res .headers() .get("location") .unwrap() .to_str() .unwrap(); assert!( error_location.contains("oauth/error"), "Should redirect to error page" ); } #[tokio::test] async fn test_full_oauth_flow() { let url = base_url().await; let http_client = client(); let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; let handle = format!("ot{}", suffix); let email = format!("ot{}@example.com", suffix); let password = "Oauthtest123!"; let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) .send() .await .unwrap(); assert_eq!(create_res.status(), StatusCode::OK); let account: Value = create_res.json().await.unwrap(); let user_did = account["did"].as_str().unwrap(); verify_new_account(&http_client, user_did).await; let redirect_uri = "https://example.com/oauth/callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); let (code_verifier, code_challenge) = generate_pkce(); let state = format!("state-{}", suffix); let par_res = http_client .post(format!("{}/oauth/par", url)) .form(&[ ("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri), ("code_challenge", &code_challenge), ("code_challenge_method", "S256"), ("scope", "atproto"), ("state", &state), ]) .send() .await .unwrap(); let par_body: Value = par_res.json().await.unwrap(); let request_uri = par_body["request_uri"].as_str().unwrap(); let auth_res = http_client .post(format!("{}/oauth/authorize", url)) .header("Content-Type", "application/json") .header("Accept", "application/json") .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false})) .send().await.unwrap(); assert_eq!( auth_res.status(), StatusCode::OK, "Expected OK with JSON response" ); let auth_body: Value = auth_res.json().await.unwrap(); let mut location = auth_body["redirect_uri"] .as_str() .expect("Expected redirect_uri in response") .to_string(); if location.contains("/oauth/consent") { let consent_res = http_client .post(format!("{}/oauth/authorize/consent", url)) .header("Content-Type", "application/json") .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false})) .send().await.unwrap(); let consent_status = consent_res.status(); let consent_body: Value = consent_res.json().await.unwrap(); assert_eq!( consent_status, StatusCode::OK, "Consent should succeed. Got: {:?}", consent_body ); location = consent_body["redirect_uri"] .as_str() .expect("Expected redirect_uri from consent") .to_string(); } assert!( location.starts_with(redirect_uri), "Redirect to wrong URI: {}", location ); assert!(location.contains("code="), "No code in redirect"); assert!( location.contains(&format!("state={}", state)), "Wrong state" ); let code = location .split("code=") .nth(1) .unwrap() .split('&') .next() .unwrap(); let token_res = http_client .post(format!("{}/oauth/token", url)) .form(&[ ("grant_type", "authorization_code"), ("code", code), ("redirect_uri", redirect_uri), ("code_verifier", &code_verifier), ("client_id", &client_id), ]) .send() .await .unwrap(); assert_eq!(token_res.status(), StatusCode::OK, "Token exchange failed"); let token_body: Value = token_res.json().await.unwrap(); assert!(token_body["access_token"].is_string()); assert!(token_body["refresh_token"].is_string()); assert_eq!(token_body["token_type"], "Bearer"); assert!(token_body["expires_in"].is_number()); assert_eq!(token_body["sub"], user_did); let access_token = token_body["access_token"].as_str().unwrap(); let refresh_token = token_body["refresh_token"].as_str().unwrap(); let refresh_res = http_client .post(format!("{}/oauth/token", url)) .form(&[ ("grant_type", "refresh_token"), ("refresh_token", refresh_token), ("client_id", &client_id), ]) .send() .await .unwrap(); assert_eq!(refresh_res.status(), StatusCode::OK); let refresh_body: Value = refresh_res.json().await.unwrap(); assert_ne!(refresh_body["access_token"].as_str().unwrap(), access_token); assert_ne!( refresh_body["refresh_token"].as_str().unwrap(), refresh_token ); let introspect_res = http_client .post(format!("{}/oauth/introspect", url)) .form(&[("token", refresh_body["access_token"].as_str().unwrap())]) .send() .await .unwrap(); assert_eq!(introspect_res.status(), StatusCode::OK); let introspect_body: Value = introspect_res.json().await.unwrap(); assert_eq!(introspect_body["active"], true); let revoke_res = http_client .post(format!("{}/oauth/revoke", url)) .form(&[("token", refresh_body["refresh_token"].as_str().unwrap())]) .send() .await .unwrap(); assert_eq!(revoke_res.status(), StatusCode::OK); let introspect_after = http_client .post(format!("{}/oauth/introspect", url)) .form(&[("token", refresh_body["access_token"].as_str().unwrap())]) .send() .await .unwrap(); let after_body: Value = introspect_after.json().await.unwrap(); assert_eq!( after_body["active"], false, "Revoked token should be inactive" ); } #[tokio::test] async fn test_oauth_error_cases() { let url = base_url().await; let http_client = client(); let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; let handle = format!("wc{}", suffix); let email = format!("wc{}@example.com", suffix); http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": "Correct123!" })) .send() .await .unwrap(); let redirect_uri = "https://example.com/callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); let (_, code_challenge) = generate_pkce(); let par_body: Value = http_client .post(format!("{}/oauth/par", url)) .form(&[ ("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri), ("code_challenge", &code_challenge), ("code_challenge_method", "S256"), ]) .send() .await .unwrap() .json() .await .unwrap(); let request_uri = par_body["request_uri"].as_str().unwrap(); let auth_res = http_client .post(format!("{}/oauth/authorize", url)) .header("Content-Type", "application/json") .header("Accept", "application/json") .json(&json!({"request_uri": request_uri, "username": &handle, "password": "wrong-password", "remember_device": false})) .send().await.unwrap(); assert_eq!(auth_res.status(), StatusCode::FORBIDDEN); let error_body: Value = auth_res.json().await.unwrap(); assert_eq!(error_body["error"], "access_denied"); let unsupported = http_client .post(format!("{}/oauth/token", url)) .form(&[ ("grant_type", "client_credentials"), ("client_id", "https://example.com"), ]) .send() .await .unwrap(); assert_eq!(unsupported.status(), StatusCode::BAD_REQUEST); let body: Value = unsupported.json().await.unwrap(); assert_eq!(body["error"], "unsupported_grant_type"); let invalid_refresh = http_client .post(format!("{}/oauth/token", url)) .form(&[ ("grant_type", "refresh_token"), ("refresh_token", "invalid-token"), ("client_id", "https://example.com"), ]) .send() .await .unwrap(); assert_eq!(invalid_refresh.status(), StatusCode::BAD_REQUEST); let body: Value = invalid_refresh.json().await.unwrap(); assert_eq!(body["error"], "invalid_grant"); let invalid_introspect = http_client .post(format!("{}/oauth/introspect", url)) .form(&[("token", "invalid.token.here")]) .send() .await .unwrap(); assert_eq!(invalid_introspect.status(), StatusCode::OK); let body: Value = invalid_introspect.json().await.unwrap(); assert_eq!(body["active"], false); let expired_res = http_client .get(format!("{}/oauth/authorize", url)) .header("Accept", "application/json") .query(&[("request_uri", "urn:ietf:params:oauth:request_uri:expired")]) .send() .await .unwrap(); assert_eq!(expired_res.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn test_oauth_2fa_flow() { let url = base_url().await; let http_client = client(); let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; let handle = format!("ft{}", suffix); let email = format!("ft{}@example.com", suffix); let password = "Twofa123test!"; let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) .send() .await .unwrap(); assert_eq!(create_res.status(), StatusCode::OK); let account: Value = create_res.json().await.unwrap(); let user_did = account["did"].as_str().unwrap(); verify_new_account(&http_client, user_did).await; let pool = get_test_db_pool().await; sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") .bind(user_did) .execute(pool) .await .unwrap(); let redirect_uri = "https://example.com/2fa-callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); let (code_verifier, code_challenge) = generate_pkce(); let par_body: Value = http_client .post(format!("{}/oauth/par", url)) .form(&[ ("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri), ("code_challenge", &code_challenge), ("code_challenge_method", "S256"), ]) .send() .await .unwrap() .json() .await .unwrap(); let request_uri = par_body["request_uri"].as_str().unwrap(); let auth_res = http_client .post(format!("{}/oauth/authorize", url)) .header("Content-Type", "application/json") .header("Accept", "application/json") .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false})) .send().await.unwrap(); assert_eq!( auth_res.status(), StatusCode::OK, "Should return OK with needs_2fa" ); let auth_body: Value = auth_res.json().await.unwrap(); assert!( auth_body["needs_2fa"].as_bool().unwrap_or(false), "Should need 2FA, got: {:?}", auth_body ); let twofa_invalid = http_client .post(format!("{}/oauth/authorize/2fa", url)) .header("Content-Type", "application/json") .json(&json!({"request_uri": request_uri, "code": "000000"})) .send() .await .unwrap(); assert_eq!(twofa_invalid.status(), StatusCode::FORBIDDEN); let body: Value = twofa_invalid.json().await.unwrap(); assert!( body["error_description"] .as_str() .unwrap_or("") .contains("Invalid") || body["error"].as_str().unwrap_or("") == "invalid_code" ); let twofa_code: String = sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1") .bind(request_uri) .fetch_one(pool) .await .unwrap(); let twofa_res = http_client .post(format!("{}/oauth/authorize/2fa", url)) .header("Content-Type", "application/json") .json(&json!({"request_uri": request_uri, "code": &twofa_code})) .send() .await .unwrap(); assert_eq!( twofa_res.status(), StatusCode::OK, "Valid 2FA code should succeed" ); let twofa_body: Value = twofa_res.json().await.unwrap(); let final_location = twofa_body["redirect_uri"].as_str().unwrap(); assert!(final_location.starts_with(redirect_uri) && final_location.contains("code=")); let auth_code = final_location .split("code=") .nth(1) .unwrap() .split('&') .next() .unwrap(); let token_res = http_client .post(format!("{}/oauth/token", url)) .form(&[ ("grant_type", "authorization_code"), ("code", auth_code), ("redirect_uri", redirect_uri), ("code_verifier", &code_verifier), ("client_id", &client_id), ]) .send() .await .unwrap(); assert_eq!(token_res.status(), StatusCode::OK); let token_body: Value = token_res.json().await.unwrap(); assert_eq!(token_body["sub"], user_did); } #[tokio::test] async fn test_oauth_2fa_lockout() { let url = base_url().await; let http_client = client(); let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; let handle = format!("fl{}", suffix); let email = format!("fl{}@example.com", suffix); let password = "Twofa123test!"; let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) .send() .await .unwrap(); let account: Value = create_res.json().await.unwrap(); let user_did = account["did"].as_str().unwrap(); verify_new_account(&http_client, user_did).await; let pool = get_test_db_pool().await; sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") .bind(user_did) .execute(pool) .await .unwrap(); let redirect_uri = "https://example.com/2fa-lockout-callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); let (_, code_challenge) = generate_pkce(); let par_body: Value = http_client .post(format!("{}/oauth/par", url)) .form(&[ ("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri), ("code_challenge", &code_challenge), ("code_challenge_method", "S256"), ]) .send() .await .unwrap() .json() .await .unwrap(); let request_uri = par_body["request_uri"].as_str().unwrap(); let auth_res = http_client .post(format!("{}/oauth/authorize", url)) .header("Content-Type", "application/json") .header("Accept", "application/json") .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false})) .send().await.unwrap(); assert_eq!( auth_res.status(), StatusCode::OK, "Should return OK with needs_2fa" ); let auth_body: Value = auth_res.json().await.unwrap(); assert!( auth_body["needs_2fa"].as_bool().unwrap_or(false), "Should need 2FA" ); for i in 0..5 { let res = http_client .post(format!("{}/oauth/authorize/2fa", url)) .header("Content-Type", "application/json") .json(&json!({"request_uri": request_uri, "code": "999999"})) .send() .await .unwrap(); if i < 4 { assert_eq!( res.status(), StatusCode::FORBIDDEN, "Attempt {} should return 403", i ); } } let lockout_res = http_client .post(format!("{}/oauth/authorize/2fa", url)) .header("Content-Type", "application/json") .json(&json!({"request_uri": request_uri, "code": "999999"})) .send() .await .unwrap(); let body: Value = lockout_res.json().await.unwrap(); let desc = body["error_description"].as_str().unwrap_or(""); assert!( desc.contains("Too many") || desc.contains("No 2FA") || body["error"] == "invalid_request", "Expected lockout error, got: {:?}", body ); } #[tokio::test] async fn test_account_selector_with_2fa() { let url = base_url().await; let http_client = client(); let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; let handle = format!("sf{}", suffix); let email = format!("sf{}@example.com", suffix); let password = "Selector2fa123!"; let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) .send() .await .unwrap(); let account: Value = create_res.json().await.unwrap(); let user_did = account["did"].as_str().unwrap().to_string(); verify_new_account(&http_client, &user_did).await; let redirect_uri = "https://example.com/selector-2fa-callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); let (code_verifier, code_challenge) = generate_pkce(); let par_body: Value = http_client .post(format!("{}/oauth/par", url)) .form(&[ ("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri), ("code_challenge", &code_challenge), ("code_challenge_method", "S256"), ]) .send() .await .unwrap() .json() .await .unwrap(); let request_uri = par_body["request_uri"].as_str().unwrap(); let auth_res = http_client .post(format!("{}/oauth/authorize", url)) .header("Content-Type", "application/json") .header("Accept", "application/json") .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": true})) .send().await.unwrap(); assert_eq!( auth_res.status(), StatusCode::OK, "Expected OK with JSON response" ); let device_cookie = auth_res .headers() .get("set-cookie") .and_then(|v| v.to_str().ok()) .map(|s| s.split(';').next().unwrap_or("").to_string()) .expect("Should have device cookie"); let auth_body: Value = auth_res.json().await.unwrap(); let mut location = auth_body["redirect_uri"] .as_str() .expect("Expected redirect_uri") .to_string(); if location.contains("/oauth/consent") { let consent_res = http_client .post(format!("{}/oauth/authorize/consent", url)) .header("Content-Type", "application/json") .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": true})) .send().await.unwrap(); assert_eq!( consent_res.status(), StatusCode::OK, "Consent should succeed" ); let consent_body: Value = consent_res.json().await.unwrap(); location = consent_body["redirect_uri"] .as_str() .expect("Expected redirect_uri from consent") .to_string(); } assert!(location.contains("code=")); let code = location .split("code=") .nth(1) .unwrap() .split('&') .next() .unwrap(); let _ = http_client .post(format!("{}/oauth/token", url)) .form(&[ ("grant_type", "authorization_code"), ("code", code), ("redirect_uri", redirect_uri), ("code_verifier", &code_verifier), ("client_id", &client_id), ]) .send() .await .unwrap() .json::() .await .unwrap(); let pool = get_test_db_pool().await; sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1") .bind(&user_did) .execute(pool) .await .unwrap(); let (code_verifier2, code_challenge2) = generate_pkce(); let par_body2: Value = http_client .post(format!("{}/oauth/par", url)) .form(&[ ("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri), ("code_challenge", &code_challenge2), ("code_challenge_method", "S256"), ]) .send() .await .unwrap() .json() .await .unwrap(); let request_uri2 = par_body2["request_uri"].as_str().unwrap(); let select_res = http_client .post(format!("{}/oauth/authorize/select", url)) .header("cookie", &device_cookie) .header("Content-Type", "application/json") .json(&json!({"request_uri": request_uri2, "did": &user_did})) .send() .await .unwrap(); assert_eq!( select_res.status(), StatusCode::OK, "Select should return OK with JSON" ); let select_body: Value = select_res.json().await.unwrap(); assert!( select_body["needs_2fa"].as_bool().unwrap_or(false), "Should need 2FA" ); let twofa_code: String = sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1") .bind(request_uri2) .fetch_one(pool) .await .unwrap(); let twofa_res = http_client .post(format!("{}/oauth/authorize/2fa", url)) .header("cookie", &device_cookie) .header("Content-Type", "application/json") .json(&json!({"request_uri": request_uri2, "code": &twofa_code})) .send() .await .unwrap(); assert_eq!( twofa_res.status(), StatusCode::OK, "Valid 2FA should succeed" ); let twofa_body: Value = twofa_res.json().await.unwrap(); let final_location = twofa_body["redirect_uri"].as_str().unwrap(); assert!(final_location.starts_with(redirect_uri) && final_location.contains("code=")); let final_code = final_location .split("code=") .nth(1) .unwrap() .split('&') .next() .unwrap(); let token_res = http_client .post(format!("{}/oauth/token", url)) .form(&[ ("grant_type", "authorization_code"), ("code", final_code), ("redirect_uri", redirect_uri), ("code_verifier", &code_verifier2), ("client_id", &client_id), ]) .send() .await .unwrap(); assert_eq!(token_res.status(), StatusCode::OK); let final_token: Value = token_res.json().await.unwrap(); assert_eq!(final_token["sub"], user_did); } #[tokio::test] async fn test_oauth_state_encoding() { let url = base_url().await; let http_client = client(); let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; let handle = format!("ss{}", suffix); let email = format!("ss{}@example.com", suffix); let password = "State123special!"; let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) .send() .await .unwrap(); let account: Value = create_res.json().await.unwrap(); verify_new_account(&http_client, account["did"].as_str().unwrap()).await; let redirect_uri = "https://example.com/state-special-callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); let (_, code_challenge) = generate_pkce(); let special_state = "state=with&special=chars&plus+more"; let par_body: Value = http_client .post(format!("{}/oauth/par", url)) .form(&[ ("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri), ("code_challenge", &code_challenge), ("code_challenge_method", "S256"), ("state", special_state), ]) .send() .await .unwrap() .json() .await .unwrap(); let request_uri = par_body["request_uri"].as_str().unwrap(); let auth_res = http_client .post(format!("{}/oauth/authorize", url)) .header("Content-Type", "application/json") .header("Accept", "application/json") .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false})) .send().await.unwrap(); assert_eq!( auth_res.status(), StatusCode::OK, "Expected OK with JSON response" ); let auth_body: Value = auth_res.json().await.unwrap(); let mut location = auth_body["redirect_uri"] .as_str() .expect("Expected redirect_uri") .to_string(); if location.contains("/oauth/consent") { let consent_res = http_client .post(format!("{}/oauth/authorize/consent", url)) .header("Content-Type", "application/json") .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false})) .send().await.unwrap(); assert_eq!( consent_res.status(), StatusCode::OK, "Consent should succeed" ); let consent_body: Value = consent_res.json().await.unwrap(); location = consent_body["redirect_uri"] .as_str() .expect("Expected redirect_uri from consent") .to_string(); } assert!(location.contains("state=")); let encoded_state = urlencoding::encode(special_state); assert!( location.contains(&format!("state={}", encoded_state)), "State should be URL-encoded. Got: {}", location ); } async fn get_oauth_token_with_scope(scope: &str) -> (String, String, String) { let url = base_url().await; let http_client = client(); let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; let handle = format!("st{}", suffix); let email = format!("st{}@example.com", suffix); let password = "Scopetest123!"; let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) .send() .await .unwrap(); assert_eq!(create_res.status(), StatusCode::OK); let account: Value = create_res.json().await.unwrap(); let user_did = account["did"].as_str().unwrap().to_string(); verify_new_account(&http_client, &user_did).await; let redirect_uri = "https://example.com/scope-callback"; let mock_client = setup_mock_client_metadata(redirect_uri).await; let client_id = mock_client.uri(); let (code_verifier, code_challenge) = generate_pkce(); let par_res = http_client .post(format!("{}/oauth/par", url)) .form(&[ ("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri), ("code_challenge", &code_challenge), ("code_challenge_method", "S256"), ("scope", scope), ("state", "test"), ]) .send() .await .unwrap(); assert_eq!( par_res.status(), StatusCode::CREATED, "PAR should succeed for scope: {}", scope ); let par_body: Value = par_res.json().await.unwrap(); let request_uri = par_body["request_uri"].as_str().unwrap(); let auth_res = http_client .post(format!("{}/oauth/authorize", url)) .header("Content-Type", "application/json") .header("Accept", "application/json") .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false})) .send().await.unwrap(); assert_eq!(auth_res.status(), StatusCode::OK); let auth_body: Value = auth_res.json().await.unwrap(); let mut location = auth_body["redirect_uri"] .as_str() .expect("Expected redirect_uri") .to_string(); if location.contains("/oauth/consent") { let approved_scopes: Vec<&str> = scope.split_whitespace().collect(); let consent_res = http_client .post(format!("{}/oauth/authorize/consent", url)) .header("Content-Type", "application/json") .json(&json!({"request_uri": request_uri, "approved_scopes": approved_scopes, "remember": false})) .send().await.unwrap(); let consent_status = consent_res.status(); let consent_body: Value = consent_res.json().await.unwrap(); assert_eq!( consent_status, StatusCode::OK, "Consent should succeed. Scope: {}, Body: {:?}", scope, consent_body ); location = consent_body["redirect_uri"] .as_str() .expect("Expected redirect_uri from consent") .to_string(); } let code = location .split("code=") .nth(1) .unwrap() .split('&') .next() .unwrap(); let token_res = http_client .post(format!("{}/oauth/token", url)) .form(&[ ("grant_type", "authorization_code"), ("code", code), ("redirect_uri", redirect_uri), ("code_verifier", &code_verifier), ("client_id", &client_id), ]) .send() .await .unwrap(); assert_eq!(token_res.status(), StatusCode::OK, "Token exchange failed"); let token_body: Value = token_res.json().await.unwrap(); let access_token = token_body["access_token"].as_str().unwrap().to_string(); (access_token, user_did, handle) } #[tokio::test] async fn test_granular_scope_repo_create_only() { let url = base_url().await; let http_client = client(); let (token, did, _) = get_oauth_token_with_scope("repo:app.bsky.feed.post?action=create blob:*/*").await; let now = chrono::Utc::now().to_rfc3339(); let create_res = http_client .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) .bearer_auth(&token) .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "record": { "$type": "app.bsky.feed.post", "text": "test post", "createdAt": now } })) .send() .await .unwrap(); assert_eq!( create_res.status(), StatusCode::OK, "Should allow creating posts with repo:app.bsky.feed.post?action=create" ); let body: Value = create_res.json().await.unwrap(); let uri = body["uri"].as_str().expect("Should have uri"); let rkey = uri.split('/').next_back().unwrap(); let delete_res = http_client .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) .bearer_auth(&token) .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "rkey": rkey })) .send() .await .unwrap(); assert_eq!( delete_res.status(), StatusCode::FORBIDDEN, "Should NOT allow deleting with create-only scope" ); let like_res = http_client .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) .bearer_auth(&token) .json(&json!({ "repo": &did, "collection": "app.bsky.feed.like", "record": { "$type": "app.bsky.feed.like", "subject": { "uri": uri, "cid": body["cid"] }, "createdAt": now } })) .send().await.unwrap(); assert_eq!( like_res.status(), StatusCode::FORBIDDEN, "Should NOT allow creating likes (wrong collection)" ); } #[tokio::test] async fn test_granular_scope_wildcard_collection() { let url = base_url().await; let http_client = client(); let (token, did, _) = get_oauth_token_with_scope( "repo:app.bsky.*?action=create&action=update&action=delete blob:*/*", ) .await; let now = chrono::Utc::now().to_rfc3339(); let post_res = http_client .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) .bearer_auth(&token) .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "record": { "$type": "app.bsky.feed.post", "text": "wildcard test", "createdAt": now } })) .send() .await .unwrap(); assert_eq!( post_res.status(), StatusCode::OK, "Should allow app.bsky.feed.post with app.bsky.* scope" ); let body: Value = post_res.json().await.unwrap(); let uri = body["uri"].as_str().unwrap(); let rkey = uri.split('/').next_back().unwrap(); let delete_res = http_client .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url)) .bearer_auth(&token) .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "rkey": rkey })) .send() .await .unwrap(); assert_eq!( delete_res.status(), StatusCode::OK, "Should allow delete with action=delete" ); let other_res = http_client .post(format!("{}/xrpc/com.atproto.repo.createRecord", url)) .bearer_auth(&token) .json(&json!({ "repo": &did, "collection": "com.example.record", "record": { "$type": "com.example.record", "data": "test", "createdAt": now } })) .send() .await .unwrap(); assert_eq!( other_res.status(), StatusCode::FORBIDDEN, "Should NOT allow com.example.* with app.bsky.* scope" ); } #[tokio::test] async fn test_granular_scope_email_read() { let url = base_url().await; let http_client = client(); let (token, did, _) = get_oauth_token_with_scope("account:email?action=read").await; let session_res = http_client .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .bearer_auth(&token) .send() .await .unwrap(); assert_eq!(session_res.status(), StatusCode::OK); let body: Value = session_res.json().await.unwrap(); assert_eq!(body["did"], did); assert!( body["email"].is_string(), "Email should be visible with account:email?action=read. Got: {:?}", body ); } #[tokio::test] async fn test_granular_scope_no_email_access() { let url = base_url().await; let http_client = client(); let (token, did, _) = get_oauth_token_with_scope("repo:*?action=create blob:*/*").await; let session_res = http_client .get(format!("{}/xrpc/com.atproto.server.getSession", url)) .bearer_auth(&token) .send() .await .unwrap(); assert_eq!(session_res.status(), StatusCode::OK); let body: Value = session_res.json().await.unwrap(); assert_eq!(body["did"], did); assert!( body["email"].is_null() || body.get("email").is_none(), "Email should be hidden without account:email scope. Got: {:?}", body["email"] ); } #[tokio::test] async fn test_granular_scope_rpc_specific_method() { let url = base_url().await; let http_client = client(); let (token, _, _) = get_oauth_token_with_scope("rpc:app.bsky.feed.getTimeline?aud=*").await; let allowed_res = http_client .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url)) .bearer_auth(&token) .query(&[ ("aud", "did:web:api.bsky.app"), ("lxm", "app.bsky.feed.getTimeline"), ]) .send() .await .unwrap(); assert_eq!( allowed_res.status(), StatusCode::OK, "Should allow getServiceAuth for app.bsky.feed.getTimeline" ); let body: Value = allowed_res.json().await.unwrap(); assert!(body["token"].is_string(), "Should return service token"); let blocked_res = http_client .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url)) .bearer_auth(&token) .query(&[ ("aud", "did:web:api.bsky.app"), ("lxm", "app.bsky.feed.getAuthorFeed"), ]) .send() .await .unwrap(); assert_eq!( blocked_res.status(), StatusCode::FORBIDDEN, "Should NOT allow getServiceAuth for app.bsky.feed.getAuthorFeed" ); let blocked_body: Value = blocked_res.json().await.unwrap(); assert!( blocked_body["error"] .as_str() .unwrap_or("") .contains("Scope") || blocked_body["message"] .as_str() .unwrap_or("") .contains("scope"), "Should mention scope restriction: {:?}", blocked_body ); let no_lxm_res = http_client .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url)) .bearer_auth(&token) .query(&[("aud", "did:web:api.bsky.app")]) .send() .await .unwrap(); assert_eq!( no_lxm_res.status(), StatusCode::BAD_REQUEST, "Should require lxm parameter for granular scopes" ); }