An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

fix(relay): address OAuth authorize PR review — security and coverage gaps

Critical:
- Open redirect: validate client_id + redirect_uri before deny/approve
branches; all three outcomes now only redirect to a DB-verified URI
- Store token.hash in oauth_authorization_codes, not token.plaintext,
consistent with session/refresh-token pattern; token endpoint hashes
the presented code before lookup

Important:
- Move response_type check to after redirect_uri validation in GET;
use error_redirect(unsupported_response_type) instead of error_page
- Add response_type field to ConsentForm + hidden input in render_consent_page
- Log client_id and serde error when client_metadata is malformed (GET + POST)
- Fix inaccurate doc comment on post_authorization

Tests: 8 new tests covering open redirect (tampered deny + approve redirect_uri),
XSS escaping (client_name, scope), URL-encoding of special chars in state,
non-S256 method in POST, client_name fallback, invalid action, malformed metadata

Suggestions: update comments on register_oauth_client, store_authorization_code,
and get_single_account_did; add ORDER BY created_at to get_single_account_did

authored by malpercio.dev and committed by

Tangled 08604672 97f4c54f

+227 -79
+16 -11
crates/relay/src/db/oauth.rs
··· 10 10 /// `client_metadata` is stored as a raw JSON string (RFC 7591 client metadata). 11 11 /// Callers are responsible for serializing/deserializing the JSON. 12 12 pub struct OAuthClientRow { 13 - // client_id and created_at are read by future handlers (dynamic client registration, 14 - // admin listing); only client_metadata is needed by the authorization endpoint now. 15 - #[allow(dead_code)] 16 13 pub client_id: String, 17 14 pub client_metadata: String, 15 + // created_at is included for future handlers (admin listing, DCR); 16 + // not read by any handler yet. 18 17 #[allow(dead_code)] 19 18 pub created_at: String, 20 19 } ··· 26 25 /// 27 26 /// Returns `sqlx::Error` on failure. Callers should use `crate::db::is_unique_violation` 28 27 /// to detect duplicate `client_id` conflicts. 29 - // Wired to handlers when the OAuth authorization flow is implemented. 28 + /// 29 + /// No HTTP handler calls this yet; a future dynamic client registration endpoint (RFC 7591) 30 + /// will call it. 30 31 #[allow(dead_code)] 31 32 pub async fn register_oauth_client( 32 33 pool: &SqlitePool, ··· 67 68 68 69 /// Store a newly generated authorization code. 69 70 /// 71 + /// `code` must be the SHA-256 hex hash of the raw token bytes — callers pass `token.hash`, 72 + /// not `token.plaintext`. The token endpoint (not yet implemented) hashes the presented code 73 + /// before lookup, consistent with the session and refresh-token patterns in this codebase. 74 + /// 70 75 /// The code expires 60 seconds after creation (single-use, short-lived per RFC 6749 §4.1.2). 71 - /// The token endpoint is responsible for consuming and deleting the code on exchange. 72 76 pub async fn store_authorization_code( 73 77 pool: &SqlitePool, 74 78 code: &str, ··· 97 101 Ok(()) 98 102 } 99 103 100 - /// Return the DID of the single promoted account on this PDS. 104 + /// Return the DID of the first account on this single-user PDS. 101 105 /// 102 - /// For a single-user PDS there is exactly one account after DID promotion. 103 - /// Returns `None` when no promoted account exists yet (server not yet set up). 106 + /// `ORDER BY created_at ASC` makes selection deterministic if the single-account 107 + /// invariant is ever violated. Returns `None` when no account row exists yet. 104 108 pub async fn get_single_account_did(pool: &SqlitePool) -> Result<Option<String>, sqlx::Error> { 105 - let row: Option<(String,)> = sqlx::query_as("SELECT did FROM accounts LIMIT 1") 106 - .fetch_optional(pool) 107 - .await?; 109 + let row: Option<(String,)> = 110 + sqlx::query_as("SELECT did FROM accounts ORDER BY created_at ASC LIMIT 1") 111 + .fetch_optional(pool) 112 + .await?; 108 113 Ok(row.map(|(did,)| did)) 109 114 } 110 115
+211 -68
crates/relay/src/routes/oauth_authorize.rs
··· 3 3 // Gathers: query params (client_id, redirect_uri, code_challenge, code_challenge_method, 4 4 // state, scope, response_type) on GET; form body (action + same fields) on POST 5 5 // Processes: 6 - // GET: validates params → looks up client → validates redirect_uri → renders consent HTML 7 - // POST: validates action → re-validates params → generates/stores auth code → redirects 6 + // GET: looks up client → validates redirect_uri → validates remaining params → renders HTML 7 + // POST: validates client + redirect_uri first → handles deny/approve → generates auth code 8 8 // Returns: 9 9 // GET: HTML consent page (200) or HTML error page (400) when redirect is unsafe 10 10 // POST: 303 redirect to redirect_uri?code=...&state=... or redirect_uri?error=... ··· 47 47 pub code_challenge_method: String, 48 48 pub state: String, 49 49 pub scope: String, 50 + pub response_type: String, 50 51 } 51 52 52 53 /// Subset of RFC 7591 client metadata fields used by the authorization endpoint. ··· 66 67 State(state): State<AppState>, 67 68 Query(params): Query<AuthorizeQuery>, 68 69 ) -> Response { 69 - if params.response_type != "code" { 70 - return error_page( 71 - "Unsupported Response Type", 72 - "This server only supports the authorization code flow (response_type=code).", 73 - ) 74 - .into_response(); 75 - } 76 - 70 + // Client and redirect_uri must be validated before any redirect is issued. 77 71 let client = match get_oauth_client(&state.db, &params.client_id).await { 78 72 Ok(Some(row)) => row, 79 73 Ok(None) => { ··· 92 86 93 87 let metadata: ClientMetadata = match serde_json::from_str(&client.client_metadata) { 94 88 Ok(m) => m, 95 - Err(_) => { 89 + Err(e) => { 90 + tracing::error!( 91 + client_id = %client.client_id, 92 + error = %e, 93 + "failed to parse stored client metadata" 94 + ); 96 95 return error_page( 97 96 "Client Configuration Error", 98 97 "The client's registered metadata is malformed.", 99 98 ) 100 - .into_response() 99 + .into_response(); 101 100 } 102 101 }; 103 102 ··· 109 108 .into_response(); 110 109 } 111 110 112 - // From here on redirect_uri is validated — errors go there, not to an error page. 111 + // From here on redirect_uri is validated — errors redirect there, not to an error page. 112 + if params.response_type != "code" { 113 + return error_redirect( 114 + &params.redirect_uri, 115 + "unsupported_response_type", 116 + "only response_type=code is supported", 117 + &params.state, 118 + ) 119 + .into_response(); 120 + } 121 + 113 122 if params.code_challenge_method != "S256" { 114 123 return error_redirect( 115 124 &params.redirect_uri, ··· 132 141 &params.code_challenge_method, 133 142 &params.state, 134 143 &params.scope, 144 + &params.response_type, 135 145 &state.config.public_url, 136 146 )) 137 147 .into_response() ··· 139 149 140 150 /// `POST /oauth/authorize` — handle the user's approval or denial of the consent request. 141 151 /// 142 - /// Re-validates all parameters against the database regardless of what the form 143 - /// contains — hidden form fields could be tampered with by a malicious browser. 152 + /// Re-validates client_id and redirect_uri against the database, and enforces 153 + /// code_challenge_method=S256, before issuing an authorization code or redirect. 154 + /// Hidden form fields could be tampered with by a malicious browser. 144 155 pub async fn post_authorization( 145 156 State(state): State<AppState>, 146 157 Form(form): Form<ConsentForm>, 147 158 ) -> Response { 148 - if form.action == "deny" { 149 - return error_redirect( 150 - &form.redirect_uri, 151 - "access_denied", 152 - "User denied access", 153 - &form.state, 154 - ) 155 - .into_response(); 156 - } 157 - 158 - if form.action != "approve" { 159 - return error_redirect( 160 - &form.redirect_uri, 161 - "invalid_request", 162 - "invalid action", 163 - &form.state, 164 - ) 165 - .into_response(); 166 - } 167 - 168 - // Re-validate client and redirect_uri — hidden fields could be tampered with. 159 + // Validate client and redirect_uri first — deny/approve both redirect there, 160 + // so we must confirm it is safe before using it as a redirect target. 169 161 let client = match get_oauth_client(&state.db, &form.client_id).await { 170 162 Ok(Some(row)) => row, 171 163 Ok(None) => { 172 164 return error_page("Unknown Client", "The client_id is not registered.").into_response() 173 165 } 174 166 Err(e) => { 175 - tracing::error!(error = %e, "db error looking up OAuth client during approval"); 167 + tracing::error!(error = %e, "db error looking up OAuth client"); 176 168 return error_page("Server Error", "A database error occurred.").into_response(); 177 169 } 178 170 }; 179 171 180 172 let metadata: ClientMetadata = match serde_json::from_str(&client.client_metadata) { 181 173 Ok(m) => m, 182 - Err(_) => { 174 + Err(e) => { 175 + tracing::error!( 176 + client_id = %client.client_id, 177 + error = %e, 178 + "failed to parse stored client metadata" 179 + ); 183 180 return error_page("Client Configuration Error", "Client metadata is malformed.") 184 - .into_response() 181 + .into_response(); 185 182 } 186 183 }; 187 184 ··· 193 190 .into_response(); 194 191 } 195 192 193 + // redirect_uri is now validated — denial and all subsequent errors redirect there. 194 + if form.action == "deny" { 195 + return error_redirect( 196 + &form.redirect_uri, 197 + "access_denied", 198 + "User denied access", 199 + &form.state, 200 + ) 201 + .into_response(); 202 + } 203 + 204 + if form.action != "approve" { 205 + return error_redirect( 206 + &form.redirect_uri, 207 + "invalid_request", 208 + "invalid action", 209 + &form.state, 210 + ) 211 + .into_response(); 212 + } 213 + 214 + if form.response_type != "code" { 215 + return error_redirect( 216 + &form.redirect_uri, 217 + "unsupported_response_type", 218 + "only response_type=code is supported", 219 + &form.state, 220 + ) 221 + .into_response(); 222 + } 223 + 196 224 if form.code_challenge_method != "S256" { 197 225 return error_redirect( 198 226 &form.redirect_uri, ··· 226 254 } 227 255 }; 228 256 257 + // Store the SHA-256 hash of the code, matching the session/refresh-token pattern. 258 + // The token endpoint hashes the presented code before lookup, consistent with all 259 + // other tokens in this codebase. 229 260 let token = generate_token(); 230 261 if let Err(e) = store_authorization_code( 231 262 &state.db, 232 - &token.plaintext, 263 + &token.hash, 233 264 &form.client_id, 234 265 &did, 235 266 &form.code_challenge, ··· 249 280 .into_response(); 250 281 } 251 282 283 + // Return plaintext to the client; the DB stores only the hash. 252 284 let sep = if form.redirect_uri.contains('?') { 253 285 '&' 254 286 } else { ··· 325 357 /// Render the neobrutal OAuth consent page. 326 358 /// 327 359 /// All user-controlled values are HTML-escaped before insertion. 360 + #[allow(clippy::too_many_arguments)] 328 361 fn render_consent_page( 329 362 client_name: &str, 330 363 client_id: &str, ··· 333 366 code_challenge_method: &str, 334 367 state: &str, 335 368 scope: &str, 369 + response_type: &str, 336 370 public_url: &str, 337 371 ) -> String { 338 372 let scope_tags: String = scope 339 373 .split_whitespace() 340 - .map(|s| { 341 - format!( 342 - "<span class=\"scope-tag\">{}</span>", 343 - html_escape(s) 344 - ) 345 - }) 374 + .map(|s| format!("<span class=\"scope-tag\">{}</span>", html_escape(s))) 346 375 .collect::<Vec<_>>() 347 376 .join("\n "); 348 377 ··· 375 404 ("code_challenge_method", code_challenge_method), 376 405 ("state", state), 377 406 ("scope", scope), 407 + ("response_type", response_type), 378 408 ] { 379 409 html.push_str(&format!( 380 410 " <input type=\"hidden\" name=\"{}\" value=\"{}\" />\n", ··· 546 576 547 577 use crate::app::{app, test_state}; 548 578 use crate::db::oauth::register_oauth_client; 579 + use crate::routes::token::hash_bearer_token; 549 580 550 581 const CLIENT_ID: &str = "https://app.example.com/client-metadata.json"; 551 582 const REDIRECT_URI: &str = "https://app.example.com/callback"; ··· 622 653 &code_challenge_method=S256\ 623 654 &state=teststate\ 624 655 &scope=atproto\ 656 + &response_type=code\ 625 657 {extra}" 626 658 ) 659 + } 660 + 661 + fn deny_form() -> &'static str { 662 + "action=deny\ 663 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json\ 664 + &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback\ 665 + &code_challenge=e3b0c44298fc1c149afb\ 666 + &code_challenge_method=S256\ 667 + &state=teststate\ 668 + &scope=atproto\ 669 + &response_type=code" 627 670 } 628 671 629 672 // ── GET tests ───────────────────────────────────────────────────────────── ··· 663 706 } 664 707 665 708 #[tokio::test] 666 - async fn get_returns_400_for_wrong_response_type() { 709 + async fn get_redirects_with_error_for_wrong_response_type() { 710 + // response_type check happens after redirect_uri validation — redirects, not error page. 667 711 let resp = get_authorize( 668 712 state_with_client().await, 669 713 &authorize_url("").replace("response_type=code", "response_type=token"), 670 714 ) 671 715 .await; 672 - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 716 + assert_eq!(resp.status(), StatusCode::SEE_OTHER); 717 + let location = resp.headers().get("location").unwrap().to_str().unwrap(); 718 + assert!(location.contains("error=unsupported_response_type")); 673 719 } 674 720 675 721 #[tokio::test] ··· 677 723 let url = authorize_url("") 678 724 .replace("code_challenge_method=S256", "code_challenge_method=plain"); 679 725 let resp = get_authorize(state_with_client().await, &url).await; 680 - // Redirect to redirect_uri with error — 303 681 726 assert_eq!(resp.status(), StatusCode::SEE_OTHER); 682 727 let location = resp.headers().get("location").unwrap().to_str().unwrap(); 683 728 assert!(location.contains("error=invalid_request")); ··· 692 737 } 693 738 694 739 #[tokio::test] 695 - async fn get_consent_page_contains_scope_tag() { 696 - let resp = get_authorize(state_with_client().await, &authorize_url("")).await; 740 + async fn get_consent_page_falls_back_to_client_id_when_no_client_name() { 741 + let state = test_state().await; 742 + let metadata_no_name = r#"{"redirect_uris":["https://app.example.com/callback"]}"#; 743 + register_oauth_client(&state.db, CLIENT_ID, metadata_no_name) 744 + .await 745 + .unwrap(); 746 + let resp = get_authorize(state, &authorize_url("")).await; 697 747 let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); 698 748 let html = std::str::from_utf8(&body).unwrap(); 699 749 assert!( 700 - html.contains("atproto"), 701 - "requested scope should appear in the consent page" 750 + html.contains("app.example.com"), 751 + "client_id should appear when client_name is absent" 702 752 ); 703 753 } 704 754 705 755 #[tokio::test] 756 + async fn get_consent_page_escapes_xss_in_client_name() { 757 + let state = test_state().await; 758 + let xss_metadata = r#"{"redirect_uris":["https://app.example.com/callback"],"client_name":"<script>alert(1)</script>"}"#; 759 + register_oauth_client(&state.db, CLIENT_ID, xss_metadata) 760 + .await 761 + .unwrap(); 762 + let resp = get_authorize(state, &authorize_url("")).await; 763 + let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); 764 + let html = std::str::from_utf8(&body).unwrap(); 765 + assert!(!html.contains("<script>"), "raw <script> must not appear in output"); 766 + assert!(html.contains("&lt;script&gt;"), "script tag must be HTML-escaped"); 767 + } 768 + 769 + #[tokio::test] 770 + async fn get_consent_page_escapes_xss_in_scope() { 771 + // scope=<b>bold</b> URL-encoded in the request 772 + let url = 773 + authorize_url("").replace("scope=atproto", "scope=%3Cb%3Ebold%3C%2Fb%3E"); 774 + let resp = get_authorize(state_with_client().await, &url).await; 775 + let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); 776 + let html = std::str::from_utf8(&body).unwrap(); 777 + assert!(!html.contains("<b>"), "raw HTML tags must not appear in scope output"); 778 + assert!(html.contains("&lt;b&gt;"), "scope tags must be HTML-escaped"); 779 + } 780 + 781 + #[tokio::test] 782 + async fn get_consent_page_contains_scope_tag() { 783 + let resp = get_authorize(state_with_client().await, &authorize_url("")).await; 784 + let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); 785 + let html = std::str::from_utf8(&body).unwrap(); 786 + assert!(html.contains("atproto"), "requested scope should appear in the consent page"); 787 + } 788 + 789 + #[tokio::test] 706 790 async fn get_consent_page_has_approve_and_deny_buttons() { 707 791 let resp = get_authorize(state_with_client().await, &authorize_url("")).await; 708 792 let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); ··· 716 800 let resp = get_authorize(state_with_client().await, &authorize_url("")).await; 717 801 let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); 718 802 let html = std::str::from_utf8(&body).unwrap(); 719 - // Hidden inputs must carry forward the original request params for the POST. 720 803 assert!(html.contains("name=\"state\"")); 721 804 assert!(html.contains("name=\"code_challenge\"")); 722 805 assert!(html.contains("name=\"redirect_uri\"")); 806 + assert!(html.contains("name=\"response_type\"")); 723 807 } 724 808 725 809 // ── POST tests ──────────────────────────────────────────────────────────── 726 810 727 811 #[tokio::test] 728 812 async fn post_deny_redirects_with_access_denied() { 729 - let body = "action=deny\ 730 - &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json\ 731 - &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback\ 732 - &code_challenge=e3b0c44298fc1c149afb\ 733 - &code_challenge_method=S256\ 734 - &state=teststate\ 735 - &scope=atproto"; 736 - let resp = post_authorize(state_with_client_and_account().await, body).await; 813 + let resp = post_authorize(state_with_client_and_account().await, deny_form()).await; 737 814 assert_eq!(resp.status(), StatusCode::SEE_OTHER); 738 815 let location = resp.headers().get("location").unwrap().to_str().unwrap(); 739 816 assert!(location.contains("error=access_denied")); ··· 741 818 } 742 819 743 820 #[tokio::test] 821 + async fn post_deny_with_tampered_redirect_uri_returns_400() { 822 + // Tampered redirect_uri fails DB validation before the deny redirect is issued. 823 + let body = deny_form().replace( 824 + "redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback", 825 + "redirect_uri=https%3A%2F%2Fevil.example.com%2Fcallback", 826 + ); 827 + let resp = post_authorize(state_with_client_and_account().await, &body).await; 828 + assert_eq!( 829 + resp.status(), 830 + StatusCode::BAD_REQUEST, 831 + "tampered redirect_uri must return an error page, not redirect to attacker URI" 832 + ); 833 + } 834 + 835 + #[tokio::test] 836 + async fn post_invalid_action_redirects_with_invalid_request() { 837 + let body = approve_form("").replace("action=approve", "action=blah"); 838 + let resp = post_authorize(state_with_client_and_account().await, &body).await; 839 + assert_eq!(resp.status(), StatusCode::SEE_OTHER); 840 + let location = resp.headers().get("location").unwrap().to_str().unwrap(); 841 + assert!(location.contains("error=invalid_request")); 842 + } 843 + 844 + #[tokio::test] 744 845 async fn post_approve_redirects_with_code() { 745 846 let resp = 746 847 post_authorize(state_with_client_and_account().await, &approve_form("")).await; ··· 753 854 } 754 855 755 856 #[tokio::test] 756 - async fn post_approve_stores_code_in_db() { 857 + async fn post_approve_stores_hashed_code_in_db() { 858 + // The DB stores the SHA-256 hash of the code; the plaintext goes in the redirect URL. 757 859 let state = state_with_client_and_account().await; 758 860 let db = state.db.clone(); 759 861 let resp = post_authorize(state, &approve_form("")).await; 760 862 assert_eq!(resp.status(), StatusCode::SEE_OTHER); 761 863 762 864 let location = resp.headers().get("location").unwrap().to_str().unwrap(); 763 - let code = location 865 + let plaintext = location 764 866 .split("code=") 765 867 .nth(1) 766 868 .unwrap() 767 869 .split('&') 768 870 .next() 769 871 .unwrap(); 872 + let code_hash = hash_bearer_token(plaintext).unwrap(); 770 873 771 874 let row: Option<(String,)> = 772 875 sqlx::query_as("SELECT code FROM oauth_authorization_codes WHERE code = ?") 773 - .bind(code) 876 + .bind(&code_hash) 774 877 .fetch_optional(&db) 775 878 .await 776 879 .unwrap(); 777 - assert!(row.is_some(), "auth code should be persisted in DB"); 880 + assert!(row.is_some(), "DB must store the hash, not the plaintext"); 881 + } 882 + 883 + #[tokio::test] 884 + async fn post_approve_encodes_special_chars_in_state() { 885 + // state with &, =, spaces must be percent-encoded in the Location header. 886 + let body = approve_form("").replace("state=teststate", "state=a%26b%3Dc%20d"); 887 + let resp = post_authorize(state_with_client_and_account().await, &body).await; 888 + assert_eq!(resp.status(), StatusCode::SEE_OTHER); 889 + let location = resp.headers().get("location").unwrap().to_str().unwrap(); 890 + // a&b=c d percent-encoded: a%26b%3Dc%20d 891 + assert!( 892 + location.contains("state=a%26b%3Dc%20d"), 893 + "special chars in state must be percent-encoded: {location}" 894 + ); 895 + } 896 + 897 + #[tokio::test] 898 + async fn post_approve_redirects_with_error_for_non_s256_method() { 899 + let body = approve_form("").replace("code_challenge_method=S256", "code_challenge_method=plain"); 900 + let resp = post_authorize(state_with_client_and_account().await, &body).await; 901 + assert_eq!(resp.status(), StatusCode::SEE_OTHER); 902 + let location = resp.headers().get("location").unwrap().to_str().unwrap(); 903 + assert!(location.contains("error=invalid_request")); 778 904 } 779 905 780 906 #[tokio::test] 781 907 async fn post_approve_with_no_account_redirects_with_server_error() { 782 - // No account inserted — server not set up. 783 908 let resp = post_authorize(state_with_client().await, &approve_form("")).await; 784 909 assert_eq!(resp.status(), StatusCode::SEE_OTHER); 785 910 let location = resp.headers().get("location").unwrap().to_str().unwrap(); ··· 793 918 "redirect_uri=https%3A%2F%2Fevil.example.com%2Fcallback", 794 919 ); 795 920 let resp = post_authorize(state_with_client_and_account().await, &body).await; 796 - // redirect_uri mismatch → can't safely redirect → error page 797 921 assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 798 922 } 799 923 ··· 804 928 "client_id=https%3A%2F%2Fevil.example.com%2Fclient-metadata.json", 805 929 ); 806 930 let resp = post_authorize(state_with_client_and_account().await, &body).await; 931 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 932 + } 933 + 934 + #[tokio::test] 935 + async fn post_approve_returns_400_for_malformed_client_metadata() { 936 + let state = test_state().await; 937 + register_oauth_client(&state.db, CLIENT_ID, "not valid json") 938 + .await 939 + .unwrap(); 940 + sqlx::query( 941 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 942 + VALUES (?, ?, NULL, datetime('now'), datetime('now'))", 943 + ) 944 + .bind(DID) 945 + .bind("test@example.com") 946 + .execute(&state.db) 947 + .await 948 + .unwrap(); 949 + let resp = post_authorize(state, &approve_form("")).await; 807 950 assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 808 951 } 809 952 }