Line data Source code
1 : //! HTTP handlers for identity system
2 :
3 : use std::sync::Arc;
4 :
5 : use axum::{
6 : extract::Extension, http::StatusCode, response::IntoResponse, routing::post, Json, Router,
7 : };
8 : use serde::{Deserialize, Serialize};
9 : use uuid::Uuid;
10 :
11 : use super::repo::{AccountRepo, AccountRepoError};
12 : use tc_crypto::{decode_base64url_native as decode_base64url, derive_kid};
13 :
14 : /// Signup request payload
15 : #[derive(Debug, Deserialize)]
16 : pub struct SignupRequest {
17 : pub username: String,
18 : pub root_pubkey: String, // base64url encoded
19 : }
20 :
21 : /// Signup response
22 : #[derive(Debug, Serialize, Deserialize)]
23 : pub struct SignupResponse {
24 : pub account_id: Uuid,
25 : pub root_kid: String,
26 : }
27 :
28 : /// Error response
29 : #[derive(Debug, Serialize, Deserialize)]
30 : pub struct ErrorResponse {
31 : pub error: String,
32 : }
33 :
34 : /// Create identity router
35 15 : pub fn router() -> Router {
36 15 : Router::new().route("/auth/signup", post(signup))
37 15 : }
38 :
39 : /// Handle signup request
40 13 : async fn signup(
41 13 : Extension(repo): Extension<Arc<dyn AccountRepo>>,
42 13 : Json(req): Json<SignupRequest>,
43 13 : ) -> impl IntoResponse {
44 : // Validate username
45 13 : let username = req.username.trim();
46 13 : if username.is_empty() {
47 2 : return (
48 2 : StatusCode::BAD_REQUEST,
49 2 : Json(ErrorResponse {
50 2 : error: "Username cannot be empty".to_string(),
51 2 : }),
52 2 : )
53 2 : .into_response();
54 11 : }
55 :
56 11 : if username.len() > 64 {
57 0 : return (
58 0 : StatusCode::BAD_REQUEST,
59 0 : Json(ErrorResponse {
60 0 : error: "Username too long".to_string(),
61 0 : }),
62 0 : )
63 0 : .into_response();
64 11 : }
65 :
66 : // Decode and validate public key
67 11 : let Ok(pubkey_bytes) = decode_base64url(&req.root_pubkey) else {
68 2 : return (
69 2 : StatusCode::BAD_REQUEST,
70 2 : Json(ErrorResponse {
71 2 : error: "Invalid base64url encoding for root_pubkey".to_string(),
72 2 : }),
73 2 : )
74 2 : .into_response();
75 : };
76 :
77 9 : if pubkey_bytes.len() != 32 {
78 1 : return (
79 1 : StatusCode::BAD_REQUEST,
80 1 : Json(ErrorResponse {
81 1 : error: "root_pubkey must be 32 bytes (Ed25519)".to_string(),
82 1 : }),
83 1 : )
84 1 : .into_response();
85 8 : }
86 :
87 : // Derive KID from public key
88 8 : let root_kid = derive_kid(&pubkey_bytes);
89 :
90 : // Create account via repository
91 8 : match repo.create(username, &req.root_pubkey, &root_kid).await {
92 4 : Ok(account) => (
93 4 : StatusCode::CREATED,
94 4 : Json(SignupResponse {
95 4 : account_id: account.id,
96 4 : root_kid: account.root_kid,
97 4 : }),
98 4 : )
99 4 : .into_response(),
100 4 : Err(e) => match e {
101 2 : AccountRepoError::DuplicateUsername => (
102 2 : StatusCode::CONFLICT,
103 2 : Json(ErrorResponse {
104 2 : error: "Username already taken".to_string(),
105 2 : }),
106 2 : )
107 2 : .into_response(),
108 1 : AccountRepoError::DuplicateKey => (
109 1 : StatusCode::CONFLICT,
110 1 : Json(ErrorResponse {
111 1 : error: "Public key already registered".to_string(),
112 1 : }),
113 1 : )
114 1 : .into_response(),
115 1 : AccountRepoError::Database(db_err) => {
116 1 : tracing::error!("Signup failed: {}", db_err);
117 1 : (
118 1 : StatusCode::INTERNAL_SERVER_ERROR,
119 1 : Json(ErrorResponse {
120 1 : error: "Internal server error".to_string(),
121 1 : }),
122 1 : )
123 1 : .into_response()
124 : }
125 : },
126 : }
127 13 : }
128 :
129 : #[cfg(test)]
130 : mod tests {
131 : use super::*;
132 : use crate::identity::repo::mock::MockAccountRepo;
133 : use axum::{
134 : body::{to_bytes, Body},
135 : http::{Request, StatusCode},
136 : };
137 : use sqlx::Error as SqlxError;
138 : use tc_crypto::{derive_kid, encode_base64url};
139 : use tower::ServiceExt;
140 :
141 7 : fn test_router(repo: Arc<dyn AccountRepo>) -> Router {
142 7 : Router::new()
143 7 : .route("/auth/signup", post(signup))
144 7 : .layer(Extension(repo))
145 7 : }
146 :
147 5 : fn encoded_pubkey(byte: u8) -> (String, String) {
148 5 : let pubkey_bytes = [byte; 32];
149 5 : let encoded = encode_base64url(&pubkey_bytes);
150 5 : let kid = derive_kid(&pubkey_bytes);
151 5 : (encoded, kid)
152 5 : }
153 :
154 : #[tokio::test]
155 1 : async fn test_signup_success() {
156 1 : let mock_repo = Arc::new(MockAccountRepo::new());
157 1 : let app = test_router(mock_repo.clone());
158 :
159 1 : let (root_pubkey, expected_kid) = encoded_pubkey(1);
160 :
161 1 : let response = app
162 1 : .oneshot(
163 1 : Request::builder()
164 1 : .method("POST")
165 1 : .uri("/auth/signup")
166 1 : .header("content-type", "application/json")
167 1 : .body(Body::from(format!(
168 1 : r#"{{"username": "alice", "root_pubkey": "{root_pubkey}"}}"#
169 1 : )))
170 1 : .expect("request builder"),
171 1 : )
172 1 : .await
173 1 : .expect("response");
174 :
175 1 : let (parts, body) = response.into_parts();
176 1 : assert_eq!(parts.status, StatusCode::CREATED);
177 :
178 1 : let body_bytes = to_bytes(body, 1024 * 1024).await.expect("body bytes");
179 1 : let payload: SignupResponse = serde_json::from_slice(&body_bytes).expect("json payload");
180 :
181 1 : assert_eq!(payload.root_kid, expected_kid);
182 :
183 1 : let calls = mock_repo.calls();
184 1 : assert_eq!(calls.len(), 1);
185 1 : let (username, captured_pubkey, captured_kid) = &calls[0];
186 1 : assert_eq!(username, "alice");
187 1 : assert_eq!(captured_pubkey, &root_pubkey);
188 1 : assert_eq!(captured_kid, &expected_kid);
189 1 : }
190 :
191 : #[tokio::test]
192 1 : async fn test_signup_empty_username() {
193 1 : let mock_repo = Arc::new(MockAccountRepo::new());
194 1 : let app = test_router(mock_repo);
195 :
196 1 : let (root_pubkey, _) = encoded_pubkey(2);
197 :
198 1 : let response = app
199 1 : .oneshot(
200 1 : Request::builder()
201 1 : .method("POST")
202 1 : .uri("/auth/signup")
203 1 : .header("content-type", "application/json")
204 1 : .body(Body::from(format!(
205 1 : r#"{{"username": "", "root_pubkey": "{root_pubkey}"}}"#
206 1 : )))
207 1 : .expect("request builder"),
208 1 : )
209 1 : .await
210 1 : .expect("response");
211 :
212 1 : assert_eq!(response.status(), StatusCode::BAD_REQUEST);
213 1 : }
214 :
215 : #[tokio::test]
216 1 : async fn test_signup_invalid_base64_pubkey() {
217 1 : let mock_repo = Arc::new(MockAccountRepo::new());
218 1 : let app = test_router(mock_repo);
219 :
220 1 : let response = app
221 1 : .oneshot(
222 1 : Request::builder()
223 1 : .method("POST")
224 1 : .uri("/auth/signup")
225 1 : .header("content-type", "application/json")
226 1 : .body(Body::from(
227 1 : r#"{"username": "alice", "root_pubkey": "!!!not-base64!!!"}"#,
228 1 : ))
229 1 : .expect("request builder"),
230 1 : )
231 1 : .await
232 1 : .expect("response");
233 :
234 1 : let (parts, body) = response.into_parts();
235 1 : assert_eq!(parts.status, StatusCode::BAD_REQUEST);
236 1 : let body_bytes = to_bytes(body, 1024 * 1024).await.expect("body bytes");
237 1 : let payload: ErrorResponse = serde_json::from_slice(&body_bytes).expect("json payload");
238 1 : assert!(payload
239 1 : .error
240 1 : .contains("Invalid base64url encoding for root_pubkey"));
241 1 : }
242 :
243 : #[tokio::test]
244 1 : async fn test_signup_pubkey_wrong_length() {
245 1 : let mock_repo = Arc::new(MockAccountRepo::new());
246 1 : let app = test_router(mock_repo);
247 :
248 : // Valid base64 but only encodes 4 bytes.
249 1 : let short_pubkey = encode_base64url(&[9u8; 4]);
250 :
251 1 : let response = app
252 1 : .oneshot(
253 1 : Request::builder()
254 1 : .method("POST")
255 1 : .uri("/auth/signup")
256 1 : .header("content-type", "application/json")
257 1 : .body(Body::from(format!(
258 1 : r#"{{"username": "alice", "root_pubkey": "{short_pubkey}"}}"#
259 1 : )))
260 1 : .expect("request builder"),
261 1 : )
262 1 : .await
263 1 : .expect("response");
264 :
265 1 : let (parts, body) = response.into_parts();
266 1 : assert_eq!(parts.status, StatusCode::BAD_REQUEST);
267 1 : let body_bytes = to_bytes(body, 1024 * 1024).await.expect("body bytes");
268 1 : let payload: ErrorResponse = serde_json::from_slice(&body_bytes).expect("json payload");
269 1 : assert!(payload
270 1 : .error
271 1 : .contains("root_pubkey must be 32 bytes (Ed25519)"));
272 1 : }
273 :
274 : #[tokio::test]
275 1 : async fn test_signup_duplicate_username() {
276 1 : let mock_repo = Arc::new(MockAccountRepo::new());
277 1 : mock_repo.set_create_result(Err(AccountRepoError::DuplicateUsername));
278 1 : let app = test_router(mock_repo);
279 :
280 1 : let (root_pubkey, _) = encoded_pubkey(3);
281 :
282 1 : let response = app
283 1 : .oneshot(
284 1 : Request::builder()
285 1 : .method("POST")
286 1 : .uri("/auth/signup")
287 1 : .header("content-type", "application/json")
288 1 : .body(Body::from(format!(
289 1 : r#"{{"username": "alice", "root_pubkey": "{root_pubkey}"}}"#
290 1 : )))
291 1 : .expect("request builder"),
292 1 : )
293 1 : .await
294 1 : .expect("response");
295 :
296 1 : assert_eq!(response.status(), StatusCode::CONFLICT);
297 1 : }
298 :
299 : #[tokio::test]
300 1 : async fn test_signup_duplicate_key() {
301 1 : let mock_repo = Arc::new(MockAccountRepo::new());
302 1 : mock_repo.set_create_result(Err(AccountRepoError::DuplicateKey));
303 1 : let app = test_router(mock_repo);
304 :
305 1 : let (root_pubkey, _) = encoded_pubkey(4);
306 :
307 1 : let response = app
308 1 : .oneshot(
309 1 : Request::builder()
310 1 : .method("POST")
311 1 : .uri("/auth/signup")
312 1 : .header("content-type", "application/json")
313 1 : .body(Body::from(format!(
314 1 : r#"{{"username": "alice", "root_pubkey": "{root_pubkey}"}}"#
315 1 : )))
316 1 : .expect("request builder"),
317 1 : )
318 1 : .await
319 1 : .expect("response");
320 :
321 1 : assert_eq!(response.status(), StatusCode::CONFLICT);
322 1 : }
323 :
324 : #[tokio::test]
325 1 : async fn test_signup_database_error_returns_500() {
326 1 : let mock_repo = Arc::new(MockAccountRepo::new());
327 1 : mock_repo.set_create_result(Err(AccountRepoError::Database(SqlxError::Io(
328 1 : std::io::Error::new(std::io::ErrorKind::Other, "boom"),
329 1 : ))));
330 1 : let app = test_router(mock_repo);
331 :
332 1 : let (root_pubkey, _) = encoded_pubkey(5);
333 :
334 1 : let response = app
335 1 : .oneshot(
336 1 : Request::builder()
337 1 : .method("POST")
338 1 : .uri("/auth/signup")
339 1 : .header("content-type", "application/json")
340 1 : .body(Body::from(format!(
341 1 : r#"{{"username": "alice", "root_pubkey": "{root_pubkey}"}}"#
342 1 : )))
343 1 : .expect("request builder"),
344 1 : )
345 1 : .await
346 1 : .expect("response");
347 :
348 1 : let (parts, body) = response.into_parts();
349 1 : assert_eq!(parts.status, StatusCode::INTERNAL_SERVER_ERROR);
350 :
351 1 : let body_bytes = to_bytes(body, 1024 * 1024).await.expect("body bytes");
352 1 : let payload: ErrorResponse = serde_json::from_slice(&body_bytes).expect("json payload");
353 1 : assert!(payload.error.contains("Internal server error"));
354 1 : }
355 : }
|