//! OAuth provider workflow tests: authorization code + PKCE flow. use std::fmt::Write as _; 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, #[serde(default)] refresh_token: Option, #[serde(default)] scope: String, 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&scope=sync", 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&scope=sync&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); // `scope=sync` (sent by the authorize helper, mirroring synckit-client) mints // a sync-capable token that authenticates the sync API. h.client.set_bearer_token(&token.access_token); let resp = h.client.get("/api/v1/sync/status").await; assert_ne!( resp.status.as_u16(), 401, "scope=sync token should authenticate the sync API" ); h.client.clear_bearer_token(); } #[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 ); } /// Regression (ultra-fuzz Run 11 Sec M1): the OAuth authorize password path must /// not be a confirmed-password oracle for 2FA accounts. A CORRECT password /// against a TOTP-enabled account must be indistinguishable from a wrong one, /// the same generic error AND an incremented failed-login counter, not a /// distinct "two-factor enabled" message with the counter reset. #[tokio::test] async fn oauth_2fa_account_password_not_an_oracle() { let mut h = TestHarness::new().await; let user_id = h .signup("oauth2fa", "oauth2fa@test.com", "Password1!") .await; h.client.post_form("/logout", "").await; // Enable 2FA directly, the OAuth flow rejects 2FA accounts outright. sqlx::query("UPDATE users SET totp_enabled = true WHERE id = $1") .bind(user_id) .execute(&h.db) .await .expect("enable totp"); 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"; 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 the CORRECT 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, "oauth2fa", "Password1%21", urlencoding::encode(&csrf), ); let resp = h.client.post_form("/oauth/authorize", &body).await; // Re-renders the form with the SAME generic error as a wrong password, // never the distinct "two-factor" hint that leaked the password's validity. assert_eq!(resp.status.as_u16(), 200, "should re-render, not authorize"); assert!( !resp.text.to_lowercase().contains("two-factor") && !resp.text.to_lowercase().contains("two factor"), "must not reveal 2FA status on a correct password: {}", resp.text ); assert!( resp.text.contains("Invalid") || resp.text.contains("invalid"), "should show the generic invalid-credentials error: {}", resp.text ); // The denial was accounted: a correct-but-blocked guess increments the // counter exactly like a wrong password (no oracle via the counter either). let attempts: i32 = sqlx::query_scalar("SELECT failed_login_attempts FROM users WHERE id = $1") .bind(user_id) .fetch_one(&h.db) .await .expect("read failed_login_attempts"); assert_eq!( attempts, 1, "correct password on a 2FA account must increment" ); } // ── 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); } // ── Scoped flow: refresh tokens, scope enforcement, prompt=none, discovery ── // // These exercise the OAuth maturation that closes MT finding S13: a request // that asks for `scope` opts into a short-lived userinfo-scoped access token // (rejected by the sync API) plus a rotating refresh token. Requests WITHOUT // scope keep getting the legacy full sync token (covered by oauth_full_flow). /// Like `authorize`, but sends a `scope` parameter (GET + POST), opting into the /// userinfo-scoped token flow. async fn authorize_scoped( h: &mut TestHarness, client_id: &str, code_challenge: &str, username: &str, password: &str, scope: &str, ) -> (String, String) { let state_param = "test-state-scoped"; let redirect_uri = "http://127.0.0.1:9999/callback"; let resp = h .client .get(&format!( "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope={}", urlencoding::encode(client_id), urlencoding::encode(redirect_uri), state_param, code_challenge, urlencoding::encode(scope), )) .await; assert_eq!( resp.status.as_u16(), 200, "Authorize page failed: {}", resp.text ); let csrf = h.client.csrf_token().expect("No CSRF token").to_string(); let body = format!( "client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope={}&login={}&password={}&_csrf={}", urlencoding::encode(client_id), urlencoding::encode(redirect_uri), state_param, code_challenge, urlencoding::encode(scope), urlencoding::encode(username), urlencoding::encode(password), urlencoding::encode(&csrf), ); let resp = h.client.post_form("/oauth/authorize", &body).await; assert!( resp.status.is_redirection(), "authorize POST: {} {}", resp.status, resp.text ); let location = resp.header("location").expect("No Location header"); extract_code_from_redirect(location) } /// Run a scoped authorize + code exchange, returning the parsed token response. async fn obtain_scoped_token( h: &mut TestHarness, username: &str, password: &str, scope: &str, ) -> TokenResponse { 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_scoped(h, &client_id, &challenge, username, password, scope).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 ); resp.json() } /// POST a refresh_token grant, returning the raw response. async fn refresh_token_request( h: &mut TestHarness, refresh_token: &str, scope: Option<&str>, ) -> crate::harness::client::TestResponse { let mut body = format!( "grant_type=refresh_token&refresh_token={}&client_id=test-oauth-client-id", urlencoding::encode(refresh_token), ); if let Some(s) = scope { write!(body, "&scope={}", urlencoding::encode(s)).unwrap(); } h.client.post_form("/oauth/token", &body).await } #[tokio::test] async fn oauth_userinfo_token_rejected_by_sync_api() { // THE S13 regression gate: a userinfo-scoped access token must NOT // authenticate the sync API, only /oauth/userinfo. let mut h = TestHarness::new().await; h.signup("s13user", "s13@test.com", "Password1!").await; let token = obtain_scoped_token(&mut h, "s13user", "Password1!", "profile:read perks:read").await; h.client.set_bearer_token(&token.access_token); // Works at userinfo. let resp = h.client.get("/oauth/userinfo").await; assert_eq!( resp.status.as_u16(), 200, "userinfo should accept the scoped token" ); // Rejected by the sync API (different audience). let resp = h.client.get("/api/v1/sync/status").await; assert_eq!( resp.status.as_u16(), 401, "userinfo-scoped token must be rejected by the sync API, got: {}", resp.text ); } #[tokio::test] async fn oauth_userinfo_requires_perks_scope() { let mut h = TestHarness::new().await; h.signup("scopeuser", "scope@test.com", "Password1!").await; let token = obtain_scoped_token(&mut h, "scopeuser", "Password1!", "profile:read").await; assert_eq!(token.scope, "profile:read"); h.client.set_bearer_token(&token.access_token); let resp = h.client.get("/oauth/userinfo").await; assert_eq!(resp.status.as_u16(), 200); let body: serde_json::Value = resp.json(); assert!( body.get("username").is_some(), "profile:read returns identity" ); assert!( body.get("perks").is_none(), "perks must be gated behind perks:read" ); } #[tokio::test] async fn oauth_no_scope_yields_userinfo_token_not_sync() { // Run 17 security flip: omitting `scope` now mints a LEAST-PRIVILEGE userinfo // token, not a sync token. A relying party that merely forgot its `scope` // param can no longer be silently escalated to the 7-day full-sync token; a // client that wants sync must send `scope=sync` explicitly (synckit-client // does). No scope also means no refresh token (no offline_access). let mut h = TestHarness::new().await; h.signup("legacyuser", "legacy@test.com", "Password1!") .await; let token = obtain_scoped_token(&mut h, "legacyuser", "Password1!", "").await; assert!( token.refresh_token.is_none(), "no scope => no refresh token" ); // The omitted-scope token must NOT authenticate the sync API, the escalation // guard. (Contrast oauth_full_flow, which sends scope=sync and succeeds.) h.client.set_bearer_token(&token.access_token); let resp = h.client.get("/api/v1/sync/status").await; assert_eq!( resp.status.as_u16(), 401, "an omitted-scope token must NOT authenticate the sync API" ); } #[tokio::test] async fn oauth_refresh_happy_path_rotates() { let mut h = TestHarness::new().await; h.signup("refuser", "ref@test.com", "Password1!").await; let token = obtain_scoped_token( &mut h, "refuser", "Password1!", "profile:read perks:read offline_access", ) .await; let rt1 = token .refresh_token .expect("offline_access yields a refresh token"); h.client.clear_bearer_token(); // Refresh -> new access token + new refresh token. let resp = refresh_token_request(&mut h, &rt1, None).await; assert_eq!(resp.status.as_u16(), 200, "refresh failed: {}", resp.text); let refreshed: TokenResponse = resp.json(); let rt2 = refreshed .refresh_token .expect("rotation issues a new refresh token"); assert_ne!(rt1, rt2, "refresh token must rotate"); assert!(!refreshed.access_token.is_empty()); // The new access token reads userinfo. h.client.set_bearer_token(&refreshed.access_token); assert_eq!(h.client.get("/oauth/userinfo").await.status.as_u16(), 200); h.client.clear_bearer_token(); // The OLD refresh token is now invalid. let resp = refresh_token_request(&mut h, &rt1, None).await; assert_eq!( resp.status.as_u16(), 400, "rotated (old) refresh token must be rejected" ); } #[tokio::test] async fn oauth_refresh_reuse_detection_revokes_chain() { let mut h = TestHarness::new().await; h.signup("reuseuser", "reuse@test.com", "Password1!").await; let token = obtain_scoped_token( &mut h, "reuseuser", "Password1!", "perks:read offline_access", ) .await; let rt1 = token.refresh_token.expect("refresh token"); h.client.clear_bearer_token(); // Legit rotation. let resp = refresh_token_request(&mut h, &rt1, None).await; assert_eq!(resp.status.as_u16(), 200); let rt2: String = resp.json::().refresh_token.expect("rt2"); // Replay the consumed rt1 -> theft signal: whole chain revoked. let resp = refresh_token_request(&mut h, &rt1, None).await; assert_eq!(resp.status.as_u16(), 400, "reused token rejected"); // ...and rt2 (the live sibling) is now dead too. let resp = refresh_token_request(&mut h, &rt2, None).await; assert_eq!( resp.status.as_u16(), 400, "reuse detection must revoke the whole chain" ); } #[tokio::test] async fn oauth_refresh_downgrade_only() { let mut h = TestHarness::new().await; h.signup("downuser", "down@test.com", "Password1!").await; // Grant excludes profile:read so requesting it on refresh is a widening. let token = obtain_scoped_token( &mut h, "downuser", "Password1!", "perks:read offline_access", ) .await; let rt1 = token.refresh_token.expect("refresh token"); h.client.clear_bearer_token(); // Widening request -> invalid_scope. let resp = refresh_token_request(&mut h, &rt1, Some("profile:read perks:read")).await; assert_eq!(resp.status.as_u16(), 400, "widening scope must be rejected"); let body: serde_json::Value = resp.json(); assert_eq!( body.get("error").and_then(|e| e.as_str()), Some("invalid_scope") ); } #[tokio::test] async fn oauth_refresh_revoked_by_password_change() { let mut h = TestHarness::new().await; let user_id = h .signup("revokeuser", "revoke@test.com", "Password1!") .await; let token = obtain_scoped_token( &mut h, "revokeuser", "Password1!", "perks:read offline_access", ) .await; let rt1 = token.refresh_token.expect("refresh token"); h.client.clear_bearer_token(); // Simulate a credential change: bump jwt_invalidated_at into the future of // the token's issuance. sqlx::query("UPDATE users SET jwt_invalidated_at = NOW() + INTERVAL '1 second' WHERE id = $1") .bind(user_id) .execute(&h.db) .await .expect("bump jwt_invalidated_at"); let resp = refresh_token_request(&mut h, &rt1, None).await; assert_eq!( resp.status.as_u16(), 400, "refresh after credential change must fail" ); } #[tokio::test] async fn oauth_no_refresh_without_offline_access() { let mut h = TestHarness::new().await; h.signup("noofflineuser", "nooff@test.com", "Password1!") .await; let token = obtain_scoped_token( &mut h, "noofflineuser", "Password1!", "profile:read perks:read", ) .await; assert!( token.refresh_token.is_none(), "no offline_access => no refresh token" ); } #[tokio::test] async fn oauth_prompt_none_logged_in_issues_code() { let mut h = TestHarness::new().await; let user_id = h .signup("promptuser", "prompt@test.com", "Password1!") .await; // Signup leaves a validated session (tracking id set by track_session). let (_app_id, client_id) = create_sync_app(&h.db, user_id).await; let (_verifier, challenge) = generate_pkce(); let redirect_uri = "http://127.0.0.1:9999/callback"; // Silent re-auth only issues a code for scopes already consented to // (R6-Sec-L5), so interactively authorize the scope first, the real RP flow // (a user logs in with MNW once, then the RP refreshes silently). let resp = h .client .get(&format!( "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state=pn0&code_challenge={}&code_challenge_method=S256&scope={}", urlencoding::encode(&client_id), urlencoding::encode(redirect_uri), challenge, urlencoding::encode("profile:read perks:read"), )) .await; assert_eq!(resp.status.as_u16(), 200, "authorize page: {}", resp.text); let csrf = h.client.csrf_token().expect("csrf").to_string(); let body = format!( "client_id={}&redirect_uri={}&state=pn0&code_challenge={}&code_challenge_method=S256&scope={}&_csrf={}", urlencoding::encode(&client_id), urlencoding::encode(redirect_uri), challenge, urlencoding::encode("profile:read perks:read"), urlencoding::encode(&csrf), ); let resp = h.client.post_form("/oauth/authorize", &body).await; assert!( resp.status.is_redirection(), "consent POST: {} {}", resp.status, resp.text ); // Now prompt=none silently issues a code for the consented scope. let resp = h .client .get(&format!( "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state=pn&code_challenge={}&code_challenge_method=S256&scope={}&prompt=none", urlencoding::encode(&client_id), urlencoding::encode(redirect_uri), challenge, urlencoding::encode("profile:read perks:read"), )) .await; assert!( resp.status.is_redirection(), "prompt=none logged-in should 302, got {}", resp.status ); let location = resp.header("location").expect("Location header"); let (code, _state) = extract_code_from_redirect(location); assert!( !code.is_empty(), "prompt=none should return a code silently" ); } #[tokio::test] async fn oauth_prompt_none_logged_out_returns_login_required() { let mut h = TestHarness::new().await; let user_id = h.signup("pnoutuser", "pnout@test.com", "Password1!").await; 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 resp = h .client .get(&format!( "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state=pn2&code_challenge={}&code_challenge_method=S256&prompt=none", urlencoding::encode(&client_id), urlencoding::encode("http://127.0.0.1:9999/callback"), challenge, )) .await; assert!( resp.status.is_redirection(), "prompt=none logged-out should 302" ); let location = resp.header("location").expect("Location header"); assert!( location.contains("error=login_required"), "expected login_required, got: {location}" ); } #[tokio::test] async fn oauth_discovery_metadata() { let mut h = TestHarness::new().await; let resp = h .client .get("/.well-known/oauth-authorization-server") .await; assert_eq!(resp.status.as_u16(), 200); let meta: serde_json::Value = resp.json(); assert!(meta.get("authorization_endpoint").is_some()); assert!(meta.get("token_endpoint").is_some()); assert!(meta.get("userinfo_endpoint").is_some()); let grants = meta .get("grant_types_supported") .and_then(|g| g.as_array()) .expect("grants"); assert!(grants.iter().any(|g| g == "refresh_token")); let scopes = meta .get("scopes_supported") .and_then(|s| s.as_array()) .expect("scopes"); assert!(scopes.iter().any(|s| s == "offline_access")); } /// R6-Sec-L5: the prompt=none silent re-auth path may only mint a code for /// scopes already interactively consented to. A subset request is issued /// silently; a wider request returns error=consent_required. #[tokio::test] async fn oauth_prompt_none_gated_by_prior_consent() { let mut h = TestHarness::new().await; let user_id = h .signup("oauthconsent", "oc@example.com", "Password1!") .await; let (_app_id, client_id) = create_sync_app(&h.db, user_id).await; // A validated site session is required for the silent path. h.client.post_form("/logout", "").await; h.login("oauthconsent", "Password1!").await; let (_verifier, challenge) = generate_pkce(); let redirect_uri = "http://127.0.0.1:9999/callback"; // Interactive consent for `profile:read perks:read`. let resp = h .client .get(&format!( "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state=s1&code_challenge={}&code_challenge_method=S256&scope={}", urlencoding::encode(&client_id), urlencoding::encode(redirect_uri), challenge, urlencoding::encode("profile:read perks:read"), )) .await; assert_eq!(resp.status.as_u16(), 200, "authorize page: {}", resp.text); let csrf = h.client.csrf_token().expect("csrf").to_string(); let body = format!( "client_id={}&redirect_uri={}&state=s1&code_challenge={}&code_challenge_method=S256&scope={}&_csrf={}", urlencoding::encode(&client_id), urlencoding::encode(redirect_uri), challenge, urlencoding::encode("profile:read perks:read"), urlencoding::encode(&csrf), ); let resp = h.client.post_form("/oauth/authorize", &body).await; assert!( resp.status.is_redirection(), "consent POST: {} {}", resp.status, resp.text ); // prompt=none with a SUBSET scope -> silent code issued. let resp = h .client .get(&format!( "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state=s2&code_challenge={}&code_challenge_method=S256&scope={}&prompt=none", urlencoding::encode(&client_id), urlencoding::encode(redirect_uri), challenge, urlencoding::encode("profile:read"), )) .await; assert!( resp.status.is_redirection(), "silent subset should redirect: {} {}", resp.status, resp.text ); let loc = resp.header("location").expect("location"); assert!( loc.contains("code="), "silent subset should carry a code: {loc}" ); assert!( !loc.contains("error="), "silent subset should not error: {loc}" ); // prompt=none with a WIDER scope (offline_access never consented) -> consent_required. let resp = h .client .get(&format!( "/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state=s3&code_challenge={}&code_challenge_method=S256&scope={}&prompt=none", urlencoding::encode(&client_id), urlencoding::encode(redirect_uri), challenge, urlencoding::encode("profile:read perks:read offline_access"), )) .await; assert!( resp.status.is_redirection(), "silent wider should redirect: {} {}", resp.status, resp.text ); let loc = resp.header("location").expect("location"); assert!( loc.contains("error=consent_required"), "wider scope must require consent: {loc}" ); }