Skip to main content

owlauth_types/
server.rs

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/// Stable Server API error codes. Authentication failures intentionally collapse to one code.
57#[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/// Complete JSON error envelope for the isolated Server API.
70#[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/// Project-owned user read model exposed to one authenticated customer backend.
89#[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/// One deterministic keyset page ordered by immutable `(created_at, user_id)`.
115#[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/// Exact normalized-email lookup body. Email is never carried in a URL.
126#[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/// Non-enumerating zero-or-one result inside the already authenticated Project.
134#[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/// Existing materialized Application projection; Server never performs an ad hoc reprojection.
152#[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/// Bounded online introspection input. The token is write-only and always redacted in `Debug`.
188#[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/// Exact inactive introspection response. No denial reason or resource identity is disclosed.
214#[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/// Current online authority for one active Project access token.
223#[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/// Non-enumerating introspection union. Invalid or inactive authority is always the inactive arm.
253#[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/// Generates the complete Server-plane `OpenAPI` document.
404#[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}