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

feat(relay): POST /oauth/token stub — route, types, error format

+241
+2
crates/relay/src/app.rs
··· 25 25 use crate::routes::health::health; 26 26 use crate::routes::oauth_authorize::{get_authorization, post_authorization}; 27 27 use crate::routes::oauth_server_metadata::oauth_server_metadata; 28 + use crate::routes::oauth_token::post_token; 28 29 use crate::routes::register_device::register_device; 29 30 use crate::routes::resolve_handle::resolve_handle_handler; 30 31 use crate::well_known::WellKnownResolver; ··· 125 126 "/oauth/authorize", 126 127 get(get_authorization).post(post_authorization), 127 128 ) 129 + .route("/oauth/token", post(post_token)) 128 130 .route("/xrpc/_health", get(health)) 129 131 .route( 130 132 "/xrpc/com.atproto.server.describeServer",
+1
crates/relay/src/routes/mod.rs
··· 10 10 pub mod health; 11 11 pub mod oauth_authorize; 12 12 pub mod oauth_server_metadata; 13 + pub mod oauth_token; 13 14 pub mod register_device; 14 15 pub mod resolve_handle; 15 16
+238
crates/relay/src/routes/oauth_token.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: AppState (signing key, nonce store, DB), DPoP header, form body 4 + // Processes: DPoP validation → grant dispatch → token issuance 5 + // Returns: JSON TokenResponse + DPoP-Nonce header on success; 6 + // JSON OAuthTokenError on all failure paths 7 + 8 + use axum::{ 9 + extract::State, 10 + http::{HeaderMap, StatusCode}, 11 + response::{IntoResponse, Response}, 12 + Form, Json, 13 + }; 14 + use serde::{Deserialize, Serialize}; 15 + 16 + use crate::app::AppState; 17 + 18 + // ── Request / response types ────────────────────────────────────────────────── 19 + 20 + /// Flat form body for `POST /oauth/token` (application/x-www-form-urlencoded). 21 + /// 22 + /// All fields are `Option<String>` so that the handler can provide RFC 6749-compliant 23 + /// error messages instead of Axum's default 422 rejection when fields are missing. 24 + #[derive(Debug, Deserialize)] 25 + pub struct TokenRequestForm { 26 + pub grant_type: Option<String>, 27 + // authorization_code grant 28 + pub code: Option<String>, 29 + pub redirect_uri: Option<String>, 30 + pub client_id: Option<String>, 31 + pub code_verifier: Option<String>, 32 + // refresh_token grant 33 + pub refresh_token: Option<String>, 34 + } 35 + 36 + /// Successful token endpoint response body (RFC 6749 §5.1). 37 + #[derive(Debug, Serialize)] 38 + pub struct TokenResponse { 39 + pub access_token: String, 40 + pub token_type: &'static str, 41 + pub expires_in: u64, 42 + pub refresh_token: String, 43 + pub scope: String, 44 + } 45 + 46 + /// OAuth 2.0 error response body (RFC 6749 §5.2). 47 + /// 48 + /// All token endpoint errors use this format, distinct from the codebase's 49 + /// `ApiError` envelope (`{ "error": { "code": "...", "message": "..." } }`). 50 + pub struct OAuthTokenError { 51 + pub error: &'static str, 52 + pub error_description: &'static str, 53 + /// Optional DPoP-Nonce value to include in the response header. 54 + /// Required for `use_dpop_nonce` errors so the client can retry. 55 + pub dpop_nonce: Option<String>, 56 + } 57 + 58 + impl OAuthTokenError { 59 + pub fn new(error: &'static str, error_description: &'static str) -> Self { 60 + Self { 61 + error, 62 + error_description, 63 + dpop_nonce: None, 64 + } 65 + } 66 + 67 + pub fn with_nonce( 68 + error: &'static str, 69 + error_description: &'static str, 70 + nonce: String, 71 + ) -> Self { 72 + Self { 73 + error, 74 + error_description, 75 + dpop_nonce: Some(nonce), 76 + } 77 + } 78 + } 79 + 80 + impl IntoResponse for OAuthTokenError { 81 + fn into_response(self) -> Response { 82 + let body = serde_json::json!({ 83 + "error": self.error, 84 + "error_description": self.error_description, 85 + }); 86 + let mut headers = axum::http::HeaderMap::new(); 87 + headers.insert( 88 + axum::http::header::CONTENT_TYPE, 89 + "application/json".parse().unwrap(), 90 + ); 91 + if let Some(nonce) = self.dpop_nonce { 92 + headers.insert("DPoP-Nonce", nonce.parse().unwrap()); 93 + } 94 + (StatusCode::BAD_REQUEST, headers, Json(body)).into_response() 95 + } 96 + } 97 + 98 + // ── Handler ─────────────────────────────────────────────────────────────────── 99 + 100 + /// `POST /oauth/token` — OAuth 2.0 token endpoint (RFC 6749 §3.2). 101 + /// 102 + /// Phase 4 stub: validates grant_type, returns correct errors for unknown or 103 + /// missing grant_type. Full grant logic is added in Phases 5 and 6. 104 + pub async fn post_token( 105 + State(_state): State<AppState>, 106 + _headers: HeaderMap, 107 + Form(form): Form<TokenRequestForm>, 108 + ) -> Response { 109 + let grant_type = match form.grant_type.as_deref() { 110 + Some(g) => g, 111 + None => { 112 + return OAuthTokenError::new( 113 + "invalid_request", 114 + "missing required parameter: grant_type", 115 + ) 116 + .into_response(); 117 + } 118 + }; 119 + 120 + match grant_type { 121 + "authorization_code" => { 122 + // Implemented in Phase 5. 123 + OAuthTokenError::new("invalid_grant", "authorization_code grant not yet implemented") 124 + .into_response() 125 + } 126 + "refresh_token" => { 127 + // Implemented in Phase 6. 128 + OAuthTokenError::new("invalid_grant", "refresh_token grant not yet implemented") 129 + .into_response() 130 + } 131 + _ => OAuthTokenError::new( 132 + "unsupported_grant_type", 133 + "grant_type must be authorization_code or refresh_token", 134 + ) 135 + .into_response(), 136 + } 137 + } 138 + 139 + #[cfg(test)] 140 + mod tests { 141 + use axum::{ 142 + body::Body, 143 + http::{Request, StatusCode}, 144 + }; 145 + use tower::ServiceExt; 146 + 147 + use crate::app::{app, test_state}; 148 + 149 + fn post_token(body: &str) -> Request<Body> { 150 + Request::builder() 151 + .method("POST") 152 + .uri("/oauth/token") 153 + .header("Content-Type", "application/x-www-form-urlencoded") 154 + .body(Body::from(body.to_string())) 155 + .unwrap() 156 + } 157 + 158 + async fn json_body(resp: axum::response::Response) -> serde_json::Value { 159 + let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); 160 + serde_json::from_slice(&bytes).unwrap() 161 + } 162 + 163 + // AC5.2 — unknown grant_type 164 + #[tokio::test] 165 + async fn unknown_grant_type_returns_400_unsupported() { 166 + let resp = app(test_state().await) 167 + .oneshot(post_token("grant_type=client_credentials")) 168 + .await 169 + .unwrap(); 170 + 171 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 172 + let json = json_body(resp).await; 173 + assert_eq!(json["error"], "unsupported_grant_type"); 174 + } 175 + 176 + // AC5.3 — missing grant_type 177 + #[tokio::test] 178 + async fn missing_grant_type_returns_400_invalid_request() { 179 + let resp = app(test_state().await) 180 + .oneshot(post_token("code=abc123")) 181 + .await 182 + .unwrap(); 183 + 184 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); 185 + let json = json_body(resp).await; 186 + assert_eq!(json["error"], "invalid_request"); 187 + } 188 + 189 + // AC5.4 — errors must be JSON, not HTML 190 + #[tokio::test] 191 + async fn error_response_content_type_is_json() { 192 + let resp = app(test_state().await) 193 + .oneshot(post_token("grant_type=bad")) 194 + .await 195 + .unwrap(); 196 + 197 + let ct = resp 198 + .headers() 199 + .get("content-type") 200 + .unwrap() 201 + .to_str() 202 + .unwrap(); 203 + assert!(ct.contains("application/json"), "content-type must be application/json"); 204 + } 205 + 206 + // AC5.1 partial — errors have expected field shape 207 + #[tokio::test] 208 + async fn error_response_has_error_and_error_description_fields() { 209 + let resp = app(test_state().await) 210 + .oneshot(post_token("grant_type=bad")) 211 + .await 212 + .unwrap(); 213 + 214 + let json = json_body(resp).await; 215 + assert!(json["error"].is_string(), "error field must be a string"); 216 + assert!( 217 + json["error_description"].is_string(), 218 + "error_description field must be a string" 219 + ); 220 + } 221 + 222 + // GET to /oauth/token should return 405 Method Not Allowed. 223 + #[tokio::test] 224 + async fn get_token_endpoint_returns_405() { 225 + let resp = app(test_state().await) 226 + .oneshot( 227 + Request::builder() 228 + .method("GET") 229 + .uri("/oauth/token") 230 + .body(Body::empty()) 231 + .unwrap(), 232 + ) 233 + .await 234 + .unwrap(); 235 + 236 + assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED); 237 + } 238 + }