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

feat(relay): OAuth authorization endpoint + neobrutal consent UI (MM-76)

Implements GET /oauth/authorize and POST /oauth/authorize for the
authorization code flow with PKCE enforcement and a server-rendered
consent screen.

GET validates client_id, redirect_uri, response_type=code, and
code_challenge_method=S256; renders a neobrutal HTML consent page
showing the client name and requested scopes. Errors that make
redirecting unsafe (unknown client, mismatched redirect_uri) return
an HTML error page instead of a redirect.

POST re-validates all parameters against the DB (hidden form fields
could be tampered with), generates a 43-char base64url authorization
code, stores it in oauth_authorization_codes with a 60-second TTL,
and redirects to redirect_uri?code=...&state=.... Denial redirects
with error=access_denied.

Also adds store_authorization_code and get_single_account_did to
db/oauth.rs; 21 new tests across the DB and route layers.

authored by malpercio.dev and committed by

Tangled 97f4c54f 0169a85c

+980 -7
+19
bruno/oauth_authorize_get.bru
··· 1 + meta { 2 + name: OAuth Authorize — GET (Consent Page) 3 + type: http 4 + seq: 13 5 + } 6 + 7 + get { 8 + url: {{baseUrl}}/oauth/authorize?client_id={{clientId}}&redirect_uri={{redirectUri}}&code_challenge={{codeChallenge}}&code_challenge_method=S256&state={{state}}&response_type=code&scope=atproto 9 + body: none 10 + auth: none 11 + } 12 + 13 + vars:pre-request { 14 + baseUrl: http://localhost:8080 15 + clientId: https://app.example.com/client-metadata.json 16 + redirectUri: https://app.example.com/callback 17 + codeChallenge: e3b0c44298fc1c149afbf4c8996fb924 18 + state: randomstate123 19 + }
+25
bruno/oauth_authorize_post.bru
··· 1 + meta { 2 + name: OAuth Authorize — POST (Approve) 3 + type: http 4 + seq: 14 5 + } 6 + 7 + post { 8 + url: {{baseUrl}}/oauth/authorize 9 + body: formUrlEncoded 10 + auth: none 11 + } 12 + 13 + body:form-urlencoded { 14 + action: approve 15 + client_id: https://app.example.com/client-metadata.json 16 + redirect_uri: https://app.example.com/callback 17 + code_challenge: e3b0c44298fc1c149afbf4c8996fb924 18 + code_challenge_method: S256 19 + state: randomstate123 20 + scope: atproto 21 + } 22 + 23 + vars:pre-request { 24 + baseUrl: http://localhost:8080 25 + }
+5
crates/relay/src/app.rs
··· 22 22 use crate::routes::describe_server::describe_server; 23 23 use crate::routes::get_relay_signing_key::get_relay_signing_key; 24 24 use crate::routes::health::health; 25 + use crate::routes::oauth_authorize::{get_authorization, post_authorization}; 25 26 use crate::routes::oauth_server_metadata::oauth_server_metadata; 26 27 use crate::routes::register_device::register_device; 27 28 use crate::routes::resolve_handle::resolve_handle_handler; ··· 111 112 .route( 112 113 "/.well-known/oauth-authorization-server", 113 114 get(oauth_server_metadata), 115 + ) 116 + .route( 117 + "/oauth/authorize", 118 + get(get_authorization).post(post_authorization), 114 119 ) 115 120 .route("/xrpc/_health", get(health)) 116 121 .route(
+121 -7
crates/relay/src/db/oauth.rs
··· 1 1 // pattern: Imperative Shell 2 2 // 3 - // Storage adapter for OAuth server-side state in the `oauth_clients` table. 4 - // Authorization code and token functions will be added when the full OAuth 5 - // flow is implemented. 3 + // Storage adapter for OAuth server-side state: client registry, authorization 4 + // codes, and helpers for the authorization endpoint. 6 5 7 6 use sqlx::SqlitePool; 8 7 ··· 10 9 /// 11 10 /// `client_metadata` is stored as a raw JSON string (RFC 7591 client metadata). 12 11 /// Callers are responsible for serializing/deserializing the JSON. 13 - // Wired to handlers when the OAuth authorization flow is implemented. 14 - #[allow(dead_code)] 15 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 16 pub client_id: String, 17 17 pub client_metadata: String, 18 + #[allow(dead_code)] 18 19 pub created_at: String, 19 20 } 20 21 ··· 44 45 } 45 46 46 47 /// Look up a registered OAuth client by `client_id`. Returns `None` if not found. 47 - // Wired to handlers when the OAuth authorization flow is implemented. 48 - #[allow(dead_code)] 49 48 pub async fn get_oauth_client( 50 49 pool: &SqlitePool, 51 50 client_id: &str, ··· 66 65 ) 67 66 } 68 67 68 + /// Store a newly generated authorization code. 69 + /// 70 + /// 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 + pub async fn store_authorization_code( 73 + pool: &SqlitePool, 74 + code: &str, 75 + client_id: &str, 76 + did: &str, 77 + code_challenge: &str, 78 + code_challenge_method: &str, 79 + redirect_uri: &str, 80 + scope: &str, 81 + ) -> Result<(), sqlx::Error> { 82 + sqlx::query( 83 + "INSERT INTO oauth_authorization_codes \ 84 + (code, client_id, did, code_challenge, code_challenge_method, redirect_uri, scope, \ 85 + expires_at, created_at) \ 86 + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now', '+60 seconds'), datetime('now'))", 87 + ) 88 + .bind(code) 89 + .bind(client_id) 90 + .bind(did) 91 + .bind(code_challenge) 92 + .bind(code_challenge_method) 93 + .bind(redirect_uri) 94 + .bind(scope) 95 + .execute(pool) 96 + .await?; 97 + Ok(()) 98 + } 99 + 100 + /// Return the DID of the single promoted account on this PDS. 101 + /// 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). 104 + 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?; 108 + Ok(row.map(|(did,)| did)) 109 + } 110 + 69 111 #[cfg(test)] 70 112 mod tests { 71 113 use super::*; ··· 124 166 is_unique_violation(&err), 125 167 "duplicate client_id should be a unique violation" 126 168 ); 169 + } 170 + 171 + #[tokio::test] 172 + async fn store_and_retrieve_authorization_code_exists_in_db() { 173 + let pool = test_pool().await; 174 + 175 + // Register client and account (FK constraints). 176 + register_oauth_client( 177 + &pool, 178 + "https://app.example.com/client-metadata.json", 179 + r#"{"redirect_uris":["https://app.example.com/callback"]}"#, 180 + ) 181 + .await 182 + .unwrap(); 183 + 184 + sqlx::query( 185 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 186 + VALUES (?, ?, NULL, datetime('now'), datetime('now'))", 187 + ) 188 + .bind("did:plc:testaccount000000000000") 189 + .bind("test@example.com") 190 + .execute(&pool) 191 + .await 192 + .unwrap(); 193 + 194 + store_authorization_code( 195 + &pool, 196 + "test-code-abc123", 197 + "https://app.example.com/client-metadata.json", 198 + "did:plc:testaccount000000000000", 199 + "e3b0c44298fc1c149afbf4c8996fb924", 200 + "S256", 201 + "https://app.example.com/callback", 202 + "atproto", 203 + ) 204 + .await 205 + .unwrap(); 206 + 207 + let row: Option<(String,)> = 208 + sqlx::query_as("SELECT code FROM oauth_authorization_codes WHERE code = ?") 209 + .bind("test-code-abc123") 210 + .fetch_optional(&pool) 211 + .await 212 + .unwrap(); 213 + 214 + assert!(row.is_some(), "authorization code should be stored"); 215 + } 216 + 217 + #[tokio::test] 218 + async fn get_single_account_did_returns_none_when_no_accounts() { 219 + let pool = test_pool().await; 220 + let result = get_single_account_did(&pool).await.unwrap(); 221 + assert!(result.is_none()); 222 + } 223 + 224 + #[tokio::test] 225 + async fn get_single_account_did_returns_did_when_account_exists() { 226 + let pool = test_pool().await; 227 + let did = "did:plc:testaccount000000000000"; 228 + 229 + sqlx::query( 230 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 231 + VALUES (?, ?, NULL, datetime('now'), datetime('now'))", 232 + ) 233 + .bind(did) 234 + .bind("test@example.com") 235 + .execute(&pool) 236 + .await 237 + .unwrap(); 238 + 239 + let result = get_single_account_did(&pool).await.unwrap(); 240 + assert_eq!(result.as_deref(), Some(did)); 127 241 } 128 242 }
+1
crates/relay/src/routes/mod.rs
··· 8 8 pub mod describe_server; 9 9 pub mod get_relay_signing_key; 10 10 pub mod health; 11 + pub mod oauth_authorize; 11 12 pub mod oauth_server_metadata; 12 13 pub mod register_device; 13 14 pub mod resolve_handle;
+809
crates/relay/src/routes/oauth_authorize.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: query params (client_id, redirect_uri, code_challenge, code_challenge_method, 4 + // state, scope, response_type) on GET; form body (action + same fields) on POST 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 8 + // Returns: 9 + // GET: HTML consent page (200) or HTML error page (400) when redirect is unsafe 10 + // POST: 303 redirect to redirect_uri?code=...&state=... or redirect_uri?error=... 11 + 12 + use axum::{ 13 + extract::{Form, Query, State}, 14 + http::StatusCode, 15 + response::{Html, IntoResponse, Redirect, Response}, 16 + }; 17 + use serde::Deserialize; 18 + 19 + use crate::app::AppState; 20 + use crate::db::oauth::{get_oauth_client, get_single_account_did, store_authorization_code}; 21 + use crate::routes::token::generate_token; 22 + 23 + /// Query parameters for `GET /oauth/authorize`. 24 + #[derive(Deserialize)] 25 + pub struct AuthorizeQuery { 26 + pub client_id: String, 27 + pub redirect_uri: String, 28 + pub code_challenge: String, 29 + pub code_challenge_method: String, 30 + pub state: String, 31 + pub response_type: String, 32 + #[serde(default = "default_scope")] 33 + pub scope: String, 34 + } 35 + 36 + fn default_scope() -> String { 37 + "atproto".to_string() 38 + } 39 + 40 + /// Form body for `POST /oauth/authorize`. 41 + #[derive(Deserialize)] 42 + pub struct ConsentForm { 43 + pub action: String, 44 + pub client_id: String, 45 + pub redirect_uri: String, 46 + pub code_challenge: String, 47 + pub code_challenge_method: String, 48 + pub state: String, 49 + pub scope: String, 50 + } 51 + 52 + /// Subset of RFC 7591 client metadata fields used by the authorization endpoint. 53 + #[derive(Deserialize, Default)] 54 + struct ClientMetadata { 55 + #[serde(default)] 56 + redirect_uris: Vec<String>, 57 + client_name: Option<String>, 58 + } 59 + 60 + /// `GET /oauth/authorize` — validate request parameters and render the consent page. 61 + /// 62 + /// Returns an HTML error page (400) for errors that make a redirect unsafe: 63 + /// unknown `client_id` or mismatched `redirect_uri`. All other parameter errors 64 + /// redirect to `redirect_uri` with an `error` query parameter per RFC 6749 §4.1.2.1. 65 + pub async fn get_authorization( 66 + State(state): State<AppState>, 67 + Query(params): Query<AuthorizeQuery>, 68 + ) -> 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 + 77 + let client = match get_oauth_client(&state.db, &params.client_id).await { 78 + Ok(Some(row)) => row, 79 + Ok(None) => { 80 + return error_page( 81 + "Unknown Client", 82 + "The client_id is not registered with this server.", 83 + ) 84 + .into_response() 85 + } 86 + Err(e) => { 87 + tracing::error!(error = %e, "db error looking up OAuth client"); 88 + return error_page("Server Error", "A database error occurred. Please try again.") 89 + .into_response(); 90 + } 91 + }; 92 + 93 + let metadata: ClientMetadata = match serde_json::from_str(&client.client_metadata) { 94 + Ok(m) => m, 95 + Err(_) => { 96 + return error_page( 97 + "Client Configuration Error", 98 + "The client's registered metadata is malformed.", 99 + ) 100 + .into_response() 101 + } 102 + }; 103 + 104 + if !metadata.redirect_uris.contains(&params.redirect_uri) { 105 + return error_page( 106 + "Invalid Redirect URI", 107 + "The redirect_uri does not match the client's registered URIs.", 108 + ) 109 + .into_response(); 110 + } 111 + 112 + // From here on redirect_uri is validated — errors go there, not to an error page. 113 + if params.code_challenge_method != "S256" { 114 + return error_redirect( 115 + &params.redirect_uri, 116 + "invalid_request", 117 + "code_challenge_method must be S256", 118 + &params.state, 119 + ) 120 + .into_response(); 121 + } 122 + 123 + let client_name = metadata 124 + .client_name 125 + .unwrap_or_else(|| params.client_id.clone()); 126 + 127 + Html(render_consent_page( 128 + &client_name, 129 + &params.client_id, 130 + &params.redirect_uri, 131 + &params.code_challenge, 132 + &params.code_challenge_method, 133 + &params.state, 134 + &params.scope, 135 + &state.config.public_url, 136 + )) 137 + .into_response() 138 + } 139 + 140 + /// `POST /oauth/authorize` — handle the user's approval or denial of the consent request. 141 + /// 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. 144 + pub async fn post_authorization( 145 + State(state): State<AppState>, 146 + Form(form): Form<ConsentForm>, 147 + ) -> 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. 169 + let client = match get_oauth_client(&state.db, &form.client_id).await { 170 + Ok(Some(row)) => row, 171 + Ok(None) => { 172 + return error_page("Unknown Client", "The client_id is not registered.").into_response() 173 + } 174 + Err(e) => { 175 + tracing::error!(error = %e, "db error looking up OAuth client during approval"); 176 + return error_page("Server Error", "A database error occurred.").into_response(); 177 + } 178 + }; 179 + 180 + let metadata: ClientMetadata = match serde_json::from_str(&client.client_metadata) { 181 + Ok(m) => m, 182 + Err(_) => { 183 + return error_page("Client Configuration Error", "Client metadata is malformed.") 184 + .into_response() 185 + } 186 + }; 187 + 188 + if !metadata.redirect_uris.contains(&form.redirect_uri) { 189 + return error_page( 190 + "Invalid Redirect URI", 191 + "The redirect_uri does not match the client's registered URIs.", 192 + ) 193 + .into_response(); 194 + } 195 + 196 + if form.code_challenge_method != "S256" { 197 + return error_redirect( 198 + &form.redirect_uri, 199 + "invalid_request", 200 + "code_challenge_method must be S256", 201 + &form.state, 202 + ) 203 + .into_response(); 204 + } 205 + 206 + let did = match get_single_account_did(&state.db).await { 207 + Ok(Some(did)) => did, 208 + Ok(None) => { 209 + return error_redirect( 210 + &form.redirect_uri, 211 + "server_error", 212 + "No account exists on this server", 213 + &form.state, 214 + ) 215 + .into_response() 216 + } 217 + Err(e) => { 218 + tracing::error!(error = %e, "db error fetching account DID for OAuth approval"); 219 + return error_redirect( 220 + &form.redirect_uri, 221 + "server_error", 222 + "Internal server error", 223 + &form.state, 224 + ) 225 + .into_response(); 226 + } 227 + }; 228 + 229 + let token = generate_token(); 230 + if let Err(e) = store_authorization_code( 231 + &state.db, 232 + &token.plaintext, 233 + &form.client_id, 234 + &did, 235 + &form.code_challenge, 236 + &form.code_challenge_method, 237 + &form.redirect_uri, 238 + &form.scope, 239 + ) 240 + .await 241 + { 242 + tracing::error!(error = %e, "failed to store authorization code"); 243 + return error_redirect( 244 + &form.redirect_uri, 245 + "server_error", 246 + "Failed to generate authorization code", 247 + &form.state, 248 + ) 249 + .into_response(); 250 + } 251 + 252 + let sep = if form.redirect_uri.contains('?') { 253 + '&' 254 + } else { 255 + '?' 256 + }; 257 + let redirect_url = format!( 258 + "{}{}code={}&state={}", 259 + form.redirect_uri, 260 + sep, 261 + encode_param(&token.plaintext), 262 + encode_param(&form.state), 263 + ); 264 + Redirect::to(&redirect_url).into_response() 265 + } 266 + 267 + // ── Helpers ─────────────────────────────────────────────────────────────────── 268 + 269 + /// Percent-encode a string for safe inclusion as a URL query parameter value. 270 + fn encode_param(s: &str) -> String { 271 + let mut out = String::with_capacity(s.len()); 272 + for b in s.bytes() { 273 + match b { 274 + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { 275 + out.push(b as char) 276 + } 277 + _ => out.push_str(&format!("%{b:02X}")), 278 + } 279 + } 280 + out 281 + } 282 + 283 + /// HTML-escape a string for safe embedding in HTML content or attribute values. 284 + fn html_escape(s: &str) -> String { 285 + s.replace('&', "&amp;") 286 + .replace('<', "&lt;") 287 + .replace('>', "&gt;") 288 + .replace('"', "&quot;") 289 + .replace('\'', "&#39;") 290 + } 291 + 292 + /// Build an OAuth error redirect (303) to `redirect_uri` with error parameters. 293 + fn error_redirect(redirect_uri: &str, error: &str, description: &str, state: &str) -> Redirect { 294 + let sep = if redirect_uri.contains('?') { '&' } else { '?' }; 295 + let url = format!( 296 + "{}{}error={}&error_description={}&state={}", 297 + redirect_uri, 298 + sep, 299 + encode_param(error), 300 + encode_param(description), 301 + encode_param(state), 302 + ); 303 + Redirect::to(&url) 304 + } 305 + 306 + /// Render a standalone HTML error page for cases where redirecting is unsafe 307 + /// (unknown `client_id`, mismatched `redirect_uri`). 308 + fn error_page(title: &str, message: &str) -> (StatusCode, Html<String>) { 309 + let mut html = String::with_capacity(1500); 310 + html.push_str(ERROR_PAGE_HEADER); 311 + html.push_str(&html_escape(title)); 312 + html.push_str("</title>\n <style>"); 313 + html.push_str(ERROR_CSS); 314 + html.push_str(" </style>\n</head>\n<body>\n"); 315 + html.push_str(" <div class=\"card\">\n"); 316 + html.push_str(" <div class=\"badge\">Error</div>\n"); 317 + html.push_str(" <h1>"); 318 + html.push_str(&html_escape(title)); 319 + html.push_str("</h1>\n <p>"); 320 + html.push_str(&html_escape(message)); 321 + html.push_str("</p>\n </div>\n</body>\n</html>"); 322 + (StatusCode::BAD_REQUEST, Html(html)) 323 + } 324 + 325 + /// Render the neobrutal OAuth consent page. 326 + /// 327 + /// All user-controlled values are HTML-escaped before insertion. 328 + fn render_consent_page( 329 + client_name: &str, 330 + client_id: &str, 331 + redirect_uri: &str, 332 + code_challenge: &str, 333 + code_challenge_method: &str, 334 + state: &str, 335 + scope: &str, 336 + public_url: &str, 337 + ) -> String { 338 + let scope_tags: String = scope 339 + .split_whitespace() 340 + .map(|s| { 341 + format!( 342 + "<span class=\"scope-tag\">{}</span>", 343 + html_escape(s) 344 + ) 345 + }) 346 + .collect::<Vec<_>>() 347 + .join("\n "); 348 + 349 + // Build HTML by concatenation — avoids doubling all CSS braces for format!. 350 + let mut html = String::with_capacity(4096); 351 + html.push_str(CONSENT_PAGE_HEADER); 352 + html.push_str(CONSENT_CSS); 353 + html.push_str(" </style>\n</head>\n<body>\n"); 354 + html.push_str(" <div class=\"card\">\n"); 355 + html.push_str(" <div class=\"header\">\n"); 356 + html.push_str(" <div class=\"badge\">Authorization Request</div>\n"); 357 + html.push_str(" <h1>Allow Access?</h1>\n"); 358 + html.push_str(" </div>\n"); 359 + html.push_str(" <div class=\"section-label\">Application</div>\n"); 360 + html.push_str(" <div class=\"client-name\">"); 361 + html.push_str(&html_escape(client_name)); 362 + html.push_str("</div>\n"); 363 + html.push_str(" <div class=\"client-id\">"); 364 + html.push_str(&html_escape(client_id)); 365 + html.push_str("</div>\n"); 366 + html.push_str(" <div class=\"section-label\">Requesting Permissions</div>\n"); 367 + html.push_str(" <div class=\"scopes\">\n "); 368 + html.push_str(&scope_tags); 369 + html.push_str("\n </div>\n"); 370 + html.push_str(" <form method=\"POST\" action=\"/oauth/authorize\">\n"); 371 + for (name, value) in [ 372 + ("client_id", client_id), 373 + ("redirect_uri", redirect_uri), 374 + ("code_challenge", code_challenge), 375 + ("code_challenge_method", code_challenge_method), 376 + ("state", state), 377 + ("scope", scope), 378 + ] { 379 + html.push_str(&format!( 380 + " <input type=\"hidden\" name=\"{}\" value=\"{}\" />\n", 381 + name, 382 + html_escape(value) 383 + )); 384 + } 385 + html.push_str(" <div class=\"actions\">\n"); 386 + html.push_str(" <button type=\"submit\" name=\"action\" value=\"deny\" class=\"btn btn-deny\">Deny</button>\n"); 387 + html.push_str(" <button type=\"submit\" name=\"action\" value=\"approve\" class=\"btn btn-approve\">Approve</button>\n"); 388 + html.push_str(" </div>\n </form>\n"); 389 + html.push_str(" <div class=\"server-info\">"); 390 + html.push_str(&html_escape(public_url)); 391 + html.push_str("</div>\n </div>\n</body>\n</html>"); 392 + html 393 + } 394 + 395 + // ── Static HTML fragments ───────────────────────────────────────────────────── 396 + 397 + const CONSENT_CSS: &str = r#" 398 + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 399 + body { 400 + font-family: ui-sans-serif, system-ui, sans-serif; 401 + background: #FFFBF0; 402 + min-height: 100vh; 403 + display: flex; 404 + align-items: center; 405 + justify-content: center; 406 + padding: 1.5rem; 407 + } 408 + .card { 409 + background: #fff; 410 + border: 3px solid #000; 411 + box-shadow: 6px 6px 0 #000; 412 + max-width: 440px; 413 + width: 100%; 414 + padding: 2rem; 415 + } 416 + .header { 417 + border-bottom: 3px solid #000; 418 + padding-bottom: 1.25rem; 419 + margin-bottom: 1.5rem; 420 + } 421 + .badge { 422 + display: inline-block; 423 + background: #FFE600; 424 + border: 2px solid #000; 425 + padding: 2px 10px; 426 + font-size: .75rem; 427 + font-weight: 700; 428 + text-transform: uppercase; 429 + letter-spacing: .05em; 430 + margin-bottom: .75rem; 431 + } 432 + h1 { 433 + font-size: 1.5rem; 434 + font-weight: 900; 435 + line-height: 1.2; 436 + color: #000; 437 + } 438 + .section-label { 439 + font-size: .7rem; 440 + font-weight: 700; 441 + text-transform: uppercase; 442 + letter-spacing: .06em; 443 + color: #555; 444 + margin-bottom: .5rem; 445 + margin-top: 1rem; 446 + } 447 + .client-name { font-size: 1.1rem; font-weight: 800; color: #000; } 448 + .client-id { font-size: .78rem; color: #555; word-break: break-all; margin-top: .2rem; } 449 + .scopes { 450 + display: flex; 451 + flex-wrap: wrap; 452 + gap: .5rem; 453 + margin-top: .5rem; 454 + margin-bottom: 1.5rem; 455 + } 456 + .scope-tag { 457 + background: #E8F4FF; 458 + border: 2px solid #000; 459 + padding: 3px 10px; 460 + font-size: .85rem; 461 + font-weight: 600; 462 + font-family: ui-monospace, monospace; 463 + } 464 + .actions { display: flex; gap: .75rem; } 465 + .btn { 466 + flex: 1; 467 + border: 3px solid #000; 468 + padding: .75rem 1.5rem; 469 + font-size: 1rem; 470 + font-weight: 800; 471 + cursor: pointer; 472 + text-transform: uppercase; 473 + letter-spacing: .05em; 474 + } 475 + .btn:active { transform: translate(3px, 3px); box-shadow: none !important; } 476 + .btn-approve { background: #00C853; box-shadow: 4px 4px 0 #000; } 477 + .btn-approve:hover { background: #00E676; } 478 + .btn-deny { background: #fff; box-shadow: 4px 4px 0 #000; } 479 + .btn-deny:hover { background: #FFE600; } 480 + .server-info { 481 + margin-top: 1.25rem; 482 + padding-top: 1rem; 483 + border-top: 2px solid #eee; 484 + font-size: .75rem; 485 + color: #888; 486 + } 487 + "#; 488 + 489 + const CONSENT_PAGE_HEADER: &str = concat!( 490 + "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n", 491 + " <meta charset=\"UTF-8\" />\n", 492 + " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n", 493 + " <title>Authorize Access</title>\n", 494 + " <style>", 495 + ); 496 + 497 + const ERROR_CSS: &str = r#" 498 + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 499 + body { 500 + font-family: ui-sans-serif, system-ui, sans-serif; 501 + background: #FFFBF0; 502 + min-height: 100vh; 503 + display: flex; 504 + align-items: center; 505 + justify-content: center; 506 + padding: 1.5rem; 507 + } 508 + .card { 509 + background: #fff; 510 + border: 3px solid #000; 511 + box-shadow: 6px 6px 0 #000; 512 + max-width: 420px; 513 + width: 100%; 514 + padding: 2rem; 515 + } 516 + .badge { 517 + display: inline-block; 518 + background: #FF3B30; 519 + color: #fff; 520 + border: 2px solid #000; 521 + padding: 2px 10px; 522 + font-size: .75rem; 523 + font-weight: 700; 524 + text-transform: uppercase; 525 + letter-spacing: .05em; 526 + margin-bottom: .75rem; 527 + } 528 + h1 { font-size: 1.5rem; font-weight: 900; color: #000; margin-bottom: 1rem; } 529 + p { color: #333; font-size: .95rem; line-height: 1.5; } 530 + "#; 531 + 532 + const ERROR_PAGE_HEADER: &str = concat!( 533 + "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n", 534 + " <meta charset=\"UTF-8\" />\n", 535 + " <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n", 536 + " <title>", 537 + ); 538 + 539 + #[cfg(test)] 540 + mod tests { 541 + use axum::{ 542 + body::Body, 543 + http::{Request, StatusCode}, 544 + }; 545 + use tower::ServiceExt; 546 + 547 + use crate::app::{app, test_state}; 548 + use crate::db::oauth::register_oauth_client; 549 + 550 + const CLIENT_ID: &str = "https://app.example.com/client-metadata.json"; 551 + const REDIRECT_URI: &str = "https://app.example.com/callback"; 552 + const CLIENT_METADATA: &str = 553 + r#"{"redirect_uris":["https://app.example.com/callback"],"client_name":"Test App"}"#; 554 + const DID: &str = "did:plc:testaccount000000000000"; 555 + 556 + async fn state_with_client() -> crate::app::AppState { 557 + let state = test_state().await; 558 + register_oauth_client(&state.db, CLIENT_ID, CLIENT_METADATA) 559 + .await 560 + .unwrap(); 561 + state 562 + } 563 + 564 + async fn state_with_client_and_account() -> crate::app::AppState { 565 + let state = state_with_client().await; 566 + sqlx::query( 567 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 568 + VALUES (?, ?, NULL, datetime('now'), datetime('now'))", 569 + ) 570 + .bind(DID) 571 + .bind("test@example.com") 572 + .execute(&state.db) 573 + .await 574 + .unwrap(); 575 + state 576 + } 577 + 578 + fn authorize_url(extra_params: &str) -> String { 579 + format!( 580 + "/oauth/authorize\ 581 + ?client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json\ 582 + &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback\ 583 + &code_challenge=e3b0c44298fc1c149afb\ 584 + &code_challenge_method=S256\ 585 + &state=teststate\ 586 + &response_type=code\ 587 + &scope=atproto\ 588 + {extra_params}" 589 + ) 590 + } 591 + 592 + async fn get_authorize(state: crate::app::AppState, url: &str) -> axum::response::Response { 593 + app(state) 594 + .oneshot(Request::builder().uri(url).body(Body::empty()).unwrap()) 595 + .await 596 + .unwrap() 597 + } 598 + 599 + async fn post_authorize( 600 + state: crate::app::AppState, 601 + body: &str, 602 + ) -> axum::response::Response { 603 + app(state) 604 + .oneshot( 605 + Request::builder() 606 + .method("POST") 607 + .uri("/oauth/authorize") 608 + .header("content-type", "application/x-www-form-urlencoded") 609 + .body(Body::from(body.to_string())) 610 + .unwrap(), 611 + ) 612 + .await 613 + .unwrap() 614 + } 615 + 616 + fn approve_form(extra: &str) -> String { 617 + format!( 618 + "action=approve\ 619 + &client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json\ 620 + &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback\ 621 + &code_challenge=e3b0c44298fc1c149afb\ 622 + &code_challenge_method=S256\ 623 + &state=teststate\ 624 + &scope=atproto\ 625 + {extra}" 626 + ) 627 + } 628 + 629 + // ── GET tests ───────────────────────────────────────────────────────────── 630 + 631 + #[tokio::test] 632 + async fn get_returns_200_with_html_content_type() { 633 + let resp = get_authorize(state_with_client().await, &authorize_url("")).await; 634 + assert_eq!(resp.status(), StatusCode::OK); 635 + assert!( 636 + resp.headers() 637 + .get("content-type") 638 + .unwrap() 639 + .to_str() 640 + .unwrap() 641 + .starts_with("text/html") 642 + ); 643 + } 644 + 645 + #[tokio::test] 646 + async fn get_returns_400_for_unknown_client_id() { 647 + let state = test_state().await; // no client registered 648 + let resp = get_authorize(state, &authorize_url("")).await; 649 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 650 + } 651 + 652 + #[tokio::test] 653 + async fn get_returns_400_for_mismatched_redirect_uri() { 654 + let resp = get_authorize( 655 + state_with_client().await, 656 + &authorize_url("").replace( 657 + "redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback", 658 + "redirect_uri=https%3A%2F%2Fevil.example.com%2Fcallback", 659 + ), 660 + ) 661 + .await; 662 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 663 + } 664 + 665 + #[tokio::test] 666 + async fn get_returns_400_for_wrong_response_type() { 667 + let resp = get_authorize( 668 + state_with_client().await, 669 + &authorize_url("").replace("response_type=code", "response_type=token"), 670 + ) 671 + .await; 672 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 673 + } 674 + 675 + #[tokio::test] 676 + async fn get_redirects_with_error_for_non_s256_challenge_method() { 677 + let url = authorize_url("") 678 + .replace("code_challenge_method=S256", "code_challenge_method=plain"); 679 + let resp = get_authorize(state_with_client().await, &url).await; 680 + // Redirect to redirect_uri with error — 303 681 + assert_eq!(resp.status(), StatusCode::SEE_OTHER); 682 + let location = resp.headers().get("location").unwrap().to_str().unwrap(); 683 + assert!(location.contains("error=invalid_request")); 684 + } 685 + 686 + #[tokio::test] 687 + async fn get_consent_page_contains_client_name() { 688 + let resp = get_authorize(state_with_client().await, &authorize_url("")).await; 689 + let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); 690 + let html = std::str::from_utf8(&body).unwrap(); 691 + assert!(html.contains("Test App"), "client_name should appear in the consent page"); 692 + } 693 + 694 + #[tokio::test] 695 + async fn get_consent_page_contains_scope_tag() { 696 + let resp = get_authorize(state_with_client().await, &authorize_url("")).await; 697 + let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); 698 + let html = std::str::from_utf8(&body).unwrap(); 699 + assert!( 700 + html.contains("atproto"), 701 + "requested scope should appear in the consent page" 702 + ); 703 + } 704 + 705 + #[tokio::test] 706 + async fn get_consent_page_has_approve_and_deny_buttons() { 707 + let resp = get_authorize(state_with_client().await, &authorize_url("")).await; 708 + let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); 709 + let html = std::str::from_utf8(&body).unwrap(); 710 + assert!(html.contains("value=\"approve\"")); 711 + assert!(html.contains("value=\"deny\"")); 712 + } 713 + 714 + #[tokio::test] 715 + async fn get_consent_page_has_hidden_inputs_with_request_values() { 716 + let resp = get_authorize(state_with_client().await, &authorize_url("")).await; 717 + let body = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); 718 + let html = std::str::from_utf8(&body).unwrap(); 719 + // Hidden inputs must carry forward the original request params for the POST. 720 + assert!(html.contains("name=\"state\"")); 721 + assert!(html.contains("name=\"code_challenge\"")); 722 + assert!(html.contains("name=\"redirect_uri\"")); 723 + } 724 + 725 + // ── POST tests ──────────────────────────────────────────────────────────── 726 + 727 + #[tokio::test] 728 + 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; 737 + assert_eq!(resp.status(), StatusCode::SEE_OTHER); 738 + let location = resp.headers().get("location").unwrap().to_str().unwrap(); 739 + assert!(location.contains("error=access_denied")); 740 + assert!(location.contains("state=teststate")); 741 + } 742 + 743 + #[tokio::test] 744 + async fn post_approve_redirects_with_code() { 745 + let resp = 746 + post_authorize(state_with_client_and_account().await, &approve_form("")).await; 747 + assert_eq!(resp.status(), StatusCode::SEE_OTHER); 748 + let location = resp.headers().get("location").unwrap().to_str().unwrap(); 749 + assert!(location.starts_with(REDIRECT_URI)); 750 + assert!(location.contains("code=")); 751 + assert!(location.contains("state=teststate")); 752 + assert!(!location.contains("error=")); 753 + } 754 + 755 + #[tokio::test] 756 + async fn post_approve_stores_code_in_db() { 757 + let state = state_with_client_and_account().await; 758 + let db = state.db.clone(); 759 + let resp = post_authorize(state, &approve_form("")).await; 760 + assert_eq!(resp.status(), StatusCode::SEE_OTHER); 761 + 762 + let location = resp.headers().get("location").unwrap().to_str().unwrap(); 763 + let code = location 764 + .split("code=") 765 + .nth(1) 766 + .unwrap() 767 + .split('&') 768 + .next() 769 + .unwrap(); 770 + 771 + let row: Option<(String,)> = 772 + sqlx::query_as("SELECT code FROM oauth_authorization_codes WHERE code = ?") 773 + .bind(code) 774 + .fetch_optional(&db) 775 + .await 776 + .unwrap(); 777 + assert!(row.is_some(), "auth code should be persisted in DB"); 778 + } 779 + 780 + #[tokio::test] 781 + async fn post_approve_with_no_account_redirects_with_server_error() { 782 + // No account inserted — server not set up. 783 + let resp = post_authorize(state_with_client().await, &approve_form("")).await; 784 + assert_eq!(resp.status(), StatusCode::SEE_OTHER); 785 + let location = resp.headers().get("location").unwrap().to_str().unwrap(); 786 + assert!(location.contains("error=server_error")); 787 + } 788 + 789 + #[tokio::test] 790 + async fn post_approve_returns_400_for_tampered_redirect_uri() { 791 + let body = approve_form("").replace( 792 + "redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback", 793 + "redirect_uri=https%3A%2F%2Fevil.example.com%2Fcallback", 794 + ); 795 + let resp = post_authorize(state_with_client_and_account().await, &body).await; 796 + // redirect_uri mismatch → can't safely redirect → error page 797 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 798 + } 799 + 800 + #[tokio::test] 801 + async fn post_approve_returns_400_for_tampered_client_id() { 802 + let body = approve_form("").replace( 803 + "client_id=https%3A%2F%2Fapp.example.com%2Fclient-metadata.json", 804 + "client_id=https%3A%2F%2Fevil.example.com%2Fclient-metadata.json", 805 + ); 806 + let resp = post_authorize(state_with_client_and_account().await, &body).await; 807 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 808 + } 809 + }