1use std::fmt;
2
3use serde::{Deserialize, Deserializer, Serialize};
4use utoipa::openapi::{
5 RefOr,
6 schema::{ObjectBuilder, Schema, SchemaType, Type},
7 security::{Http, HttpAuthScheme, SecurityScheme},
8};
9use utoipa::{Modify, OpenApi, ToSchema};
10
11use crate::health::HealthResponse;
12
13fn deserialize_required_nullable<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
14where
15 D: Deserializer<'de>,
16{
17 Option::<String>::deserialize(deserializer)
18}
19
20fn deserialize_active_false<'de, D>(deserializer: D) -> Result<bool, D::Error>
21where
22 D: Deserializer<'de>,
23{
24 let value = bool::deserialize(deserializer)?;
25 if value {
26 return Err(serde::de::Error::custom("active must be false"));
27 }
28 Ok(false)
29}
30
31fn deserialize_active_true<'de, D>(deserializer: D) -> Result<bool, D::Error>
32where
33 D: Deserializer<'de>,
34{
35 let value = bool::deserialize(deserializer)?;
36 if !value {
37 return Err(serde::de::Error::custom("active must be true"));
38 }
39 Ok(true)
40}
41
42fn false_schema() -> RefOr<Schema> {
43 ObjectBuilder::new()
44 .schema_type(SchemaType::Type(Type::Boolean))
45 .enum_values(Some([false]))
46 .into()
47}
48
49fn true_schema() -> RefOr<Schema> {
50 ObjectBuilder::new()
51 .schema_type(SchemaType::Type(Type::Boolean))
52 .enum_values(Some([true]))
53 .into()
54}
55
56#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)]
58#[serde(rename_all = "snake_case")]
59pub enum ServerErrorCode {
60 InvalidRequest,
61 InvalidCredential,
62 NotFound,
63 Conflict,
64 RequestTimeout,
65 TemporarilyUnavailable,
66 InternalError,
67}
68
69#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)]
71#[serde(deny_unknown_fields)]
72pub struct ServerError {
73 pub code: ServerErrorCode,
74 #[schema(max_length = 256)]
75 pub message: String,
76 #[schema(max_length = 128)]
77 pub request_id: String,
78}
79
80#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)]
81#[serde(rename_all = "snake_case")]
82pub enum ServerUserStatus {
83 Active,
84 Disabled,
85 Merged,
86}
87
88#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)]
90#[serde(deny_unknown_fields)]
91pub struct ServerUser {
92 #[schema(max_length = 96)]
93 pub user_id: String,
94 #[schema(max_length = 96)]
95 pub project_id: String,
96 pub status: ServerUserStatus,
97 #[serde(deserialize_with = "deserialize_required_nullable")]
98 #[schema(max_length = 128, required = true)]
99 pub display_name: Option<String>,
100 #[serde(deserialize_with = "deserialize_required_nullable")]
101 #[schema(max_length = 2048, required = true)]
102 pub picture_url: Option<String>,
103 #[serde(deserialize_with = "deserialize_required_nullable")]
104 #[schema(max_length = 320, required = true)]
105 pub verified_email: Option<String>,
106 #[schema(minimum = 1)]
107 pub user_revision: i64,
108 #[schema(max_length = 64)]
109 pub created_at: String,
110 #[schema(max_length = 64)]
111 pub updated_at: String,
112}
113
114#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)]
116#[serde(deny_unknown_fields)]
117pub struct ServerUserList {
118 #[schema(max_items = 100)]
119 pub items: Vec<ServerUser>,
120 #[serde(deserialize_with = "deserialize_required_nullable")]
121 #[schema(max_length = 64, required = true)]
122 pub next_cursor: Option<String>,
123}
124
125#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)]
127#[serde(deny_unknown_fields)]
128pub struct LookupServerUserRequest {
129 #[schema(min_length = 3, max_length = 320)]
130 pub email: String,
131}
132
133#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)]
135#[serde(deny_unknown_fields)]
136pub struct LookupServerUserResponse {
137 #[serde(deserialize_with = "deserialize_required_nullable_user")]
138 #[schema(required = true)]
139 pub user: Option<ServerUser>,
140}
141
142fn deserialize_required_nullable_user<'de, D>(
143 deserializer: D,
144) -> Result<Option<ServerUser>, D::Error>
145where
146 D: Deserializer<'de>,
147{
148 Option::<ServerUser>::deserialize(deserializer)
149}
150
151#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
153#[serde(deny_unknown_fields)]
154pub struct ServerApplicationUserProjection {
155 #[schema(max_length = 96)]
156 pub project_id: String,
157 #[schema(max_length = 96)]
158 pub application_id: String,
159 #[schema(max_length = 96)]
160 pub user_id: String,
161 #[schema(max_length = 64)]
162 pub projection_schema: String,
163 #[schema(minimum = 1)]
164 pub user_revision: i64,
165 #[schema(minimum = 1)]
166 pub projection_revision: i64,
167 #[serde(deserialize_with = "deserialize_required_nullable")]
168 #[schema(max_length = 128, required = true)]
169 pub display_name: Option<String>,
170 #[serde(deserialize_with = "deserialize_required_nullable")]
171 #[schema(max_length = 2048, required = true)]
172 pub picture_url: Option<String>,
173 #[serde(deserialize_with = "deserialize_required_nullable")]
174 #[schema(max_length = 35, required = true)]
175 pub locale: Option<String>,
176 #[serde(deserialize_with = "deserialize_required_nullable")]
177 #[schema(max_length = 320, required = true)]
178 pub verified_email: Option<String>,
179 #[schema(max_length = 32)]
180 pub status: String,
181 #[schema(max_length = 64)]
182 pub created_at: String,
183 #[schema(max_length = 64)]
184 pub updated_at: String,
185}
186
187#[derive(Clone, Deserialize, Eq, PartialEq, Serialize, ToSchema)]
189#[serde(deny_unknown_fields)]
190pub struct IntrospectProjectTokenRequest {
191 #[schema(min_length = 1, max_length = 16384, write_only)]
192 pub token: String,
193 #[schema(max_length = 96)]
194 pub expected_application_id: Option<String>,
195}
196
197impl fmt::Debug for IntrospectProjectTokenRequest {
198 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
199 formatter
200 .debug_struct("IntrospectProjectTokenRequest")
201 .field("token", &"[REDACTED]")
202 .field("expected_application_id", &self.expected_application_id)
203 .finish()
204 }
205}
206
207impl Drop for IntrospectProjectTokenRequest {
208 fn drop(&mut self) {
209 zeroize::Zeroize::zeroize(&mut self.token);
210 }
211}
212
213#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, ToSchema)]
215#[serde(deny_unknown_fields)]
216pub struct InactiveProjectToken {
217 #[serde(deserialize_with = "deserialize_active_false")]
218 #[schema(schema_with = false_schema)]
219 pub active: bool,
220}
221
222#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
224#[serde(deny_unknown_fields)]
225pub struct ActiveProjectToken {
226 #[serde(deserialize_with = "deserialize_active_true")]
227 #[schema(schema_with = true_schema)]
228 pub active: bool,
229 #[schema(max_length = 96)]
230 pub project_id: String,
231 #[schema(max_length = 96)]
232 pub application_id: String,
233 #[schema(max_length = 96)]
234 pub user_id: String,
235 #[schema(max_length = 64)]
236 pub session_id: String,
237 #[schema(max_length = 32)]
238 pub token_type: String,
239 #[schema(max_length = 64)]
240 pub issued_at: String,
241 #[schema(max_length = 64)]
242 pub expires_at: String,
243 #[schema(minimum = 1)]
244 pub user_revision: i64,
245 #[schema(minimum = 1)]
246 pub session_revision: i64,
247 #[schema(minimum = 1)]
248 pub application_revision: i64,
249 pub projection: ServerApplicationUserProjection,
250}
251
252#[allow(
254 clippy::large_enum_variant,
255 reason = "the public untagged HTTP union keeps its reviewed schema and avoids boxed wire models"
256)]
257#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)]
258#[serde(untagged)]
259pub enum ProjectTokenIntrospectionResponse {
260 Active(ActiveProjectToken),
261 Inactive(InactiveProjectToken),
262}
263
264#[utoipa::path(
265 get,
266 path = "/v1/projects/{project_id}/users",
267 params(
268 ("project_id" = String, Path, max_length = 96),
269 ("cursor" = Option<String>, Query, max_length = 64),
270 ("limit" = Option<usize>, Query, minimum = 1, maximum = 100)
271 ),
272 responses(
273 (status = 200, body = ServerUserList),
274 (status = 400, body = ServerError),
275 (status = 401, description = "Missing or invalid Project server key", body = ServerError, headers(("WWW-Authenticate" = String, description = "Required Bearer authentication challenge"))),
276 (status = 503, body = ServerError)
277 ),
278 security(("project_server_key" = []))
279)]
280#[doc(hidden)]
281pub fn list_project_users() {}
282
283#[utoipa::path(
284 post,
285 path = "/v1/projects/{project_id}/users/lookup",
286 params(("project_id" = String, Path, max_length = 96)),
287 request_body = LookupServerUserRequest,
288 responses(
289 (status = 200, body = LookupServerUserResponse),
290 (status = 400, body = ServerError),
291 (status = 401, description = "Missing or invalid Project server key", body = ServerError, headers(("WWW-Authenticate" = String, description = "Required Bearer authentication challenge"))),
292 (status = 503, body = ServerError)
293 ),
294 security(("project_server_key" = []))
295)]
296#[doc(hidden)]
297pub fn lookup_project_user() {}
298
299#[utoipa::path(
300 get,
301 path = "/v1/projects/{project_id}/users/{user_id}",
302 params(
303 ("project_id" = String, Path, max_length = 96),
304 ("user_id" = String, Path, max_length = 96)
305 ),
306 responses(
307 (status = 200, body = ServerUser),
308 (status = 400, body = ServerError),
309 (status = 401, description = "Missing or invalid Project server key", body = ServerError, headers(("WWW-Authenticate" = String, description = "Required Bearer authentication challenge"))),
310 (status = 404, body = ServerError),
311 (status = 503, body = ServerError)
312 ),
313 security(("project_server_key" = []))
314)]
315#[doc(hidden)]
316pub fn get_project_user() {}
317
318#[utoipa::path(
319 get,
320 path = "/v1/projects/{project_id}/applications/{application_id}/users/{user_id}",
321 params(
322 ("project_id" = String, Path, max_length = 96),
323 ("application_id" = String, Path, max_length = 96),
324 ("user_id" = String, Path, max_length = 96)
325 ),
326 responses(
327 (status = 200, body = ServerApplicationUserProjection),
328 (status = 400, body = ServerError),
329 (status = 401, description = "Missing or invalid Project server key", body = ServerError, headers(("WWW-Authenticate" = String, description = "Required Bearer authentication challenge"))),
330 (status = 404, body = ServerError),
331 (status = 409, body = ServerError),
332 (status = 503, body = ServerError)
333 ),
334 security(("project_server_key" = []))
335)]
336#[doc(hidden)]
337pub fn get_application_user_projection() {}
338
339#[utoipa::path(
340 post,
341 path = "/v1/projects/{project_id}/tokens/introspect",
342 params(("project_id" = String, Path, max_length = 96)),
343 request_body = IntrospectProjectTokenRequest,
344 responses(
345 (status = 200, body = ProjectTokenIntrospectionResponse),
346 (status = 400, body = ServerError),
347 (status = 401, description = "Missing or invalid Project server key", body = ServerError, headers(("WWW-Authenticate" = String, description = "Required Bearer authentication challenge"))),
348 (status = 503, body = ServerError)
349 ),
350 security(("project_server_key" = []))
351)]
352#[doc(hidden)]
353pub fn introspect_project_token() {}
354
355#[derive(OpenApi)]
356#[openapi(
357 info(
358 title = "OwlAuth Server API",
359 description = "Project-scoped customer backend Server API"
360 ),
361 paths(
362 crate::health::get_liveness,
363 crate::health::get_readiness,
364 list_project_users,
365 lookup_project_user,
366 get_project_user,
367 get_application_user_projection,
368 introspect_project_token
369 ),
370 components(schemas(
371 HealthResponse,
372 ServerErrorCode,
373 ServerError,
374 ServerUserStatus,
375 ServerUser,
376 ServerUserList,
377 LookupServerUserRequest,
378 LookupServerUserResponse,
379 ServerApplicationUserProjection,
380 IntrospectProjectTokenRequest,
381 InactiveProjectToken,
382 ActiveProjectToken,
383 ProjectTokenIntrospectionResponse
384 )),
385 modifiers(&ServerSecurity)
386)]
387struct ServerApiDoc;
388
389struct ServerSecurity;
390
391impl Modify for ServerSecurity {
392 fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
393 openapi
394 .components
395 .get_or_insert_default()
396 .add_security_scheme(
397 "project_server_key",
398 SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
399 );
400 }
401}
402
403#[must_use]
405pub fn openapi() -> utoipa::openapi::OpenApi {
406 let mut document = ServerApiDoc::openapi();
407 crate::add_response_to_operations(&mut document, "408", |_| {
408 crate::json_error_response(
409 "The request exceeded the Server listener time budget",
410 "ServerError",
411 "application/json",
412 )
413 });
414 document
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn secret_bearing_request_debug_is_redacted() {
423 let request = IntrospectProjectTokenRequest {
424 token: "secret-access-token".to_owned(),
425 expected_application_id: Some("app_1".to_owned()),
426 };
427 let debug = format!("{request:?}");
428 assert!(debug.contains("[REDACTED]"));
429 assert!(!debug.contains("secret-access-token"));
430 }
431
432 #[test]
433 fn request_and_response_models_reject_unknown_fields() {
434 assert!(
435 serde_json::from_value::<LookupServerUserRequest>(serde_json::json!({
436 "email": "person@example.com",
437 "prefix": true
438 }))
439 .is_err()
440 );
441 assert!(
442 serde_json::from_value::<InactiveProjectToken>(serde_json::json!({
443 "active": false,
444 "reason": "revoked"
445 }))
446 .is_err()
447 );
448 assert!(
449 serde_json::from_value::<InactiveProjectToken>(serde_json::json!({"active": true}))
450 .is_err()
451 );
452 let mut active = serde_json::json!({
453 "active": false,
454 "project_id": "project",
455 "application_id": "application",
456 "user_id": "user",
457 "session_id": "00000000-0000-0000-0000-000000000001",
458 "token_type": "Bearer",
459 "issued_at": "2026-01-01T00:00:00Z",
460 "expires_at": "2026-01-01T01:00:00Z",
461 "user_revision": 1,
462 "session_revision": 1,
463 "application_revision": 1,
464 "projection": {
465 "project_id": "project",
466 "application_id": "application",
467 "user_id": "user",
468 "projection_schema": "owlauth.user.v1",
469 "user_revision": 1,
470 "projection_revision": 1,
471 "display_name": null,
472 "picture_url": null,
473 "locale": null,
474 "verified_email": null,
475 "status": "active",
476 "created_at": "2026-01-01T00:00:00Z",
477 "updated_at": "2026-01-01T00:00:00Z"
478 }
479 });
480 assert!(serde_json::from_value::<ActiveProjectToken>(active.clone()).is_err());
481 active["active"] = serde_json::json!(true);
482 assert!(serde_json::from_value::<ActiveProjectToken>(active).is_ok());
483 }
484}