···1010pub mod health;
1111pub mod oauth_authorize;
1212pub mod oauth_server_metadata;
1313+pub mod oauth_token;
1314pub mod register_device;
1415pub mod resolve_handle;
1516
+238
crates/relay/src/routes/oauth_token.rs
···11+// pattern: Imperative Shell
22+//
33+// Gathers: AppState (signing key, nonce store, DB), DPoP header, form body
44+// Processes: DPoP validation → grant dispatch → token issuance
55+// Returns: JSON TokenResponse + DPoP-Nonce header on success;
66+// JSON OAuthTokenError on all failure paths
77+88+use axum::{
99+ extract::State,
1010+ http::{HeaderMap, StatusCode},
1111+ response::{IntoResponse, Response},
1212+ Form, Json,
1313+};
1414+use serde::{Deserialize, Serialize};
1515+1616+use crate::app::AppState;
1717+1818+// ── Request / response types ──────────────────────────────────────────────────
1919+2020+/// Flat form body for `POST /oauth/token` (application/x-www-form-urlencoded).
2121+///
2222+/// All fields are `Option<String>` so that the handler can provide RFC 6749-compliant
2323+/// error messages instead of Axum's default 422 rejection when fields are missing.
2424+#[derive(Debug, Deserialize)]
2525+pub struct TokenRequestForm {
2626+ pub grant_type: Option<String>,
2727+ // authorization_code grant
2828+ pub code: Option<String>,
2929+ pub redirect_uri: Option<String>,
3030+ pub client_id: Option<String>,
3131+ pub code_verifier: Option<String>,
3232+ // refresh_token grant
3333+ pub refresh_token: Option<String>,
3434+}
3535+3636+/// Successful token endpoint response body (RFC 6749 §5.1).
3737+#[derive(Debug, Serialize)]
3838+pub struct TokenResponse {
3939+ pub access_token: String,
4040+ pub token_type: &'static str,
4141+ pub expires_in: u64,
4242+ pub refresh_token: String,
4343+ pub scope: String,
4444+}
4545+4646+/// OAuth 2.0 error response body (RFC 6749 §5.2).
4747+///
4848+/// All token endpoint errors use this format, distinct from the codebase's
4949+/// `ApiError` envelope (`{ "error": { "code": "...", "message": "..." } }`).
5050+pub struct OAuthTokenError {
5151+ pub error: &'static str,
5252+ pub error_description: &'static str,
5353+ /// Optional DPoP-Nonce value to include in the response header.
5454+ /// Required for `use_dpop_nonce` errors so the client can retry.
5555+ pub dpop_nonce: Option<String>,
5656+}
5757+5858+impl OAuthTokenError {
5959+ pub fn new(error: &'static str, error_description: &'static str) -> Self {
6060+ Self {
6161+ error,
6262+ error_description,
6363+ dpop_nonce: None,
6464+ }
6565+ }
6666+6767+ pub fn with_nonce(
6868+ error: &'static str,
6969+ error_description: &'static str,
7070+ nonce: String,
7171+ ) -> Self {
7272+ Self {
7373+ error,
7474+ error_description,
7575+ dpop_nonce: Some(nonce),
7676+ }
7777+ }
7878+}
7979+8080+impl IntoResponse for OAuthTokenError {
8181+ fn into_response(self) -> Response {
8282+ let body = serde_json::json!({
8383+ "error": self.error,
8484+ "error_description": self.error_description,
8585+ });
8686+ let mut headers = axum::http::HeaderMap::new();
8787+ headers.insert(
8888+ axum::http::header::CONTENT_TYPE,
8989+ "application/json".parse().unwrap(),
9090+ );
9191+ if let Some(nonce) = self.dpop_nonce {
9292+ headers.insert("DPoP-Nonce", nonce.parse().unwrap());
9393+ }
9494+ (StatusCode::BAD_REQUEST, headers, Json(body)).into_response()
9595+ }
9696+}
9797+9898+// ── Handler ───────────────────────────────────────────────────────────────────
9999+100100+/// `POST /oauth/token` — OAuth 2.0 token endpoint (RFC 6749 §3.2).
101101+///
102102+/// Phase 4 stub: validates grant_type, returns correct errors for unknown or
103103+/// missing grant_type. Full grant logic is added in Phases 5 and 6.
104104+pub async fn post_token(
105105+ State(_state): State<AppState>,
106106+ _headers: HeaderMap,
107107+ Form(form): Form<TokenRequestForm>,
108108+) -> Response {
109109+ let grant_type = match form.grant_type.as_deref() {
110110+ Some(g) => g,
111111+ None => {
112112+ return OAuthTokenError::new(
113113+ "invalid_request",
114114+ "missing required parameter: grant_type",
115115+ )
116116+ .into_response();
117117+ }
118118+ };
119119+120120+ match grant_type {
121121+ "authorization_code" => {
122122+ // Implemented in Phase 5.
123123+ OAuthTokenError::new("invalid_grant", "authorization_code grant not yet implemented")
124124+ .into_response()
125125+ }
126126+ "refresh_token" => {
127127+ // Implemented in Phase 6.
128128+ OAuthTokenError::new("invalid_grant", "refresh_token grant not yet implemented")
129129+ .into_response()
130130+ }
131131+ _ => OAuthTokenError::new(
132132+ "unsupported_grant_type",
133133+ "grant_type must be authorization_code or refresh_token",
134134+ )
135135+ .into_response(),
136136+ }
137137+}
138138+139139+#[cfg(test)]
140140+mod tests {
141141+ use axum::{
142142+ body::Body,
143143+ http::{Request, StatusCode},
144144+ };
145145+ use tower::ServiceExt;
146146+147147+ use crate::app::{app, test_state};
148148+149149+ fn post_token(body: &str) -> Request<Body> {
150150+ Request::builder()
151151+ .method("POST")
152152+ .uri("/oauth/token")
153153+ .header("Content-Type", "application/x-www-form-urlencoded")
154154+ .body(Body::from(body.to_string()))
155155+ .unwrap()
156156+ }
157157+158158+ async fn json_body(resp: axum::response::Response) -> serde_json::Value {
159159+ let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap();
160160+ serde_json::from_slice(&bytes).unwrap()
161161+ }
162162+163163+ // AC5.2 — unknown grant_type
164164+ #[tokio::test]
165165+ async fn unknown_grant_type_returns_400_unsupported() {
166166+ let resp = app(test_state().await)
167167+ .oneshot(post_token("grant_type=client_credentials"))
168168+ .await
169169+ .unwrap();
170170+171171+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
172172+ let json = json_body(resp).await;
173173+ assert_eq!(json["error"], "unsupported_grant_type");
174174+ }
175175+176176+ // AC5.3 — missing grant_type
177177+ #[tokio::test]
178178+ async fn missing_grant_type_returns_400_invalid_request() {
179179+ let resp = app(test_state().await)
180180+ .oneshot(post_token("code=abc123"))
181181+ .await
182182+ .unwrap();
183183+184184+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
185185+ let json = json_body(resp).await;
186186+ assert_eq!(json["error"], "invalid_request");
187187+ }
188188+189189+ // AC5.4 — errors must be JSON, not HTML
190190+ #[tokio::test]
191191+ async fn error_response_content_type_is_json() {
192192+ let resp = app(test_state().await)
193193+ .oneshot(post_token("grant_type=bad"))
194194+ .await
195195+ .unwrap();
196196+197197+ let ct = resp
198198+ .headers()
199199+ .get("content-type")
200200+ .unwrap()
201201+ .to_str()
202202+ .unwrap();
203203+ assert!(ct.contains("application/json"), "content-type must be application/json");
204204+ }
205205+206206+ // AC5.1 partial — errors have expected field shape
207207+ #[tokio::test]
208208+ async fn error_response_has_error_and_error_description_fields() {
209209+ let resp = app(test_state().await)
210210+ .oneshot(post_token("grant_type=bad"))
211211+ .await
212212+ .unwrap();
213213+214214+ let json = json_body(resp).await;
215215+ assert!(json["error"].is_string(), "error field must be a string");
216216+ assert!(
217217+ json["error_description"].is_string(),
218218+ "error_description field must be a string"
219219+ );
220220+ }
221221+222222+ // GET to /oauth/token should return 405 Method Not Allowed.
223223+ #[tokio::test]
224224+ async fn get_token_endpoint_returns_405() {
225225+ let resp = app(test_state().await)
226226+ .oneshot(
227227+ Request::builder()
228228+ .method("GET")
229229+ .uri("/oauth/token")
230230+ .body(Body::empty())
231231+ .unwrap(),
232232+ )
233233+ .await
234234+ .unwrap();
235235+236236+ assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
237237+ }
238238+}