Skip to main content

owlauth_types/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Stable public HTTP contracts for `OwlAuth`'s isolated Runtime, Server, and Control planes.
4
5pub mod control;
6mod control_resources;
7pub mod export;
8pub mod health;
9pub mod runtime;
10pub mod server;
11
12pub use health::HealthResponse;
13
14use utoipa::openapi::{Content, OpenApi, Ref, RefOr, Response};
15
16/// Compile-time availability of the complete federated Project Auth surface.
17pub const FEDERATED_PROJECT_AUTH_AVAILABLE: bool = true;
18
19pub(crate) fn json_error_response(
20    description: &str,
21    schema_name: &str,
22    content_type: &str,
23) -> RefOr<Response> {
24    Response::builder()
25        .description(description)
26        .content(
27            content_type,
28            Content::new(Some(Ref::from_schema_name(schema_name))),
29        )
30        .build()
31        .into()
32}
33
34pub(crate) fn add_response_to_operations(
35    openapi: &mut OpenApi,
36    status: &str,
37    mut response_for_path: impl FnMut(&str) -> RefOr<Response>,
38) {
39    for (path, item) in &mut openapi.paths.paths {
40        let response = response_for_path(path);
41        for operation in [
42            item.get.as_mut(),
43            item.put.as_mut(),
44            item.post.as_mut(),
45            item.delete.as_mut(),
46            item.options.as_mut(),
47            item.head.as_mut(),
48            item.patch.as_mut(),
49            item.trace.as_mut(),
50        ]
51        .into_iter()
52        .flatten()
53        {
54            operation
55                .responses
56                .responses
57                .insert(status.to_owned(), response.clone());
58        }
59    }
60}
61
62#[cfg(test)]
63mod tests {
64    use serde_json::Value;
65
66    use crate::{control, export, runtime, server};
67
68    #[test]
69    fn every_listener_operation_declares_its_request_timeout_envelope() {
70        let documents = [
71            (
72                serde_json::to_value(runtime::openapi()).unwrap(),
73                "RuntimeError",
74                "application/json",
75            ),
76            (
77                serde_json::to_value(server::openapi()).unwrap(),
78                "ServerError",
79                "application/json",
80            ),
81            (
82                serde_json::to_value(control::openapi()).unwrap(),
83                "ProblemDetails",
84                "application/problem+json",
85            ),
86        ];
87        for (document, schema, content_type) in documents {
88            for (path, item) in document["paths"].as_object().unwrap() {
89                for method in [
90                    "get", "put", "post", "delete", "options", "head", "patch", "trace",
91                ] {
92                    let Some(operation) = item.get(method) else {
93                        continue;
94                    };
95                    let timeout = &operation["responses"]["408"];
96                    assert!(
97                        timeout.is_object(),
98                        "missing 408 response for {method} {path}"
99                    );
100                    if schema == "RuntimeError" && path.starts_with("/auth/") {
101                        assert!(timeout["content"]["text/html"].is_object());
102                    } else {
103                        assert_eq!(
104                            timeout["content"][content_type]["schema"]["$ref"],
105                            format!("#/components/schemas/{schema}")
106                        );
107                    }
108                }
109            }
110        }
111    }
112
113    #[test]
114    #[allow(
115        clippy::too_many_lines,
116        reason = "one contract test keeps complete cross-plane path and component isolation visible"
117    )]
118    fn generated_documents_are_plane_pure_and_versioned() {
119        let runtime =
120            serde_json::to_value(runtime::openapi()).expect("Runtime OpenAPI should serialize");
121        let server =
122            serde_json::to_value(server::openapi()).expect("Server OpenAPI should serialize");
123        let control =
124            serde_json::to_value(control::openapi()).expect("Control OpenAPI should serialize");
125
126        assert_eq!(runtime["info"]["version"], env!("CARGO_PKG_VERSION"));
127        assert_eq!(server["info"]["version"], env!("CARGO_PKG_VERSION"));
128        assert_eq!(control["info"]["version"], env!("CARGO_PKG_VERSION"));
129        assert!(runtime["paths"]["/health"].is_object());
130        assert!(runtime["paths"]["/ready"].is_object());
131        assert!(runtime["paths"].get("/v1/system").is_none());
132        assert!(runtime["paths"]["/v1/projects/{project_public_id}/auth/config"].is_object());
133        assert!(
134            runtime["paths"]["/projects/{project_public_id}/.well-known/jwks.json"].is_object()
135        );
136        for required in [
137            "/v1/projects/{project_public_id}/auth/login/start",
138            "/auth/interactions/{interaction}",
139            "/v1/projects/{project_public_id}/auth/interactions/{interaction}/method",
140            "/v1/projects/{project_public_id}/auth/interactions/{interaction}/session/reuse",
141            "/projects/{project_public_id}/auth/callback/{provider_key}",
142            "/v1/projects/{project_public_id}/auth/handoff/exchange",
143            "/v1/projects/{project_public_id}/auth/sessions/refresh",
144            "/v1/projects/{project_public_id}/auth/users/me",
145            "/v1/projects/{project_public_id}/auth/sessions/logout",
146            "/v1/projects/{project_public_id}/auth/browser-logout/prepare",
147            "/auth/browser-logout/{preparation}",
148            "/v1/projects/{project_public_id}/auth/browser-logout/{preparation}/confirm",
149        ] {
150            assert!(
151                runtime["paths"][required].is_object(),
152                "federated authentication path is missing from Runtime OpenAPI: {required}"
153            );
154        }
155        assert!(runtime["paths"].get("/v1/projects").is_none());
156        for forbidden_schema in [
157            "CreateProviderRequest",
158            "NamedProviderPreflightRequest",
159            "OidcPreflightRequest",
160        ] {
161            assert!(
162                runtime["components"]["schemas"]
163                    .get(forbidden_schema)
164                    .is_none()
165            );
166        }
167        assert!(
168            runtime["components"]["schemas"]
169                .get("IntrospectProjectTokenRequest")
170                .is_none()
171        );
172
173        for path in [
174            "/health",
175            "/ready",
176            "/v1/projects/{project_id}/users",
177            "/v1/projects/{project_id}/users/lookup",
178            "/v1/projects/{project_id}/users/{user_id}",
179            "/v1/projects/{project_id}/applications/{application_id}/users/{user_id}",
180            "/v1/projects/{project_id}/tokens/introspect",
181        ] {
182            assert!(
183                server["paths"][path].is_object(),
184                "missing Server path: {path}"
185            );
186        }
187        assert!(server["components"]["securitySchemes"]["project_server_key"].is_object());
188        assert!(server["paths"].get("/v1/system").is_none());
189        assert!(
190            server["paths"]
191                .get("/v1/projects/{project_public_id}/auth/config")
192                .is_none()
193        );
194        for forbidden_schema in [
195            "CreateProviderRequest",
196            "NamedProviderPreflightRequest",
197            "OidcPreflightRequest",
198            "PublicApplicationConfig",
199            "RefreshSessionRequest",
200            "ProjectServerKey",
201        ] {
202            assert!(
203                server["components"]["schemas"]
204                    .get(forbidden_schema)
205                    .is_none(),
206                "Server OpenAPI leaked {forbidden_schema}"
207            );
208        }
209
210        assert!(control["paths"]["/v1/system"].is_object());
211        let capabilities = &control["components"]["schemas"]["SystemCapabilities"];
212        assert!(capabilities["properties"].get("project_auth").is_none());
213        assert!(capabilities["properties"]["provisioning"].is_object());
214        assert!(capabilities["properties"]["login_readiness"].is_object());
215        assert!(capabilities["properties"]["federated_project_auth"].is_object());
216        let advertised = control::get_system();
217        assert!(advertised.provisioning);
218        assert!(advertised.login_readiness);
219        assert!(advertised.federated_project_auth);
220        assert!(control["paths"]["/v1/projects"].is_object());
221        let signing_key_collection = &control["paths"]["/v1/projects/{project_id}/signing-keys"];
222        assert!(signing_key_collection["get"].is_object());
223        assert!(signing_key_collection.get("post").is_none());
224        assert!(
225            control["paths"]["/v1/projects/{project_id}/signing-keys/rotate"]["post"].is_object()
226        );
227        for removed_path in [
228            "/v1/projects/{project_id}/signing-keys/{key_id}/reconcile",
229            "/v1/projects/{project_id}/signing-keys/{key_id}/activate",
230            "/v1/projects/{project_id}/signing-keys/{key_id}/retire",
231        ] {
232            assert!(
233                control["paths"].get(removed_path).is_none(),
234                "removed signing-key path leaked into Control OpenAPI: {removed_path}"
235            );
236        }
237        assert!(
238            control["paths"]["/v1/projects/{project_id}/providers/{provider_id}/reconcile"]
239                .is_object()
240        );
241        assert!(
242            control["paths"]
243                .get("/v1/projects/{project_public_id}/auth/config")
244                .is_none()
245        );
246        assert!(
247            control["paths"]
248                .get("/projects/{project_public_id}/.well-known/jwks.json")
249                .is_none()
250        );
251        assert!(control["components"]["securitySchemes"]["operator_api_key"].is_object());
252        for (path, request_schema) in [
253            (
254                "/v1/projects/{project_id}/providers/oidc/preflight",
255                "OidcPreflightRequest",
256            ),
257            (
258                "/v1/projects/{project_id}/providers/named/preflight",
259                "NamedProviderPreflightRequest",
260            ),
261        ] {
262            assert!(control["paths"][path]["post"].is_object());
263            let properties = &control["components"]["schemas"][request_schema]["properties"];
264            assert!(properties["provider_key"].is_object());
265            assert!(properties.get("client_secret").is_none());
266            assert!(properties.get("callback_url").is_none());
267        }
268        for result_schema in ["OidcPreflightResult", "NamedProviderPreflightResult"] {
269            let properties = &control["components"]["schemas"][result_schema]["properties"];
270            assert!(properties["callback_url"].is_object());
271            assert!(properties["callback_guidance"].is_object());
272        }
273        assert_eq!(
274            control["components"]["schemas"]["CreateProviderRequest"]["properties"]["client_secret"]
275                ["writeOnly"],
276            true
277        );
278        assert!(
279            control["components"]["schemas"]["CreateProviderRequest"]["required"]
280                .as_array()
281                .is_some_and(|required| required.iter().any(|field| field == "kind"))
282        );
283        for schema in [
284            "ReconcileProviderRequest",
285            "ReplaceProviderSecretRequest",
286            "ReconcileProviderSecretReplacementRequest",
287        ] {
288            assert_eq!(
289                control["components"]["schemas"][schema]["properties"]["client_secret"]["writeOnly"],
290                true
291            );
292        }
293        assert!(
294            control["components"]["schemas"]["UpdateProviderRequest"]["properties"]
295                .get("client_secret")
296                .is_none()
297        );
298    }
299
300    #[test]
301    #[allow(
302        clippy::too_many_lines,
303        reason = "the exact Control operation and exceptional-response inventory is reviewed as one protocol matrix"
304    )]
305    fn control_operation_inventory_and_exceptional_responses_are_exact() {
306        let control = serde_json::to_value(control::openapi()).expect("Control OpenAPI serializes");
307        for (path, methods) in [
308            ("/v1/projects", &["get", "post"][..]),
309            ("/v1/projects/{project_id}", &["get", "patch"]),
310            ("/v1/projects/{project_id}/overview", &["get"]),
311            ("/v1/projects/{project_id}/applications", &["get", "post"]),
312            (
313                "/v1/projects/{project_id}/applications/{application_id}",
314                &["get", "patch"],
315            ),
316            (
317                "/v1/projects/{project_id}/applications/{application_id}/webhook-endpoints",
318                &["get", "post"],
319            ),
320            (
321                "/v1/projects/{project_id}/applications/{application_id}/webhook-endpoints/{endpoint_id}",
322                &["get", "put"],
323            ),
324            ("/v1/projects/{project_id}/server-keys", &["get", "post"]),
325            ("/v1/projects/{project_id}/email-method", &["get", "put"]),
326            ("/v1/projects/{project_id}/policy", &["get", "put"]),
327            (
328                "/v1/projects/{project_id}/provider-egress-policy",
329                &["get", "put"],
330            ),
331            ("/v1/projects/{project_id}/providers", &["get", "post"]),
332            (
333                "/v1/projects/{project_id}/providers/{provider_id}",
334                &["patch"],
335            ),
336            (
337                "/v1/projects/{project_id}/providers/{provider_id}/replace-secret",
338                &["post"],
339            ),
340            (
341                "/v1/projects/{project_id}/providers/{provider_id}/replace-secret/reconcile",
342                &["post"],
343            ),
344            (
345                "/v1/projects/{project_id}/providers/{provider_id}/replace-secret/abandon",
346                &["post"],
347            ),
348            (
349                "/v1/projects/{project_id}/providers/{provider_id}/assignments/{application_id}",
350                &["put"],
351            ),
352            (
353                "/v1/projects/{project_id}/smtp-configurations",
354                &["get", "post"],
355            ),
356            ("/v1/system/smtp-default-generations", &["get", "post"]),
357        ] {
358            let path_item = &control["paths"][path];
359            for method in methods {
360                assert!(
361                    path_item[method].is_object(),
362                    "missing Control operation {method} {path}"
363                );
364            }
365        }
366
367        for path in [
368            "/v1/projects/{project_id}/providers/oidc/preflight",
369            "/v1/projects/{project_id}/providers/named/preflight",
370        ] {
371            assert!(control["paths"][path]["post"]["responses"]["422"].is_object());
372        }
373        let smtp_test = &control["paths"]["/v1/projects/{project_id}/smtp-configurations/{smtp_id}/test"]
374            ["post"];
375        assert!(smtp_test["responses"].get("200").is_none());
376        let accepted = &smtp_test["responses"]["202"];
377        assert_eq!(accepted["headers"]["Location"]["schema"]["type"], "string");
378        assert!(
379            accepted["headers"]["Location"]["description"]
380                .as_str()
381                .is_some_and(|description| description.contains("Exact Control path"))
382        );
383
384        for path in [
385            "/v1/projects/{project_id}/users/{user_id}/managed-provider-connections/{connection_id}/reauthorizations",
386            "/v1/projects/{project_id}/identity-mutation-intents",
387        ] {
388            let responses = &control["paths"][path]["post"]["responses"];
389            assert!(responses["200"].is_object(), "{path} must describe replay");
390            assert_eq!(
391                responses["201"]["headers"]["Location"]["schema"]["type"],
392                "string"
393            );
394        }
395        assert_eq!(
396            control["paths"]["/v1/projects/{project_id}/server-keys"]["post"]["responses"]["201"]["headers"]
397                ["Location"]["schema"]["type"],
398            "string"
399        );
400        assert!(
401            control["paths"]["/v1/projects/{project_id}/applications/{application_id}/webhook-deliveries/{delivery_id}"]["get"].is_object()
402        );
403        let replay = &control["paths"]["/v1/projects/{project_id}/applications/{application_id}/webhook-deliveries/{delivery_id}/replay"]
404            ["post"]["responses"];
405        assert!(replay.get("200").is_none());
406        assert_eq!(
407            replay["201"]["headers"]["Location"]["schema"]["type"],
408            "string"
409        );
410    }
411
412    #[test]
413    fn project_overview_contract_is_grouped_required_and_control_only() {
414        let control = serde_json::to_value(control::openapi()).expect("Control OpenAPI serializes");
415        let runtime = serde_json::to_value(runtime::openapi()).expect("Runtime OpenAPI serializes");
416        let server = serde_json::to_value(server::openapi()).expect("Server OpenAPI serializes");
417        let path = "/v1/projects/{project_id}/overview";
418        assert_eq!(
419            control["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
420                ["$ref"],
421            "#/components/schemas/ProjectOverviewSummary"
422        );
423        assert!(runtime["paths"].get(path).is_none());
424        assert!(server["paths"].get(path).is_none());
425        let summary = &control["components"]["schemas"]["ProjectOverviewSummary"];
426        let summary_required = summary["required"]
427            .as_array()
428            .expect("overview groups are required")
429            .iter()
430            .filter_map(serde_json::Value::as_str)
431            .collect::<std::collections::BTreeSet<_>>();
432        assert_eq!(
433            summary_required,
434            [
435                "applications",
436                "project_id",
437                "project_server_keys",
438                "providers",
439                "users",
440            ]
441            .into_iter()
442            .collect()
443        );
444
445        for (schema, fields) in [
446            (
447                "ProjectOverviewApplicationCounts",
448                &["active", "configured", "total"][..],
449            ),
450            (
451                "ProjectOverviewProviderCounts",
452                &["active", "active_assignments", "total"],
453            ),
454            (
455                "ProjectOverviewUserCounts",
456                &["active", "disabled", "merged", "total"],
457            ),
458            (
459                "ProjectOverviewServerKeyCounts",
460                &["active", "revoked", "total"],
461            ),
462        ] {
463            let definition = &control["components"]["schemas"][schema];
464            let required = definition["required"]
465                .as_array()
466                .expect("overview count fields are required")
467                .iter()
468                .filter_map(serde_json::Value::as_str)
469                .collect::<std::collections::BTreeSet<_>>();
470            assert_eq!(required, fields.iter().copied().collect());
471            for field in fields {
472                assert_eq!(definition["properties"][field]["minimum"], 0);
473            }
474        }
475    }
476
477    #[test]
478    fn runtime_pending_email_and_hosted_document_contracts_are_minimal() {
479        let runtime = serde_json::to_value(runtime::openapi()).expect("Runtime OpenAPI serializes");
480        let pending = &runtime["components"]["schemas"]["HostedPendingEmailChallenge"];
481        let properties = pending["properties"]
482            .as_object()
483            .expect("pending properties");
484        assert_eq!(
485            properties
486                .keys()
487                .cloned()
488                .collect::<std::collections::BTreeSet<_>>(),
489            ["challenge_id", "expires_at", "generation", "proof_modes"]
490                .into_iter()
491                .map(str::to_owned)
492                .collect()
493        );
494        for forbidden in [
495            "email",
496            "address",
497            "account",
498            "delivery",
499            "otp",
500            "magic_proof",
501            "smtp",
502        ] {
503            assert!(properties.get(forbidden).is_none());
504        }
505        assert!(
506            runtime["components"]["schemas"]["HostedInteractionResponse"]["properties"]
507                ["pending_email_challenge"]
508                .is_object()
509        );
510        for path in [
511            "/auth/interactions/{interaction}",
512            "/auth/email/confirm/{challenge_id}",
513            "/auth/browser-logout/{preparation}",
514            "/auth/managed-reauthorizations/{interaction}",
515            "/auth/identity-mutations/{intent}",
516            "/auth/identity-mutations/email/confirm/{challenge_id}",
517        ] {
518            assert_eq!(
519                runtime["paths"][path]["get"]["responses"]["200"]["content"]["text/html"]["schema"]
520                    ["type"],
521                "string",
522                "Hosted document must declare text/html for {path}"
523            );
524        }
525        for excluded in ["/auth/", "/auth/assets/{asset}"] {
526            assert!(runtime["paths"].get(excluded).is_none());
527        }
528    }
529
530    #[test]
531    fn server_auth_projection_and_introspection_contract_is_exact() {
532        let server: Value = serde_json::from_str(
533            &export::to_pretty_json(export::OpenApiPlane::Server)
534                .expect("Server OpenAPI should serialize"),
535        )
536        .expect("exported Server OpenAPI should be JSON");
537        let operations = [
538            ("/v1/projects/{project_id}/users", "get"),
539            ("/v1/projects/{project_id}/users/lookup", "post"),
540            ("/v1/projects/{project_id}/users/{user_id}", "get"),
541            (
542                "/v1/projects/{project_id}/applications/{application_id}/users/{user_id}",
543                "get",
544            ),
545            ("/v1/projects/{project_id}/tokens/introspect", "post"),
546        ];
547        for (path, method) in operations {
548            let challenge =
549                &server["paths"][path][method]["responses"]["401"]["headers"]["WWW-Authenticate"];
550            assert_eq!(challenge["required"], true, "{method} {path}");
551            assert_eq!(challenge["schema"]["type"], "string", "{method} {path}");
552        }
553
554        let schemas = &server["components"]["schemas"];
555        assert_eq!(
556            schemas["InactiveProjectToken"]["properties"]["active"]["const"],
557            false
558        );
559        assert_eq!(
560            schemas["ActiveProjectToken"]["properties"]["active"]["const"],
561            true
562        );
563        assert!(
564            schemas["ServerApplicationUserProjection"]["required"]
565                .as_array()
566                .is_some_and(|required| required.iter().any(|field| field == "user_revision"))
567        );
568        assert_eq!(
569            schemas["ServerApplicationUserProjection"]["properties"]["user_revision"]["minimum"],
570            1
571        );
572        assert!(
573            schemas["ServerUserList"]["required"]
574                .as_array()
575                .is_some_and(|required| required.iter().any(|field| field == "next_cursor"))
576        );
577        assert_eq!(
578            schemas["ServerUserList"]["properties"]["next_cursor"]["maxLength"],
579            64
580        );
581
582        let parameters = server["paths"]["/v1/projects/{project_id}/users"]["get"]["parameters"]
583            .as_array()
584            .expect("Server list parameters");
585        let parameter = |name: &str| {
586            parameters
587                .iter()
588                .find(|parameter| parameter["name"] == name)
589                .unwrap_or_else(|| panic!("missing Server parameter {name}"))
590        };
591        assert_eq!(parameter("project_id")["schema"]["maxLength"], 96);
592        assert_eq!(parameter("cursor")["schema"]["maxLength"], 64);
593    }
594
595    #[test]
596    fn public_contracts_are_bounded_and_reject_private_jwk_members() {
597        let runtime =
598            serde_json::to_value(runtime::openapi()).expect("Runtime OpenAPI should serialize");
599        let control =
600            serde_json::to_value(control::openapi()).expect("Control OpenAPI should serialize");
601
602        assert_eq!(
603            control["components"]["schemas"]["ApplicationType"]["enum"],
604            serde_json::json!(["web", "native"])
605        );
606        assert_eq!(
607            control["components"]["schemas"]["UpdateProjectPolicyRequest"]["properties"]["access_token_lifetime_seconds"]
608                ["minimum"],
609            60
610        );
611        assert_eq!(
612            runtime["components"]["schemas"]["PublicApplicationConfig"]["properties"]["providers"]
613                ["maxItems"],
614            50
615        );
616        assert!(
617            serde_json::from_value::<runtime::PublicJwk>(serde_json::json!({
618                "kty": "OKP",
619                "crv": "Ed25519",
620                "alg": "EdDSA",
621                "use": "sig",
622                "kid": "key-1",
623                "x": "public-value",
624                "d": "private-value"
625            }))
626            .is_err()
627        );
628    }
629
630    #[test]
631    fn separate_exports_are_deterministic() {
632        for plane in [
633            export::OpenApiPlane::Runtime,
634            export::OpenApiPlane::Server,
635            export::OpenApiPlane::Control,
636        ] {
637            let first = export::to_pretty_json(plane).expect("OpenAPI should serialize");
638            let second = export::to_pretty_json(plane).expect("OpenAPI should serialize");
639            assert_eq!(first, second);
640
641            let parsed: Value = serde_json::from_str(&first).expect("OpenAPI should be JSON");
642            assert_eq!(parsed["openapi"], "3.1.0");
643        }
644    }
645}