//! OAuth provider workflow tests: authorization code + PKCE flow. use crate::harness::TestHarness; use makenotwork::db::{SyncAppId, UserId}; use serde::Deserialize; use sha2::{Digest, Sha256}; use sqlx::PgPool; // ── Response types ── #[derive(Deserialize)] struct TokenResponse { access_token: String, token_type: String, expires_in: i64, user_id: UserId, app_id: SyncAppId, } // ── Helpers ── /// Insert a sync app directly via SQL and return (app_id, api_key). async fn create_sync_app(pool: &PgPool, user_id: UserId) -> (SyncAppId, String) { let api_key = "test-oauth-client-id"; let key_hash = crate::harness::hash_api_key(api_key); let key_prefix = &api_key[..8]; let app_id: SyncAppId = sqlx::query_scalar( "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) VALUES ($1, 'OAuth Test App', $2, $3) RETURNING id", ) .bind(user_id) .bind(&key_hash) .bind(key_prefix) .fetch_one(pool) .await .expect("Failed to create sync app"); (app_id, api_key.to_string()) } /// Generate PKCE code_verifier and code_challenge (S256). fn generate_pkce() -> (String, String) { // Deterministic 64-char alphanumeric verifier for tests let verifier: String = (0u32..64) .map(|i| { let idx = ((i * 7 + 3) % 26) as u8; (b'A' + idx) as char }) .collect(); let mut hasher = Sha256::new(); hasher.update(verifier.as_bytes()); let digest = hasher.finalize(); use base64::Engine; let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest); (verifier, challenge) } /// Extract `code` and `state` from a redirect Location header. fn extract_code_from_redirect(location: &str) -> (String, String) { let url = url::Url::parse(location).expect("Invalid redirect URL"); let mut code = String::new(); let mut state = String::new(); for (key, value) in url.query_pairs() { match key.as_ref() { "code" => code = value.to_string(), "state" => state = value.to_string(), _ => {} } } assert!(!code.is_empty(), "No code in redirect: {}", location); (code, state) } /// Full OAuth authorize flow: GET authorize page, POST with credentials, return (code, state). async fn authorize( h: &mut TestHarness, client_id: &str, code_challenge: &str, username: &str, password: &str, ) -> (String, String) { let state_param = "test-state-12345"; let redirect_uri = "http://127.0.0.1:9999/callback"; // GET the authorize page (populates CSRF) let resp = h .client .get(&format!( "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256", urlencoding::encode(client_id), urlencoding::encode(redirect_uri), state_param, code_challenge, )) .await; assert_eq!(resp.status.as_u16(), 200, "Authorize page failed: {}", resp.text); // Extract CSRF token let csrf = h .client .csrf_token() .expect("No CSRF token after loading authorize page") .to_string(); // POST credentials (CSRF goes in form body as _csrf) let body = format!( "client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&login={}&password={}&_csrf={}", urlencoding::encode(client_id), urlencoding::encode(redirect_uri), state_param, code_challenge, urlencoding::encode(username), urlencoding::encode(password), urlencoding::encode(&csrf), ); let resp = h.client.post_form("/oauth/authorize", &body).await; assert!( resp.status.is_redirection(), "Expected redirect after authorize POST, got {}: {}", resp.status, resp.text ); let location = resp.header("location").expect("No Location header on redirect"); extract_code_from_redirect(location) } // ── Tests ── #[tokio::test] async fn oauth_full_flow() { let mut h = TestHarness::new().await; let user_id = h.signup("oauthuser", "oauthuser@test.com", "Password1!").await; // Logout so we test the credential flow h.client.post_form("/logout", "").await; let (app_id, client_id) = create_sync_app(&h.db, user_id).await; let (verifier, challenge) = generate_pkce(); let (code, state) = authorize(&mut h, &client_id, &challenge, "oauthuser", "Password1!").await; assert_eq!(state, "test-state-12345"); // Exchange code for token (OAuth RFC requires form-encoded) let resp = h .client .post_form( "/oauth/token", &format!( "grant_type=authorization_code&code={}&redirect_uri={}&code_verifier={}&client_id={}&key=test-session-key", code, urlencoding::encode("http://127.0.0.1:9999/callback"), verifier, client_id, ), ) .await; assert_eq!(resp.status.as_u16(), 200, "Token exchange failed: {}", resp.text); let token: TokenResponse = resp.json(); assert!(!token.access_token.is_empty()); assert_eq!(token.token_type, "Bearer"); assert!(token.expires_in > 0); assert_eq!(token.user_id, user_id); assert_eq!(token.app_id, app_id); } #[tokio::test] async fn oauth_pkce_wrong_verifier() { let mut h = TestHarness::new().await; let user_id = h.signup("oauthpkce", "oauthpkce@test.com", "Password1!").await; h.client.post_form("/logout", "").await; let (_app_id, client_id) = create_sync_app(&h.db, user_id).await; let (_verifier, challenge) = generate_pkce(); let (code, _) = authorize(&mut h, &client_id, &challenge, "oauthpkce", "Password1!").await; // Use wrong verifier let resp = h .client .post_form( "/oauth/token", &format!( "grant_type=authorization_code&code={}&redirect_uri={}&code_verifier=this-is-the-wrong-verifier-and-should-fail&client_id={}&key=test-session-key", code, urlencoding::encode("http://127.0.0.1:9999/callback"), client_id, ), ) .await; assert_eq!(resp.status.as_u16(), 400, "Wrong PKCE verifier should be rejected"); } #[tokio::test] async fn oauth_code_single_use() { let mut h = TestHarness::new().await; let user_id = h.signup("oauthonce", "oauthonce@test.com", "Password1!").await; h.client.post_form("/logout", "").await; let (_app_id, client_id) = create_sync_app(&h.db, user_id).await; let (verifier, challenge) = generate_pkce(); let (code, _) = authorize(&mut h, &client_id, &challenge, "oauthonce", "Password1!").await; let token_body = format!( "grant_type=authorization_code&code={}&redirect_uri={}&code_verifier={}&client_id={}&key=test-session-key", code, urlencoding::encode("http://127.0.0.1:9999/callback"), verifier, client_id, ); // First exchange — should succeed let resp = h.client.post_form("/oauth/token", &token_body).await; assert_eq!(resp.status.as_u16(), 200, "First token exchange failed: {}", resp.text); // Second exchange with same code — should fail let resp = h.client.post_form("/oauth/token", &token_body).await; assert_eq!(resp.status.as_u16(), 400, "Reused auth code should be rejected"); } #[tokio::test] async fn oauth_invalid_client_id() { let mut h = TestHarness::new().await; h.signup("oauthbad", "oauthbad@test.com", "Password1!").await; let resp = h .client .get("/oauth/authorize?response_type=code&client_id=nonexistent-app&redirect_uri=http://127.0.0.1:9999/callback&state=x&code_challenge=abc&code_challenge_method=S256") .await; assert_eq!(resp.status.as_u16(), 400, "Invalid client_id should return 400"); } #[tokio::test] async fn oauth_invalid_credentials() { let mut h = TestHarness::new().await; let user_id = h.signup("oauthcred", "oauthcred@test.com", "Password1!").await; h.client.post_form("/logout", "").await; let (_app_id, client_id) = create_sync_app(&h.db, user_id).await; let (_verifier, challenge) = generate_pkce(); let state_param = "test-state-12345"; let redirect_uri = "http://127.0.0.1:9999/callback"; // GET the authorize page let resp = h .client .get(&format!( "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256", urlencoding::encode(&client_id), urlencoding::encode(redirect_uri), state_param, challenge, )) .await; assert_eq!(resp.status.as_u16(), 200); let csrf = h .client .csrf_token() .expect("No CSRF token") .to_string(); // POST with wrong password let body = format!( "client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&login={}&password={}&_csrf={}", urlencoding::encode(&client_id), urlencoding::encode(redirect_uri), state_param, challenge, "oauthcred", "WrongPassword1%21", urlencoding::encode(&csrf), ); let resp = h.client.post_form("/oauth/authorize", &body).await; // Should re-render the form with an error (200, not a redirect) assert_eq!( resp.status.as_u16(), 200, "Invalid credentials should re-render form, got {}", resp.status ); assert!( resp.text.contains("Invalid") || resp.text.contains("invalid") || resp.text.contains("password"), "Should show error message: {}", resp.text ); } // ── Userinfo (`/oauth/userinfo`) ── // // `userinfo` is the canonical entitlement endpoint for external "Log in with MNW" // implementers. Tests cover the `perks` contract: shape on a fresh user, on a // creator, and on a Fan+ subscriber. /// Run the full authorize → token flow and return the Bearer access token. async fn obtain_access_token(h: &mut TestHarness, username: &str, password: &str) -> String { let user_id = sqlx::query_scalar::<_, UserId>("SELECT id FROM users WHERE username = $1") .bind(username) .fetch_one(&h.db) .await .expect("user lookup"); let (_app_id, client_id) = create_sync_app(&h.db, user_id).await; let (verifier, challenge) = generate_pkce(); h.client.post_form("/logout", "").await; let (code, _state) = authorize(h, &client_id, &challenge, username, password).await; let resp = h .client .post_form( "/oauth/token", &format!( "grant_type=authorization_code&code={}&redirect_uri={}&code_verifier={}&client_id={}&key=test-session-key", code, urlencoding::encode("http://127.0.0.1:9999/callback"), verifier, client_id, ), ) .await; assert_eq!(resp.status.as_u16(), 200, "Token exchange failed: {}", resp.text); let token: TokenResponse = resp.json(); token.access_token } #[derive(Deserialize)] struct UserinfoResp { user_id: UserId, username: String, display_name: Option, avatar_url: Option, perks: PerksResp, } #[derive(Deserialize)] struct PerksResp { fan_plus: bool, is_creator: bool, creator_tier: Option, } #[derive(Deserialize)] struct CreatorTierResp { tier: String, features: Vec, } #[tokio::test] async fn oauth_userinfo_default() { let mut h = TestHarness::new().await; let user_id = h.signup("uinfo_def", "uinfo_def@test.com", "Password1!").await; let token = obtain_access_token(&mut h, "uinfo_def", "Password1!").await; h.client.set_bearer_token(&token); let resp = h.client.get("/oauth/userinfo").await; assert_eq!(resp.status.as_u16(), 200, "userinfo failed: {}", resp.text); let info: UserinfoResp = resp.json(); assert_eq!(info.user_id, user_id); assert_eq!(info.username, "uinfo_def"); assert!(info.display_name.is_none() || info.display_name.as_deref() == Some("")); let _ = info.avatar_url; assert!(!info.perks.fan_plus); assert!(!info.perks.is_creator); assert!(info.perks.creator_tier.is_none()); } #[tokio::test] async fn oauth_userinfo_creator_tier() { let mut h = TestHarness::new().await; let user_id = h.signup("uinfo_creator", "uinfo_creator@test.com", "Password1!").await; sqlx::query("UPDATE users SET creator_tier = 'big_files' WHERE id = $1") .bind(user_id) .execute(&h.db) .await .expect("set tier"); let token = obtain_access_token(&mut h, "uinfo_creator", "Password1!").await; h.client.set_bearer_token(&token); let resp = h.client.get("/oauth/userinfo").await; assert_eq!(resp.status.as_u16(), 200); let info: UserinfoResp = resp.json(); assert!(info.perks.is_creator); assert!(!info.perks.fan_plus); let tier = info.perks.creator_tier.expect("creator_tier populated"); assert_eq!(tier.tier, "big_files"); assert!(tier.features.iter().any(|f| f == "file_uploads")); assert!(tier.features.iter().any(|f| f == "large_files")); } #[tokio::test] async fn oauth_userinfo_fan_plus() { let mut h = TestHarness::new().await; let user_id = h.signup("uinfo_fp", "uinfo_fp@test.com", "Password1!").await; sqlx::query( "INSERT INTO fan_plus_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, status) \ VALUES ($1, 'sub_uinfo_fp', 'cus_uinfo_fp', 'active')", ) .bind(user_id) .execute(&h.db) .await .expect("seed fan_plus"); let token = obtain_access_token(&mut h, "uinfo_fp", "Password1!").await; h.client.set_bearer_token(&token); let resp = h.client.get("/oauth/userinfo").await; assert_eq!(resp.status.as_u16(), 200); let info: UserinfoResp = resp.json(); assert!(info.perks.fan_plus); assert!(!info.perks.is_creator); assert!(info.perks.creator_tier.is_none()); } #[tokio::test] async fn oauth_userinfo_unauthorized() { let mut h = TestHarness::new().await; // No bearer token set — extractor rejects. let resp = h.client.get("/oauth/userinfo").await; assert_eq!(resp.status.as_u16(), 401); }