From 68611d803a67d9cc32c515b25484ebb73cc0a4ad Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 11:03:15 +0200 Subject: [PATCH 01/35] refactor(rest): separate service model and parser --- crates/rest/ras-rest-macro/src/ast.rs | 129 ++++ crates/rest/ras-rest-macro/src/lib.rs | 657 +----------------- crates/rest/ras-rest-macro/src/parser.rs | 509 ++++++++++++++ .../reviews/comments-and-boundaries.md | 139 ++++ documentation/reviews/refactor-plan.md | 201 ++++++ documentation/reviews/refactor-progress.md | 17 + 6 files changed, 1012 insertions(+), 640 deletions(-) create mode 100644 crates/rest/ras-rest-macro/src/ast.rs create mode 100644 crates/rest/ras-rest-macro/src/parser.rs create mode 100644 documentation/reviews/comments-and-boundaries.md create mode 100644 documentation/reviews/refactor-plan.md create mode 100644 documentation/reviews/refactor-progress.md diff --git a/crates/rest/ras-rest-macro/src/ast.rs b/crates/rest/ras-rest-macro/src/ast.rs new file mode 100644 index 0000000..ec4fc95 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/ast.rs @@ -0,0 +1,129 @@ +//! Parsed REST service contract shared by the parser and emitters. + +use crate::static_hosting; +use syn::{Ident, Type}; + +#[derive(Debug)] +pub(crate) struct ServiceDefinition { + pub(crate) service_name: Ident, + pub(crate) base_path: String, + pub(crate) openapi: Option, + pub(crate) static_hosting: static_hosting::StaticHostingConfig, + pub(crate) body_limit: Option, + pub(crate) feature_gated: bool, + /// Require an `application/json` request `Content-Type` on every endpoint + /// that declares a body. Defaults to `true`. Set `require_json_content_type: + /// false` to opt out (e.g. for clients that cannot set the header). + pub(crate) require_json_content_type: bool, + /// Gate the generated docs page and `openapi.json` behind authentication + /// (any authenticated user). Defaults to `false` — docs are public when + /// `serve_docs` is enabled, matching conventional API-explorer behavior. + pub(crate) docs_require_auth: bool, + pub(crate) endpoints: Vec, +} + +/// Default maximum JSON body size in bytes (matches axum's default). +pub(crate) const DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024; + +#[derive(Debug)] +pub(crate) enum OpenApiConfig { + Enabled, + WithPath(String), +} + +#[derive(Debug)] +pub(crate) struct EndpointDefinition { + pub(crate) docs: Option, + pub(crate) method: HttpMethod, + pub(crate) auth: AuthRequirement, + pub(crate) path: String, + pub(crate) path_params: Vec, + pub(crate) query_params: Vec, + pub(crate) request_type: Option, + pub(crate) response_type: Type, + pub(crate) handler_name: Ident, + pub(crate) version: Option, + pub(crate) versions: Vec, + /// Per-endpoint request body size cap (bytes). Overrides the service-level + /// `body_limit` for this endpoint when set. + pub(crate) body_limit: Option, + /// When `true`, the handler receives the request `HeaderMap` as an extra + /// parameter (immediately after the caller/user, before path params). + pub(crate) with_headers: bool, +} + +#[derive(Debug)] +pub(crate) struct EndpointVersionDefinition { + pub(crate) version: String, + pub(crate) path: String, + pub(crate) path_params: Vec, + pub(crate) query_params: Vec, + pub(crate) request_type: Option, + pub(crate) response_type: Type, + pub(crate) migration_type: Type, +} + +#[derive(Debug)] +pub(crate) struct DocComment { + pub(crate) summary: String, + pub(crate) description: String, +} + +impl DocComment { + pub(crate) fn from_lines(lines: Vec) -> Option { + let lines: Vec = lines + .into_iter() + .map(|line| line.trim().to_string()) + .collect(); + let start = lines.iter().position(|line| !line.is_empty())?; + let end = lines.iter().rposition(|line| !line.is_empty())?; + let lines = &lines[start..=end]; + + Some(Self { + summary: lines[0].clone(), + description: lines.join("\n"), + }) + } +} + +#[derive(Debug, Clone)] +pub(crate) enum HttpMethod { + Get, + Post, + Put, + Delete, + Patch, +} + +impl HttpMethod { + pub(crate) fn as_str(&self) -> &'static str { + match self { + HttpMethod::Get => "GET", + HttpMethod::Post => "POST", + HttpMethod::Put => "PUT", + HttpMethod::Delete => "DELETE", + HttpMethod::Patch => "PATCH", + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct PathParam { + pub(crate) name: Ident, + pub(crate) param_type: Type, +} + +#[derive(Debug, Clone)] +pub(crate) struct QueryParam { + pub(crate) name: Ident, + pub(crate) param_type: Type, +} + +#[derive(Debug)] +pub(crate) enum AuthRequirement { + Unauthorized, + /// Public route that opportunistically identifies its caller. Never rejected + /// for auth reasons; the handler receives a `ras_auth_core::Caller`. + OptionalAuth, + WithPermissions(Vec>), // Vec of permission groups - OR between groups, AND within groups +} diff --git a/crates/rest/ras-rest-macro/src/lib.rs b/crates/rest/ras-rest-macro/src/lib.rs index b190874..fdc78b2 100644 --- a/crates/rest/ras-rest-macro/src/lib.rs +++ b/crates/rest/ras-rest-macro/src/lib.rs @@ -1,6 +1,10 @@ +use ast::*; use proc_macro::TokenStream; use quote::{format_ident, quote}; -use syn::{Ident, LitStr, Token, Type, parse::Parse, parse_macro_input}; +use syn::{Ident, Type, parse_macro_input}; + +mod ast; +mod parser; mod client; mod openapi; @@ -138,645 +142,6 @@ pub fn rest_service(input: TokenStream) -> TokenStream { } } -#[derive(Debug)] -struct ServiceDefinition { - service_name: Ident, - base_path: String, - openapi: Option, - static_hosting: static_hosting::StaticHostingConfig, - body_limit: Option, - feature_gated: bool, - /// Require an `application/json` request `Content-Type` on every endpoint - /// that declares a body. Defaults to `true`. Set `require_json_content_type: - /// false` to opt out (e.g. for clients that cannot set the header). - require_json_content_type: bool, - /// Gate the generated docs page and `openapi.json` behind authentication - /// (any authenticated user). Defaults to `false` — docs are public when - /// `serve_docs` is enabled, matching conventional API-explorer behavior. - docs_require_auth: bool, - endpoints: Vec, -} - -/// Default maximum JSON body size in bytes (matches axum's default). -const DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024; - -#[derive(Debug)] -enum OpenApiConfig { - Enabled, - WithPath(String), -} - -#[derive(Debug)] -struct EndpointDefinition { - docs: Option, - method: HttpMethod, - auth: AuthRequirement, - path: String, - path_params: Vec, - query_params: Vec, - request_type: Option, - response_type: Type, - handler_name: Ident, - version: Option, - versions: Vec, - /// Per-endpoint request body size cap (bytes). Overrides the service-level - /// `body_limit` for this endpoint when set. - body_limit: Option, - /// When `true`, the handler receives the request `HeaderMap` as an extra - /// parameter (immediately after the caller/user, before path params). - with_headers: bool, -} - -#[derive(Debug)] -struct EndpointVersionDefinition { - version: String, - path: String, - path_params: Vec, - query_params: Vec, - request_type: Option, - response_type: Type, - migration_type: Type, -} - -#[derive(Debug)] -struct DocComment { - summary: String, - description: String, -} - -impl DocComment { - fn from_lines(lines: Vec) -> Option { - let lines: Vec = lines - .into_iter() - .map(|line| line.trim().to_string()) - .collect(); - let start = lines.iter().position(|line| !line.is_empty())?; - let end = lines.iter().rposition(|line| !line.is_empty())?; - let lines = &lines[start..=end]; - - Some(Self { - summary: lines[0].clone(), - description: lines.join("\n"), - }) - } -} - -#[derive(Debug, Clone)] -enum HttpMethod { - Get, - Post, - Put, - Delete, - Patch, -} - -impl HttpMethod { - fn as_axum_method(&self) -> proc_macro2::TokenStream { - match self { - HttpMethod::Get => quote! { axum::routing::get }, - HttpMethod::Post => quote! { axum::routing::post }, - HttpMethod::Put => quote! { axum::routing::put }, - HttpMethod::Delete => quote! { axum::routing::delete }, - HttpMethod::Patch => quote! { axum::routing::patch }, - } - } - - fn as_str(&self) -> &'static str { - match self { - HttpMethod::Get => "GET", - HttpMethod::Post => "POST", - HttpMethod::Put => "PUT", - HttpMethod::Delete => "DELETE", - HttpMethod::Patch => "PATCH", - } - } -} - -#[derive(Debug, Clone)] -struct PathParam { - name: Ident, - param_type: Type, -} - -#[derive(Debug, Clone)] -struct QueryParam { - name: Ident, - param_type: Type, -} - -#[derive(Debug)] -enum AuthRequirement { - Unauthorized, - /// Public route that opportunistically identifies its caller. Never rejected - /// for auth reasons; the handler receives a `ras_auth_core::Caller`. - OptionalAuth, - WithPermissions(Vec>), // Vec of permission groups - OR between groups, AND within groups -} - -const DOC_COMMENT_EXPECTED: &str = "Expected doc comment in the form `/// ...`"; - -fn parse_label(input: syn::parse::ParseStream) -> syn::Result { - if input.peek(LitStr) { - Ok(input.parse::()?.value()) - } else { - Ok(input.parse::()?.to_string()) - } -} - -fn parse_doc_comment_attrs( - attrs: Vec, - entry_kind: &str, -) -> syn::Result> { - let lines = attrs - .into_iter() - .map(|attr| parse_doc_comment_attr(attr, entry_kind)) - .collect::>>()?; - - Ok(DocComment::from_lines(lines)) -} - -fn parse_doc_comment_attr(attr: syn::Attribute, entry_kind: &str) -> syn::Result { - if !attr.path().is_ident("doc") { - return Err(syn::Error::new_spanned( - attr, - format!("Only doc comments (`/// ...`) are supported before {entry_kind} definitions"), - )); - } - - if let syn::Meta::NameValue(name_value) = &attr.meta - && let syn::Expr::Lit(expr_lit) = &name_value.value - && let syn::Lit::Str(doc_line) = &expr_lit.lit - { - return Ok(doc_line.value()); - } - - Err(syn::Error::new_spanned(attr, DOC_COMMENT_EXPECTED)) -} - -impl Parse for ServiceDefinition { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let content; - syn::braced!(content in input); - - let _ = content.parse::()?; // "service_name" - let _ = content.parse::()?; - let service_name = content.parse::()?; - let _ = content.parse::()?; - - let _ = content.parse::()?; // "base_path" - let _ = content.parse::()?; - let base_path_lit = content.parse::()?; - let base_path = base_path_lit.value(); - let _ = content.parse::()?; - - let mut openapi = None; - let mut static_hosting = static_hosting::StaticHostingConfig::default(); - let mut body_limit = None; - let mut feature_gated = false; - let mut require_json_content_type = true; - let mut docs_require_auth = false; - - while content.peek(Ident) { - let field_name = content.fork().parse::()?; - - if field_name == "openapi" { - let _ = content.parse::()?; // "openapi" - let _ = content.parse::()?; - - if content.peek(syn::LitBool) { - let enabled = content.parse::()?; - if enabled.value() { - openapi = Some(OpenApiConfig::Enabled); - } - } else if content.peek(syn::token::Brace) { - let openapi_content; - syn::braced!(openapi_content in content); - - let _ = openapi_content.parse::()?; // "output" - let _ = openapi_content.parse::()?; - let path = openapi_content.parse::()?; - openapi = Some(OpenApiConfig::WithPath(path.value())); - } - - let _ = content.parse::()?; - } else if field_name == "serve_docs" { - let _ = content.parse::()?; // "serve_docs" - let _ = content.parse::()?; - let enabled = content.parse::()?; - static_hosting.serve_docs = enabled.value(); - let _ = content.parse::()?; - } else if field_name == "docs_path" { - let _ = content.parse::()?; // "docs_path" - let _ = content.parse::()?; - let path = content.parse::()?; - static_hosting.docs_path = path.value(); - let _ = content.parse::()?; - } else if field_name == "ui_theme" { - let _ = content.parse::()?; // "ui_theme" - let _ = content.parse::()?; - let theme = content.parse::()?; - static_hosting.ui_theme = theme.value(); - let _ = content.parse::()?; - } else if field_name == "body_limit" { - let _ = content.parse::()?; // "body_limit" - let _ = content.parse::()?; - let limit = content.parse::()?; - body_limit = Some(limit.base10_parse::()?); - let _ = content.parse::()?; - } else if field_name == "feature_gated" { - let _ = content.parse::()?; // "feature_gated" - let _ = content.parse::()?; - let enabled = content.parse::()?; - feature_gated = enabled.value(); - let _ = content.parse::()?; - } else if field_name == "require_json_content_type" { - let _ = content.parse::()?; // "require_json_content_type" - let _ = content.parse::()?; - let enabled = content.parse::()?; - require_json_content_type = enabled.value(); - let _ = content.parse::()?; - } else if field_name == "docs_require_auth" { - let _ = content.parse::()?; // "docs_require_auth" - let _ = content.parse::()?; - let enabled = content.parse::()?; - docs_require_auth = enabled.value(); - let _ = content.parse::()?; - } else if field_name == "endpoints" { - break; // Start parsing endpoints - } else { - return Err(syn::Error::new( - field_name.span(), - format!("Unknown field: {}", field_name), - )); - } - } - - let _ = content.parse::()?; // "endpoints" - let _ = content.parse::()?; - - let endpoints_content; - syn::bracketed!(endpoints_content in content); - - let mut endpoints = Vec::new(); - while !endpoints_content.is_empty() { - let endpoint = endpoints_content.parse::()?; - endpoints.push(endpoint); - - if endpoints_content.peek(Token![,]) { - let _ = endpoints_content.parse::()?; - } - } - - Ok(ServiceDefinition { - service_name, - base_path, - openapi, - static_hosting, - body_limit, - feature_gated, - require_json_content_type, - docs_require_auth, - endpoints, - }) - } -} - -fn parse_endpoint_path( - input: syn::parse::ParseStream, -) -> syn::Result<(String, Vec, Vec)> { - let mut path_segments = Vec::new(); - let mut path_params = Vec::new(); - let mut handler_name_parts = Vec::new(); - - let first_segment = input.parse::()?; - path_segments.push(first_segment.to_string()); - handler_name_parts.push(first_segment.to_string()); - - while input.peek(Token![/]) { - let _ = input.parse::()?; - - if input.peek(syn::token::Brace) { - let param_content; - syn::braced!(param_content in input); - - let param_name = param_content.parse::()?; - let _ = param_content.parse::()?; - let param_type = param_content.parse::()?; - - path_segments.push(format!("{{{}}}", param_name)); - path_params.push(PathParam { - name: param_name.clone(), - param_type, - }); - handler_name_parts.push(format!("by_{}", param_name)); - } else { - let segment = input.parse::()?; - path_segments.push(segment.to_string()); - handler_name_parts.push(segment.to_string()); - } - } - - Ok(( - format!("/{}", path_segments.join("/")), - path_params, - handler_name_parts, - )) -} - -fn parse_query_params(input: syn::parse::ParseStream) -> syn::Result> { - let mut query_params = Vec::new(); - - if input.is_empty() { - return Ok(query_params); - } - - let param_name = input.parse::()?; - let _ = input.parse::()?; - let param_type = input.parse::()?; - query_params.push(QueryParam { - name: param_name, - param_type, - }); - - while input.peek(Token![&]) || input.peek(Token![,]) { - if input.peek(Token![&]) { - let _ = input.parse::()?; - } else { - let _ = input.parse::()?; - } - - if input.is_empty() { - break; - } - - let param_name = input.parse::()?; - let _ = input.parse::()?; - let param_type = input.parse::()?; - query_params.push(QueryParam { - name: param_name, - param_type, - }); - } - - Ok(query_params) -} - -impl Parse for EndpointDefinition { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let docs = parse_doc_comment_attrs(input.call(syn::Attribute::parse_outer)?, "endpoint")?; - - let method_ident = input.parse::()?; - let method = match method_ident.to_string().as_str() { - "GET" => HttpMethod::Get, - "POST" => HttpMethod::Post, - "PUT" => HttpMethod::Put, - "DELETE" => HttpMethod::Delete, - "PATCH" => HttpMethod::Patch, - _ => { - return Err(syn::Error::new( - method_ident.span(), - "Expected GET, POST, PUT, DELETE, or PATCH", - )); - } - }; - - let auth = if input.peek(syn::Ident) { - let auth_ident = input.parse::()?; - match auth_ident.to_string().as_str() { - "UNAUTHORIZED" => AuthRequirement::Unauthorized, - "OPTIONAL_AUTH" => AuthRequirement::OptionalAuth, - "WITH_PERMISSIONS" => { - let perms_content; - syn::parenthesized!(perms_content in input); - - let mut permission_groups = Vec::new(); - - let first_group_content; - syn::bracketed!(first_group_content in perms_content); - - let mut first_group = Vec::new(); - while !first_group_content.is_empty() { - let perm = first_group_content.parse::()?; - first_group.push(perm.value()); - - if first_group_content.peek(Token![,]) { - let _ = first_group_content.parse::()?; - } - } - permission_groups.push(first_group); - - while perms_content.peek(Token![|]) { - let _ = perms_content.parse::()?; - - let group_content; - syn::bracketed!(group_content in perms_content); - - let mut group = Vec::new(); - while !group_content.is_empty() { - let perm = group_content.parse::()?; - group.push(perm.value()); - - if group_content.peek(Token![,]) { - let _ = group_content.parse::()?; - } - } - permission_groups.push(group); - } - - if permission_groups.len() > 1 - && permission_groups.iter().any(|group| group.is_empty()) - { - return Err(syn::Error::new( - auth_ident.span(), - "an empty permission group is only valid as the entire requirement \ - (WITH_PERMISSIONS([]), meaning any authenticated user); mixing an \ - empty group with non-empty groups would silently grant access to any \ - authenticated user", - )); - } - - AuthRequirement::WithPermissions(permission_groups) - } - _ => { - return Err(syn::Error::new( - auth_ident.span(), - "Expected UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS", - )); - } - } - } else { - return Err(syn::Error::new( - input.span(), - "Expected authentication requirement", - )); - }; - - let (path, path_params, handler_name_parts) = parse_endpoint_path(input)?; - - let mut query_params = Vec::new(); - if input.peek(Token![?]) { - let _ = input.parse::()?; - query_params = parse_query_params(input)?; - } - - let method_str = method.as_str().to_lowercase(); - let path_str = handler_name_parts.join("_"); - let handler_name = syn::parse_str::(&format!("{}_{}", method_str, path_str))?; - - let request_type = if input.peek(syn::token::Paren) { - let request_content; - syn::parenthesized!(request_content in input); - if !request_content.is_empty() { - Some(request_content.parse::()?) - } else { - None - } - } else { - None - }; - - let _ = input.parse::]>()?; - let response_type = input.parse::()?; - - let mut version = None; - let mut versions = Vec::new(); - let mut body_limit = None; - let mut with_headers = false; - - if input.peek(syn::token::Brace) { - let content; - syn::braced!(content in input); - - while !content.is_empty() { - let field_name = content.parse::()?; - let _ = content.parse::()?; - - match field_name.to_string().as_str() { - "version" => { - version = Some(parse_label(&content)?); - } - "versions" => { - let versions_content; - syn::bracketed!(versions_content in content); - - while !versions_content.is_empty() { - versions.push(versions_content.parse::()?); - - if versions_content.peek(Token![,]) { - let _ = versions_content.parse::()?; - } - } - } - "body_limit" => { - let limit = content.parse::()?; - body_limit = Some(limit.base10_parse::()?); - } - "headers" => { - with_headers = content.parse::()?.value(); - } - _ => { - return Err(syn::Error::new( - field_name.span(), - "Expected version, versions, body_limit, or headers", - )); - } - } - - if content.peek(Token![,]) { - let _ = content.parse::()?; - } - } - } - - Ok(EndpointDefinition { - docs, - method, - auth, - path, - path_params, - query_params, - request_type, - response_type, - handler_name, - version, - versions, - body_limit, - with_headers, - }) - } -} - -impl Parse for EndpointVersionDefinition { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let version = parse_label(input)?; - - let content; - syn::braced!(content in input); - - let mut path = None; - let mut path_params = Vec::new(); - let mut query_params = Vec::new(); - let mut request_type = None; - let mut response_type = None; - let mut migration_type = None; - - while !content.is_empty() { - let field_name = content.parse::()?; - let _ = content.parse::()?; - - match field_name.to_string().as_str() { - "path" => { - let (parsed_path, parsed_path_params, _) = parse_endpoint_path(&content)?; - path = Some(parsed_path); - path_params = parsed_path_params; - } - "query" => { - let query_content; - syn::bracketed!(query_content in content); - query_params = parse_query_params(&query_content)?; - } - "body" | "request" => { - let parsed_type = content.parse::()?; - if quote!(#parsed_type).to_string() != "()" { - request_type = Some(parsed_type); - } - } - "response" => { - response_type = Some(content.parse::()?); - } - "migration" => { - migration_type = Some(content.parse::()?); - } - _ => { - return Err(syn::Error::new( - field_name.span(), - "Expected path, query, body, request, response, or migration", - )); - } - } - - if content.peek(Token![,]) { - let _ = content.parse::()?; - } - } - - Ok(Self { - version, - path: path - .ok_or_else(|| syn::Error::new(input.span(), "Version entry is missing path"))?, - path_params, - query_params, - request_type, - response_type: response_type.ok_or_else(|| { - syn::Error::new(input.span(), "Version entry is missing response") - })?, - migration_type: migration_type.ok_or_else(|| { - syn::Error::new(input.span(), "Version entry is missing migration") - })?, - }) - } -} - fn generate_service_code(service_def: ServiceDefinition) -> syn::Result { let service_name = &service_def.service_name; let service_trait_name = quote::format_ident!("{}Trait", service_name); @@ -2308,3 +1673,15 @@ fn generate_handler_body( } } } + +impl HttpMethod { + fn as_axum_method(&self) -> proc_macro2::TokenStream { + match self { + HttpMethod::Get => quote! { axum::routing::get }, + HttpMethod::Post => quote! { axum::routing::post }, + HttpMethod::Put => quote! { axum::routing::put }, + HttpMethod::Delete => quote! { axum::routing::delete }, + HttpMethod::Patch => quote! { axum::routing::patch }, + } + } +} diff --git a/crates/rest/ras-rest-macro/src/parser.rs b/crates/rest/ras-rest-macro/src/parser.rs new file mode 100644 index 0000000..f2676c3 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/parser.rs @@ -0,0 +1,509 @@ +//! REST service syntax and diagnostics. + +use crate::{ast::*, static_hosting}; +use quote::quote; +use syn::{Ident, LitStr, Token, Type, parse::Parse}; + +const DOC_COMMENT_EXPECTED: &str = "Expected doc comment in the form `/// ...`"; + +fn parse_label(input: syn::parse::ParseStream) -> syn::Result { + if input.peek(LitStr) { + Ok(input.parse::()?.value()) + } else { + Ok(input.parse::()?.to_string()) + } +} + +fn parse_doc_comment_attrs( + attrs: Vec, + entry_kind: &str, +) -> syn::Result> { + let lines = attrs + .into_iter() + .map(|attr| parse_doc_comment_attr(attr, entry_kind)) + .collect::>>()?; + + Ok(DocComment::from_lines(lines)) +} + +fn parse_doc_comment_attr(attr: syn::Attribute, entry_kind: &str) -> syn::Result { + if !attr.path().is_ident("doc") { + return Err(syn::Error::new_spanned( + attr, + format!("Only doc comments (`/// ...`) are supported before {entry_kind} definitions"), + )); + } + + if let syn::Meta::NameValue(name_value) = &attr.meta + && let syn::Expr::Lit(expr_lit) = &name_value.value + && let syn::Lit::Str(doc_line) = &expr_lit.lit + { + return Ok(doc_line.value()); + } + + Err(syn::Error::new_spanned(attr, DOC_COMMENT_EXPECTED)) +} + +impl Parse for ServiceDefinition { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let content; + syn::braced!(content in input); + + let _ = content.parse::()?; // "service_name" + let _ = content.parse::()?; + let service_name = content.parse::()?; + let _ = content.parse::()?; + + let _ = content.parse::()?; // "base_path" + let _ = content.parse::()?; + let base_path_lit = content.parse::()?; + let base_path = base_path_lit.value(); + let _ = content.parse::()?; + + let mut openapi = None; + let mut static_hosting = static_hosting::StaticHostingConfig::default(); + let mut body_limit = None; + let mut feature_gated = false; + let mut require_json_content_type = true; + let mut docs_require_auth = false; + + while content.peek(Ident) { + let field_name = content.fork().parse::()?; + + if field_name == "openapi" { + let _ = content.parse::()?; // "openapi" + let _ = content.parse::()?; + + if content.peek(syn::LitBool) { + let enabled = content.parse::()?; + if enabled.value() { + openapi = Some(OpenApiConfig::Enabled); + } + } else if content.peek(syn::token::Brace) { + let openapi_content; + syn::braced!(openapi_content in content); + + let _ = openapi_content.parse::()?; // "output" + let _ = openapi_content.parse::()?; + let path = openapi_content.parse::()?; + openapi = Some(OpenApiConfig::WithPath(path.value())); + } + + let _ = content.parse::()?; + } else if field_name == "serve_docs" { + let _ = content.parse::()?; // "serve_docs" + let _ = content.parse::()?; + let enabled = content.parse::()?; + static_hosting.serve_docs = enabled.value(); + let _ = content.parse::()?; + } else if field_name == "docs_path" { + let _ = content.parse::()?; // "docs_path" + let _ = content.parse::()?; + let path = content.parse::()?; + static_hosting.docs_path = path.value(); + let _ = content.parse::()?; + } else if field_name == "ui_theme" { + let _ = content.parse::()?; // "ui_theme" + let _ = content.parse::()?; + let theme = content.parse::()?; + static_hosting.ui_theme = theme.value(); + let _ = content.parse::()?; + } else if field_name == "body_limit" { + let _ = content.parse::()?; // "body_limit" + let _ = content.parse::()?; + let limit = content.parse::()?; + body_limit = Some(limit.base10_parse::()?); + let _ = content.parse::()?; + } else if field_name == "feature_gated" { + let _ = content.parse::()?; // "feature_gated" + let _ = content.parse::()?; + let enabled = content.parse::()?; + feature_gated = enabled.value(); + let _ = content.parse::()?; + } else if field_name == "require_json_content_type" { + let _ = content.parse::()?; // "require_json_content_type" + let _ = content.parse::()?; + let enabled = content.parse::()?; + require_json_content_type = enabled.value(); + let _ = content.parse::()?; + } else if field_name == "docs_require_auth" { + let _ = content.parse::()?; // "docs_require_auth" + let _ = content.parse::()?; + let enabled = content.parse::()?; + docs_require_auth = enabled.value(); + let _ = content.parse::()?; + } else if field_name == "endpoints" { + break; // Start parsing endpoints + } else { + return Err(syn::Error::new( + field_name.span(), + format!("Unknown field: {}", field_name), + )); + } + } + + let _ = content.parse::()?; // "endpoints" + let _ = content.parse::()?; + + let endpoints_content; + syn::bracketed!(endpoints_content in content); + + let mut endpoints = Vec::new(); + while !endpoints_content.is_empty() { + let endpoint = endpoints_content.parse::()?; + endpoints.push(endpoint); + + if endpoints_content.peek(Token![,]) { + let _ = endpoints_content.parse::()?; + } + } + + Ok(ServiceDefinition { + service_name, + base_path, + openapi, + static_hosting, + body_limit, + feature_gated, + require_json_content_type, + docs_require_auth, + endpoints, + }) + } +} + +fn parse_endpoint_path( + input: syn::parse::ParseStream, +) -> syn::Result<(String, Vec, Vec)> { + let mut path_segments = Vec::new(); + let mut path_params = Vec::new(); + let mut handler_name_parts = Vec::new(); + + let first_segment = input.parse::()?; + path_segments.push(first_segment.to_string()); + handler_name_parts.push(first_segment.to_string()); + + while input.peek(Token![/]) { + let _ = input.parse::()?; + + if input.peek(syn::token::Brace) { + let param_content; + syn::braced!(param_content in input); + + let param_name = param_content.parse::()?; + let _ = param_content.parse::()?; + let param_type = param_content.parse::()?; + + path_segments.push(format!("{{{}}}", param_name)); + path_params.push(PathParam { + name: param_name.clone(), + param_type, + }); + handler_name_parts.push(format!("by_{}", param_name)); + } else { + let segment = input.parse::()?; + path_segments.push(segment.to_string()); + handler_name_parts.push(segment.to_string()); + } + } + + Ok(( + format!("/{}", path_segments.join("/")), + path_params, + handler_name_parts, + )) +} + +fn parse_query_params(input: syn::parse::ParseStream) -> syn::Result> { + let mut query_params = Vec::new(); + + if input.is_empty() { + return Ok(query_params); + } + + let param_name = input.parse::()?; + let _ = input.parse::()?; + let param_type = input.parse::()?; + query_params.push(QueryParam { + name: param_name, + param_type, + }); + + while input.peek(Token![&]) || input.peek(Token![,]) { + if input.peek(Token![&]) { + let _ = input.parse::()?; + } else { + let _ = input.parse::()?; + } + + if input.is_empty() { + break; + } + + let param_name = input.parse::()?; + let _ = input.parse::()?; + let param_type = input.parse::()?; + query_params.push(QueryParam { + name: param_name, + param_type, + }); + } + + Ok(query_params) +} + +impl Parse for EndpointDefinition { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let docs = parse_doc_comment_attrs(input.call(syn::Attribute::parse_outer)?, "endpoint")?; + + let method_ident = input.parse::()?; + let method = match method_ident.to_string().as_str() { + "GET" => HttpMethod::Get, + "POST" => HttpMethod::Post, + "PUT" => HttpMethod::Put, + "DELETE" => HttpMethod::Delete, + "PATCH" => HttpMethod::Patch, + _ => { + return Err(syn::Error::new( + method_ident.span(), + "Expected GET, POST, PUT, DELETE, or PATCH", + )); + } + }; + + let auth = if input.peek(syn::Ident) { + let auth_ident = input.parse::()?; + match auth_ident.to_string().as_str() { + "UNAUTHORIZED" => AuthRequirement::Unauthorized, + "OPTIONAL_AUTH" => AuthRequirement::OptionalAuth, + "WITH_PERMISSIONS" => { + let perms_content; + syn::parenthesized!(perms_content in input); + + let mut permission_groups = Vec::new(); + + let first_group_content; + syn::bracketed!(first_group_content in perms_content); + + let mut first_group = Vec::new(); + while !first_group_content.is_empty() { + let perm = first_group_content.parse::()?; + first_group.push(perm.value()); + + if first_group_content.peek(Token![,]) { + let _ = first_group_content.parse::()?; + } + } + permission_groups.push(first_group); + + while perms_content.peek(Token![|]) { + let _ = perms_content.parse::()?; + + let group_content; + syn::bracketed!(group_content in perms_content); + + let mut group = Vec::new(); + while !group_content.is_empty() { + let perm = group_content.parse::()?; + group.push(perm.value()); + + if group_content.peek(Token![,]) { + let _ = group_content.parse::()?; + } + } + permission_groups.push(group); + } + + if permission_groups.len() > 1 + && permission_groups.iter().any(|group| group.is_empty()) + { + return Err(syn::Error::new( + auth_ident.span(), + "an empty permission group is only valid as the entire requirement \ + (WITH_PERMISSIONS([]), meaning any authenticated user); mixing an \ + empty group with non-empty groups would silently grant access to any \ + authenticated user", + )); + } + + AuthRequirement::WithPermissions(permission_groups) + } + _ => { + return Err(syn::Error::new( + auth_ident.span(), + "Expected UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS", + )); + } + } + } else { + return Err(syn::Error::new( + input.span(), + "Expected authentication requirement", + )); + }; + + let (path, path_params, handler_name_parts) = parse_endpoint_path(input)?; + + let mut query_params = Vec::new(); + if input.peek(Token![?]) { + let _ = input.parse::()?; + query_params = parse_query_params(input)?; + } + + let method_str = method.as_str().to_lowercase(); + let path_str = handler_name_parts.join("_"); + let handler_name = syn::parse_str::(&format!("{}_{}", method_str, path_str))?; + + let request_type = if input.peek(syn::token::Paren) { + let request_content; + syn::parenthesized!(request_content in input); + if !request_content.is_empty() { + Some(request_content.parse::()?) + } else { + None + } + } else { + None + }; + + let _ = input.parse::]>()?; + let response_type = input.parse::()?; + + let mut version = None; + let mut versions = Vec::new(); + let mut body_limit = None; + let mut with_headers = false; + + if input.peek(syn::token::Brace) { + let content; + syn::braced!(content in input); + + while !content.is_empty() { + let field_name = content.parse::()?; + let _ = content.parse::()?; + + match field_name.to_string().as_str() { + "version" => { + version = Some(parse_label(&content)?); + } + "versions" => { + let versions_content; + syn::bracketed!(versions_content in content); + + while !versions_content.is_empty() { + versions.push(versions_content.parse::()?); + + if versions_content.peek(Token![,]) { + let _ = versions_content.parse::()?; + } + } + } + "body_limit" => { + let limit = content.parse::()?; + body_limit = Some(limit.base10_parse::()?); + } + "headers" => { + with_headers = content.parse::()?.value(); + } + _ => { + return Err(syn::Error::new( + field_name.span(), + "Expected version, versions, body_limit, or headers", + )); + } + } + + if content.peek(Token![,]) { + let _ = content.parse::()?; + } + } + } + + Ok(EndpointDefinition { + docs, + method, + auth, + path, + path_params, + query_params, + request_type, + response_type, + handler_name, + version, + versions, + body_limit, + with_headers, + }) + } +} + +impl Parse for EndpointVersionDefinition { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let version = parse_label(input)?; + + let content; + syn::braced!(content in input); + + let mut path = None; + let mut path_params = Vec::new(); + let mut query_params = Vec::new(); + let mut request_type = None; + let mut response_type = None; + let mut migration_type = None; + + while !content.is_empty() { + let field_name = content.parse::()?; + let _ = content.parse::()?; + + match field_name.to_string().as_str() { + "path" => { + let (parsed_path, parsed_path_params, _) = parse_endpoint_path(&content)?; + path = Some(parsed_path); + path_params = parsed_path_params; + } + "query" => { + let query_content; + syn::bracketed!(query_content in content); + query_params = parse_query_params(&query_content)?; + } + "body" | "request" => { + let parsed_type = content.parse::()?; + if quote!(#parsed_type).to_string() != "()" { + request_type = Some(parsed_type); + } + } + "response" => { + response_type = Some(content.parse::()?); + } + "migration" => { + migration_type = Some(content.parse::()?); + } + _ => { + return Err(syn::Error::new( + field_name.span(), + "Expected path, query, body, request, response, or migration", + )); + } + } + + if content.peek(Token![,]) { + let _ = content.parse::()?; + } + } + + Ok(Self { + version, + path: path + .ok_or_else(|| syn::Error::new(input.span(), "Version entry is missing path"))?, + path_params, + query_params, + request_type, + response_type: response_type.ok_or_else(|| { + syn::Error::new(input.span(), "Version entry is missing response") + })?, + migration_type: migration_type.ok_or_else(|| { + syn::Error::new(input.span(), "Version entry is missing migration") + })?, + }) + } +} diff --git a/documentation/reviews/comments-and-boundaries.md b/documentation/reviews/comments-and-boundaries.md new file mode 100644 index 0000000..2680f4c --- /dev/null +++ b/documentation/reviews/comments-and-boundaries.md @@ -0,0 +1,139 @@ +Review date: 2026-09-05. + +This records the initial review. The [refactor plan](refactor-plan.md) updates +priorities and ownership proposals against the subsequently merged security changes. + +The main responsibility problems are concentrated in macro generation, the chat example, +and the shared explorer. Most crate boundaries are sensible. +We should split those files into cohesive modules before creating more crates. +Two package boundaries deserve separate attention: the explorer assets and bidirectional runtime adapters. + +This pass inventories 175 Rust files and all 22 library crate manifests, +examines comment patterns across source, tests, and examples, +and checks the large HTML assets and their consumers. +The findings concern documentation and ownership; they are not a complete behavioral or protocol-conformance audit. +Line counts below describe the revision before comment cleanup and include comments and blank lines. + +Comments now describe current behavior instead of audit labels, previous implementations, +feedback history, or coverage work. The cleanup also removes 225 selected narration lines +from macro generation, OAuth2, observability, and WebSocket code. +Comments explaining lock lifetimes, credential handling, feature resolution, +and other non-obvious constraints remain. +Short test scenario labels and examples that teach API usage remain useful. + +Several corrections change the meaning of the documentation: + +| Location | Correction | +| --- | --- | +| [HTTP transport](../../crates/core/ras-transport-core/src/lib.rs) | The WASM adapter buffers both bodies. Query-value serialization returns decoded pairs, which a separate helper form-encodes. | +| [Authorization helpers](../../crates/core/ras-auth-core/src/authorize.rs) | The full pipeline is shared by REST and file services. Other protocols reuse permission helpers. The helper itself cannot guarantee that its caller has not read the body. | +| [Caller](../../crates/core/ras-auth-core/src/lib.rs) | Construction requires trusted authentication results; it is not restricted to one helper, since public variants and `from_authenticated` exist. | +| [OIDC client](../../crates/identity/ras-identity-oauth2/src/client.rs) and [configuration](../../crates/identity/ras-identity-oauth2/src/config.rs) | Accepting an ID token requires a configured issuer. The constructor can panic and is documented accordingly. | +| [OAuth2 state](../../crates/identity/ras-identity-oauth2/src/state.rs) | The store description belongs on `InMemoryStateStore`, not on the preceding capacity constant. | +| [OpenRPC validation](../../crates/specs/ras-openrpc-types/src/validation.rs) | URL/email checks are shallow; the version helper checks only two numeric components. Single-name helpers do not validate collection uniqueness. These limits are explicit without changing behavior. | +| [JSON-RPC core](../../crates/rpc/ras-jsonrpc-core/src/lib.rs) | This crate is a runtime re-export facade; it does not own the authentication traits. | +| [Chat tests](../../examples/bidirectional-chat/server/tests/server_tests.rs) | The health fixture does not construct the application server. Auth lifecycle tests also wire a local service fixture. | + +The first file splits should be: + +| Priority | File and size | Proposed responsibility boundaries | Reason | +| --- | --- | --- | --- | +| 1 | [REST macro `lib.rs`](../../crates/rest/ras-rest-macro/src/lib.rs), 2,298 lines | `ast`, `parser`, `server` with handler/body extraction, routing, and version migration submodules. Keep the entry function and expansion orchestration in `lib.rs`. | Syntax, validation, and several kinds of emitted server code change independently. The existing client/spec/permission modules already establish this pattern. | +| 1 | [JSON-RPC macro `lib.rs`](../../crates/rpc/ras-jsonrpc-macro/src/lib.rs), 1,315 lines | `ast`, `parser`, `server`, and `dispatch` with version handling. | Parsing, HTTP policy, builder generation, and method dispatch share one file. Keep HTTP envelope handling separate from method dispatch. | +| 1 | [Chat server `main.rs`](../../examples/bidirectional-chat/server/src/main.rs), 2,663 lines; 1,696 before tests | Application state, chat operations, identity/session HTTP routes, persistence conversion, and router construction. Leave process setup in `main`. | This file owns an application rather than an entry point. Router construction should be callable by integration tests so fixtures need not duplicate it. Keep the existing persistence module as the storage owner. | +| 1 | [Explorer template](../../crates/rest/ras-rest-macro/src/api_explorer_template.html), 1,451 lines | CSS, schema/docs rendering, OpenAPI/OpenRPC normalization, request execution, and saved-request/history state. Assemble an embedded asset for consumers. | Styling, protocol interpretation, and application state are separate concerns. Preserve the generated explorer's self-contained delivery when splitting source assets. | +| 2 | [File server generator](../../crates/rest/ras-file-macro/src/server.rs), 1,048 lines | Support/trait types, upload handling and part validation, download handling, and route/auth glue. | Multipart state and limits dominate a file that also emits unrelated download and router code. | +| 2 | [REST OpenAPI generator](../../crates/rest/ras-rest-macro/src/openapi.rs), 823 lines | Schema collection/normalization and operation/document generation. | Most work is inside one large generator. Extract emitter functions as well as files; moving the whole function would not clarify ownership. | +| 2 | [JSON-RPC OpenRPC generator](../../crates/rpc/ras-jsonrpc-macro/src/openrpc.rs), 643 lines | Schema/reference handling, example generation, and method/document generation. | These transform different parts of the output contract. Compare duplicated normalization with REST after establishing local boundaries. | +| 2 | [Auth transport](../../crates/core/ras-auth-core/src/transport.rs), 1,088 lines; 739 before tests | Cookie configuration/emission, CSRF policy, credential extraction, and redaction, with a small transport facade. | Each has distinct invariants and tests. Preserve the current public re-exports. | +| 2 | [WASM UI](../../examples/wasm-ui-demo/src/lib.rs), 1,378 lines; 1,286 before tests | App state/service actions, login, statistics, task form/list, and dashboard composition. | UI sections have clear render functions but share a single large source file. | +| 3 | [WebSocket client](../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-client/src/client.rs), 1,465 lines; 772 before tests | Builder, connection/message driver, and facade; move the test module into a companion file. | The size is partly tests. Keep connection state, pending requests, and their lifecycle coordinated rather than scattering individual methods. | +| 3 | [WebSocket handler](../../crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs), 1,206 lines; 551 before tests | Handler contract, WebSocket IO adapter, connection loop, and companion tests. | The transport adapter is independent of service callbacks. Moving tests is the lowest-risk first step. | +| 3 | [OAuth2 client](../../crates/identity/ras-identity-oauth2/src/client.rs), 1,143 lines; 548 before tests | HTTP adapter, PKCE/authorization request handling, ID-token claim validation, and tests. | Network errors and claim validation have different responsibilities; keep flow coordination in the client. | +| 3 | [Sessions](../../crates/identity/ras-identity-session/src/lib.rs), 894 lines; 522 before tests | Configuration, JWT codec/claims, session lifecycle, auth adapter, and tests. | Cryptographic token handling and active-session state can be reviewed independently within this crate. | +| 3 | [HTTP transport root](../../crates/core/ras-transport-core/src/lib.rs), 473 lines | Query serialization into `query`; path encoding into `path`. | A custom serde serializer is an independent concern from the transport contract. Network/test adapters already have modules and optional features. | + +For tests, move large inline test modules before subdividing cohesive production types. +`ras-identity-local/src/lib.rs` is 743 lines, but only 199 precede its tests. +`ras-identity-oauth2/src/provider.rs` has 356 production/documentation lines and 347 test lines. +Neither needs a new crate. + +The REST HTTP integration file is 1,377 lines and combines substantial service fixtures +with authentication, serialization, routing, and body-policy scenarios. +Group those scenarios into test modules with local support code. +Apply the same approach to the 727-line JSON-RPC HTTP suite and 809-line file-service end-to-end suite. +Keep fixtures in `tests/support` unless multiple crates truly need the same contract. +The `xm_feedback_*` filenames should eventually become behavioral names such as `http_contract`; +their headers now describe the tested behavior, but this pass leaves filenames intact. + +The chat auth suite contains its own handlers and chat service implementation. +The smaller server suite builds a health-only router and ignores the supplied configuration. +After extracting application construction, have application integration tests call that shared constructor. +File splitting alone would otherwise preserve two versions of the behavior under test. + +Some large files should stay cohesive. OpenRPC `schema.rs` has 936 lines, +of which 676 precede tests; `method.rs` has 827, with 558 before tests. +These own recognizable specification objects, their constructors/conversions, and validation. +Move tests first; split validation implementations only if navigating them remains difficult. +The chat TUI's 664-line `ui.rs` can be divided by screen when editing it, +while its 680-line server configuration file largely consists of configuration types and defaults. +Neither is as urgent as the macro roots or application entry point. +The OAuth2 demo's large HTML pages can share styles and browser session helpers, +but their markup is also teaching content, so preserve readable standalone examples. + +At crate level, the decisions are: + +| Crate | Rust lines under `src` | Decision | +| --- | ---: | --- | +| `ras-auth-core` | 1,757 | Keep; split HTTP auth responsibilities into modules. No runtime adapter forces a crate split. | +| `ras-identity-core` | 245 | Keep the identity and permissions contracts together. | +| `ras-observability-core` | 595 | Keep; its Axum header import couples a contract crate to a framework. Prefer an `http` dependency or an adapter module when addressing this boundary. | +| `ras-transport-core` | 1,274 | Keep; optional network, filesystem, and in-process adapters already isolate dependencies sufficiently. Split serialization modules first. | +| `ras-version-core` | 192 | Keep; the shared migration trait is only 15 lines before tests and has a clear independent purpose. | +| `ras-identity-local` | 743 | Keep; move tests. The provider's small implementation does not justify more crates. | +| `ras-identity-oauth2` | 3,002 | Keep; flow, provider, configuration, and state modules already form one identity adapter. Split the client internally. | +| `ras-identity-session` | 894 | Keep; JWT and active-session modules are sufficient. A separate JWT library needs a reuse requirement. | +| `ras-observability-otel` | 601 | Keep the concrete metrics/export adapter together. | +| `ras-file-core` | 394 | Keep; upload/download runtime contracts are cohesive. | +| `ras-file-macro` | 3,191 | Keep the macro crate; split server generation internally. | +| `ras-rest-core` | 289 | Keep the REST runtime facade and response/error contracts together. | +| `ras-rest-macro` | 4,103 | Keep parsing and generation in this macro crate; extract shared explorer ownership separately. | +| `ras-jsonrpc-bidirectional-client` | 3,172 | Keep; native and WASM adapters serve the same client contract and already have separate modules. | +| `ras-jsonrpc-bidirectional-macro` | 1,575 | Keep; parser/client/server/permission concerns already have useful boundaries. | +| `ras-jsonrpc-bidirectional-server` | 3,582 | Keep; connection, manager, service, router, upgrade, and handler modules form one runtime. Refine `handler`. | +| `ras-jsonrpc-bidirectional-types` | 1,350 | Separate concrete senders from shared contracts; see below. | +| `ras-jsonrpc-core` | 199 | Keep as the stable generated-code facade. Its implementation is re-exports, not an oversized domain crate. | +| `ras-jsonrpc-macro` | 2,700 | Keep the macro crate; split parsing/server generation and stop importing assets from its sibling's source tree. | +| `ras-jsonrpc-types` | 359 | Keep the protocol envelope types independent. | +| `ras-openrpc-types` | 6,895 | Keep; 17 modules already divide specification objects. Total crate size alone is not a reason to separate the schema model. | +| `ras-permission-manifest` | 398 | Keep the transport-free tooling artifact independent. | + +The shared explorer is the strongest new-package candidate. +[`ras-jsonrpc-macro/src/static_hosting.rs`](../../crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs) +uses `include_str!` to reach into `ras-rest-macro/src/api_explorer_template.html`. +That ownership is absent from Cargo's dependency graph and depends on the workspace layout. +Move the shared embedded asset into a small ordinary library crate consumed by both macros, +or establish another explicit packaged-asset boundary. +Keep REST and JSON-RPC route generation in their respective macro crates. +The extraction should verify independently packaged builds, not only workspace compilation. + +The bidirectional types crate mixes wire messages and shared traits with a concrete +`WebSocketMessageSender` backed by Tokio and Tungstenite. +Move concrete senders to an adapter boundary; retain wire types and genuinely shared contracts. +There are no in-tree uses of `WebSocketMessageSender` outside its own implementation and tests, +so an existing runtime crate may be a sufficient destination. +Create a dedicated adapter crate only if it must serve independent consumers. +Audit pending-request channels and error types too before claiming the contracts are runtime-free. +This changes public import paths and needs an explicit compatibility/versioning decision. + +Do not create a general macro-utilities crate merely to collect similarly named helpers. +Permission metadata and schema handling repeat across macro crates, +but shared ownership should follow equivalent semantics and shared tests. +The explorer already has an actual shared consumer, which makes its boundary concrete. + +No files, modules, or crates were split in this pass. +Validation checks that all non-comment, nonblank Rust lines match the original revision, +and `git diff --check` checks whitespace. +Offline documentation builds with all features pass for `ras-auth-core`, +`ras-transport-core`, `ras-identity-oauth2`, `ras-openrpc-types`, and `ras-jsonrpc-core`. +No behavior tests were added or run for the comment-only changes. diff --git a/documentation/reviews/refactor-plan.md b/documentation/reviews/refactor-plan.md new file mode 100644 index 0000000..812aae0 --- /dev/null +++ b/documentation/reviews/refactor-plan.md @@ -0,0 +1,201 @@ +Refactor plan, 2026-09-05. Based on `84346d6`, which includes the comment-only +changes in PR #27 on top of `a2943d2` (the September security changes). +Execution: one MR, with sequential verified steps and checkpoint commits. References +to separate MRs below are historical work-package boundaries; the single-MR +instruction takes precedence. Complete each step’s checks before starting the next. + +This plan supersedes the priorities and size estimates in the earlier +[boundary review](comments-and-boundaries.md). It proposes work; no refactors have been implemented. + +We should start with macro ownership, then separate runtime policy from lifecycle code, +and finally simplify application wiring and examples. +The shared explorer is the one new crate justified immediately by existing consumers. +Other crate extractions need a concrete dependency benefit before proceeding. + +The default is to preserve public APIs, public module paths, feature behavior, +generated identifiers, serialized data, HTTP responses, and protocol ordering. +Existing public paths can re-export moved types. +Keep internal modules private and expose only the minimum visibility needed between siblings. +Breaking package moves are a separate decision described below. + +Each MR should have a single responsibility change. +Move existing code and tests first; isolate any necessary logic changes in a separate commit or MR. +An extraction is finished when the new owner is clear and callers depend on its narrow interface. +Moving a thousand-line function into another file does not meet that condition. + +Use roughly 200–500 production lines as a navigation guideline, not a quota. +Review files above about 700 production lines for multiple responsibilities. +Tests, documentation examples, and large cohesive specification objects explain some exceptions. +Avoid both a universal `utils` module and one-file-per-method fragmentation. + +The first tranche consists of these independently reviewable MRs: + +| MR | Scope | Dependency | Completion criteria | +| --- | --- | --- | --- | +| 1 | REST macro model and parser | Comment PR as the working baseline | `lib.rs` no longer defines/parses the service language. Existing valid inputs, errors, spans, and generated names remain stable. | +| 2 | REST server generation | 1 | Builder/router emission, request extraction, and canonical/versioned handlers have distinct owners. The macro root only parses, orchestrates, and reports errors. | +| 3 | JSON-RPC macro model and parser | Baseline; use the naming conventions established by 1 | Separate language model and parsing without sharing a new generic parser with REST. | +| 4 | JSON-RPC server generation | 3 | Separate HTTP envelope/auth policy from method dispatch and version migration. | +| 5 | Shared explorer package | Baseline | Both macro crates obtain the same template from an explicit Cargo dependency; neither reads the other's source tree. | +| 6 | WebSocket handler tests and policy ownership | Baseline | Move the large test module and shared policy/accounting types out of `handler.rs`; retain the current connection loop and checked subscription mutation path. | + +Implement 1 and 2 first to establish a useful macro structure. +Then finish 3 and 4. MR 5 is independent of the parser/server extractions, +and MR 6 establishes the boundary needed by later WebSocket work. +Merge each completed MR before starting another that changes the same files. +Keep the plan and review notes out of PR #27. + +For the REST macro, use this responsibility map: + +| Owner | Responsibility | +| --- | --- | +| `lib.rs` | Public macro documentation, proc-macro entry point, error conversion. | +| `ast.rs` | Service, endpoint, version, permission, parameter, and documentation models. May use `syn` types; must not depend on emitters. | +| `parser/` | Service fields, endpoint/version grammar, paths/queries, doc attributes, and syntax diagnostics. | +| `expand.rs` | Select and assemble client, server, spec, permission-manifest, and explorer output, including feature gating. | +| `server/mod.rs` | Assemble the server expansion and generated trait/builder. Extract a builder module if this remains large. | +| `server/routes.rs` | Route and extractor registration for canonical and versioned endpoints. | +| `server/request.rs` | Request-part types, argument order, body limits/content type, and body decoding. | +| `server/handlers.rs` | Canonical invocation, authorization sequencing, tracking, and response conversion. | +| `server/versioned.rs` | Adapt versioned request parts and responses around the canonical handler contract. | + +Keep the existing `client`, `permissions`, `openapi`, and `static_hosting` owners. +Do not unify canonical and versioned handlers while moving them; +first make their differences visible through explicit inputs and outputs. +Auth must still precede body consumption and the relevant request extraction. +Preserve the newer raw-request handling and error classification from the security changes. + +Use the same `ast`, `parser`, and `expand` conventions for JSON-RPC. +Its server modules should own builder/router construction, HTTP envelope processing, +and method dispatch respectively. +Dispatch owns method names, parameter decoding, permission decisions, and migration adapters. +The HTTP layer owns content-type/body limits, envelope errors, request-level credential resolution, +and the HTTP status mapping. Preserve optional-auth downgrade behavior across that boundary. +Similarity to REST does not imply that their auth pipelines can be merged. + +For the explorer, create an ordinary library crate such as +`crates/specs/ras-api-explorer-assets` with a small embedded-template API. +Place it in the existing `crates/specs/*` workspace group because it serves the API specifications. +It should have no Axum, Tokio, `syn`, or `quote` dependency. +REST and JSON-RPC retain their own route generation, authorization gates, +configuration serialization, and escaping. +The first MR moves the template intact and preserves its bytes. + +In a subsequent explorer MR, divide source assets into CSS, markup, schema/document rendering, +OpenAPI/OpenRPC normalization, request construction/execution, and local saved/history state. +Assemble them at compile time into the same self-contained HTML response. +Prefer fixed-order concatenation with an explicit initializer; do not introduce a frontend framework, +runtime asset server, or JavaScript bundler solely for this split. +Keep the JSON configuration placeholder and its escaping contract explicit. + +MR 6 needs more care than the original review suggested. +The WebSocket handler now has 2,060 lines, including about 1,216 lines of inline tests. +It defines subscription limits/accounting that `connection.rs` imports, +while the handler also depends on connection state. +Move `SubscriptionLimits`, `SubscriptionAccounting`, and `SubscriptionPolicy` +into a shared `subscriptions` module. Re-export existing public paths. +Keep `ConnectionContext::subscribe` as the checked mutation entry point; +move policy ownership without creating an alternate path around it. + +A follow-up WebSocket MR can separate the handler contract, socket IO adapter, +and revalidation/keepalive configuration from the connection loop. +Use `handler/tests/` for lifecycle, wire errors, revalidation, subscription limits, +and keepalive scenarios with local shared fixtures. +The loop continues to own timer/select ordering, cancellation, sending, +and disconnect cleanup. Do not turn each event into an independent task. +Keep the final subscription check immediately before the outbound socket write. + +The second tranche contains these bounded work packages. +Each row is one MR unless the split column explicitly describes a sequence. + +| Work package | Proposed owners and MR boundary | Validation focus | +| --- | --- | --- | +| File server generation | `server/types`, `upload`, `download`, `routes`, and local auth glue. Keep upload part dispatch/limits together; split part generation underneath `upload` if necessary. | Streaming, aggregate/part limits, cancellation/draining, filename rejection, auth, and multiple macro invocations. | +| REST OpenAPI generation | After MR 2, split schema collection/normalization from operation/document emission. Extract smaller emitter functions inside those owners. | Compare generated JSON semantics, references, nullable shapes, permissions, versioned routes, and explorer rendering. | +| JSON-RPC OpenRPC generation | After MR 4, separate schema/reference transforms, examples, and method/document emission. Separate MR from OpenAPI. | Generated JSON, method names, references, permissions, examples, and explorer rendering. | +| HTTP auth transport | Keep a `transport` facade over `cookie`, `csrf`, `credential`, and `redaction` modules. Tests follow the relevant invariants. | Credential precedence, ambiguous inputs, cookie attributes, all CSRF modes, and custom sensitive headers. No policy changes. | +| Sessions | `config`, `claims`, `jwt`, `session`, `auth`, and companion tests. Current root is 1,397 lines, with about 678 lines of tests. | Preserve secret/config validation, algorithms, issuer/audience defaults, expiry, active-session caps, revocation, permission snapshots, and cleanup. Keep signing/verification together in `jwt`. | +| OAuth2 client | Separate HTTP transport, PKCE, authorization-request policy, and ID-token validation; `client` coordinates the flow. Keep state storage and identity mapping with their existing owners. | HTTPS requirements, reserved parameters, binding/nonce/state consumption, issuer/audience/subject checks, token endpoint failures, and redaction. Move provider/client tests with their owners. | +| HTTP transport helpers | Move query serde collection into `query` and path encoding into `path`; preserve root exports. | Repeated keys, enum renames, form encoding, path escaping, and generated client round trips. Keep optional adapter features intact. | +| Observability dependency | Replace the core crate's `axum::http::HeaderMap` import/dependency with `http::HeaderMap`. Keep the existing public signatures and extractor behavior. Separate MR from transport helpers. | Core and OTEL tests plus dependency inspection showing that core no longer directly depends on Axum. | +| WebSocket client | Move tests first, then builder and message driver into child modules behind the existing facade. Keep native/WASM adapters separate. | Handshake completion, request cleanup, subscription dispatch, header/subprotocol auth, native builds, and WASM builds. Preserve lock scopes and callback lifetimes. | + +Do not move security decisions into a generic shared helper merely because branches look similar. +Any deduplication of permission or schema code comes after these local boundaries are stable, +with evidence that both callers require the same semantics. + +The application tranche makes examples easier to follow and tests more representative: + +| Work package | Scope and MR boundary | Completion criteria | +| --- | --- | --- | +| Chat library and application construction | First move state, chat operations, auth handlers, persistence conversions, and router construction into the existing library. Keep environment loading, tracing setup, listener binding, and serving in `main`. | A constructor accepts explicit configuration/dependencies and returns the assembled application without binding a socket or configuring global logging. Existing behavior and tests remain intact. | +| Chat integration fixtures | Follow the construction MR. Replace duplicated auth/chat handler implementations in integration tests with the actual application constructor. | Login, registration, permissions, persistence-backed state, and WebSocket lifecycle exercise the production wiring. Remove the health-only stand-in where application startup is the intended subject. | +| WASM demo | Move state/service actions into an app module and renderers into login, statistics, task form/item/list, and dashboard modules. | Preserve signal ownership, event lifetimes, requests, and rendered behavior. Build the WASM bundle and exercise login, create/update/list, and failure states. | +| Secondary examples | Separate optional MRs for TUI screen renderers and OAuth2 demo styles/browser session helpers. | Existing TUI behavior and standalone demo flows remain readable and usable. No redesign. | + +For chat, keep one owner of shared state and have the large generated service trait implementation +delegate to cohesive operations such as rooms, messages, profiles, and typing. +Do not create separate stores or locks just to divide methods between files. +Keep persistence IO in its existing module; isolate DTO/domain conversion from file access. +The application constructor should also retain handles needed for cleanup if it creates background work. +Integration tests supply temporary persistence locations and explicit configuration. +They must not start setting process-wide environment variables or logging subscribers. + +Test-file cleanup should follow the owner being refactored rather than become one workspace-wide MR. +For the large REST/JSON-RPC/file integration suites, keep the test target name where practical +and split scenario modules underneath it, with a local `support` module for fixtures. +Record discovered test names/counts before and after moving tests so none silently disappear. +Rename the two `xm_feedback_*` targets to behavior-oriented names in a dedicated small commit, +updating references in scripts, CI, and docs if any exist. +Move the large local-identity test module without splitting its small provider implementation. + +OpenRPC `Schema` and `Method` remain cohesive objects. +Move their tests into companion files when convenient; do not split their model into new crates. +Likewise, keep chat configuration types together unless repeated navigation reveals a useful grouping. +These are optional cleanup items, not prerequisites for the higher-value work. + +The bidirectional types crate needs a deliberate package decision. +Moving `WebSocketMessageSender` into the existing client/server runtime cannot preserve its old +re-export through the types crate: that would create a Cargo dependency cycle. +Moving it into a new adapter crate has the same issue if the types crate re-exports it. +Also, `ConnectionManager` exposes Tokio oneshot senders, so moving the concrete sender alone +does not make the contract runtime-independent. + +Default plan: separate wire models, manager contracts, sender contracts, and concrete adapters +into internal modules; retain current public paths and dependencies for this cycle. +Defer the package move to a breaking release with a migration guide. +At that point, move concrete senders into an existing runtime crate if there is one real consumer, +or a dedicated adapter crate if independent users need it. +Removing Tokio from contracts would be a further API redesign and is outside this refactor. +This default can be changed explicitly before planning that release. + +Validation uses the repository's existing CI contract rather than a new refactor-specific harness. +Before each package's first move, establish that its relevant suites pass on the chosen base. +Run focused checks during edits and the existing required CI jobs on the resulting MR. + +| Change | Required evidence | +| --- | --- | +| All Rust moves | Format and Clippy checks, affected package tests, downstream compilation, docs/doctests, and inspection of public paths/visibility. Keep the current no-retry nextest policy. | +| Macro generation | Existing HTTP/end-to-end, error, versioning, optional-auth, and multiple-invocation suites. Exercise server-only, client-only, no-default-feature, and consumer feature-gated builds from CI; all-features alone is insufficient. Add a small contract fixture only where a moved boundary lacks coverage. | +| Spec emission | Compare representative generated documents before/after as parsed JSON, ignoring irrelevant object-key ordering. Preserve references, operation names, security declarations, and response shapes. | +| Explorer package/assets | REST and JSON-RPC Playwright suites, existing XSS/token-storage/explorer tests, byte comparison for the initial asset move, and packaged-build checks. | +| WebSocket server | Unit suites plus `custom_manager_limits`, `transport_limits`, and `manager_unit`; preserve revocation, admission/subscription accounting, keepalive, and slow-client behavior. Use deterministic synchronization, not added sleeps. | +| WebSocket client/UI | The repository's native/WASM feature matrix; UI bundle build and targeted interaction checks. Native tests cannot establish browser callback behavior. | +| Chat fixtures | Tests visibly call the real constructor; health/auth paths and a WebSocket session work through that router. Keep isolated tests for config and persistence. | + +For normal package checks use `cargo nextest run -p --all-targets --all-features --locked` +and `cargo test --doc -p --all-features --locked`, with downstream suites as listed above. +Use the exact feature combinations in `.github/workflows/ci.yml` for macro and WASM validation. +The explorer suite runs with `npm --prefix tests/playwright test` after its documented setup. + +The explorer package needs more than workspace compilation. +Inspect `cargo package --list` to confirm all embedded source assets are present. +Unpack the package archives and compile the macro consumers without access to sibling source directories. +Resolve unpublished workspace dependencies through a temporary local registry or explicit test patches; +publishing is not needed for verification and is not part of this plan. + +Every MR description should name the new owner, what moved, and the contract evidence. +Report any baseline test failures separately from refactor regressions. +A failed behavioral check means investigate the extraction; do not rewrite expected output merely to pass. +No behavior changes, feature redesigns, dependency upgrades, or broad test-framework changes are bundled +with these responsibility refactors. diff --git a/documentation/reviews/refactor-progress.md b/documentation/reviews/refactor-progress.md new file mode 100644 index 0000000..4e5c0cc --- /dev/null +++ b/documentation/reviews/refactor-progress.md @@ -0,0 +1,17 @@ +Single-MR responsibility refactor. Base: `84346d6` (PR #27). + +The accepted plan is executed sequentially on `refactor/responsibility-boundaries`. +Every checkpoint below passed its affected-package gate before the next extraction. +The gate includes formatting, nextest, doctests, Clippy with warnings denied, +and additional feature/consumer checks where recorded. Raw logs are in +`/tmp/ras-refactor-logs` for this workspace session. + +Baseline: workspace nextest ran 926 tests: 924 passed, two SIGSEGV failures, +one skipped. The failures were `ras-identity-session::permissions_are_frozen_into_the_token_snapshot` +and `ras-identity-local::test_duplicate_user_is_rejected`, before refactoring. +Identity work must investigate these failures before its checkpoint can pass. +REST baseline: 61/61 passed. + +| Step | Change | Verification | +| --- | --- | --- | +| 1 | REST model and parser | 61 tests, 1 doctest; Clippy; no-default/server/client macro builds; no-default/server native and client WASM `rest-api` builds. | From df4a90e135864c221b46b38a991936089d6beb02 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 11:05:09 +0200 Subject: [PATCH 02/35] refactor(rest): separate server generation responsibilities --- crates/rest/ras-rest-macro/src/expand.rs | 94 + crates/rest/ras-rest-macro/src/lib.rs | 1551 +---------------- crates/rest/ras-rest-macro/src/server/auth.rs | 21 + .../ras-rest-macro/src/server/handlers.rs | 264 +++ crates/rest/ras-rest-macro/src/server/mod.rs | 325 ++++ .../rest/ras-rest-macro/src/server/request.rs | 479 +++++ .../rest/ras-rest-macro/src/server/routes.rs | 124 ++ .../ras-rest-macro/src/server/versioned.rs | 304 ++++ documentation/reviews/refactor-progress.md | 1 + 9 files changed, 1616 insertions(+), 1547 deletions(-) create mode 100644 crates/rest/ras-rest-macro/src/expand.rs create mode 100644 crates/rest/ras-rest-macro/src/server/auth.rs create mode 100644 crates/rest/ras-rest-macro/src/server/handlers.rs create mode 100644 crates/rest/ras-rest-macro/src/server/mod.rs create mode 100644 crates/rest/ras-rest-macro/src/server/request.rs create mode 100644 crates/rest/ras-rest-macro/src/server/routes.rs create mode 100644 crates/rest/ras-rest-macro/src/server/versioned.rs diff --git a/crates/rest/ras-rest-macro/src/expand.rs b/crates/rest/ras-rest-macro/src/expand.rs new file mode 100644 index 0000000..64ec279 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/expand.rs @@ -0,0 +1,94 @@ +//! Assemble protocol, server, client, and specification expansions. + +use crate::{ast::*, openapi, permissions, static_hosting}; +use quote::{format_ident, quote}; + +pub(crate) fn generate_service_code( + service_def: ServiceDefinition, +) -> syn::Result { + let service_name = &service_def.service_name; + let service_name_lower = service_name.to_string().to_lowercase(); + let server_mod = format_ident!("__ras_rest_{}_server", service_name_lower); + let client_mod = format_ident!("__ras_rest_{}_client", service_name_lower); + + let (openapi_code, schema_checks) = if let Some(openapi_config) = &service_def.openapi { + ( + openapi::generate_openapi_code(&service_def, openapi_config), + openapi::generate_schema_impl_checks(&service_def), + ) + } else { + (quote! {}, quote! {}) + }; + + let static_hosting_code = if service_def.static_hosting.serve_docs { + static_hosting::generate_static_hosting_code(&service_def, &service_def.static_hosting) + } else { + quote! {} + }; + + let client_impl = crate::client::generate_client_code(&service_def); + let permissions_code = if cfg!(feature = "permissions") { + permissions::generate_permissions_code(&service_def) + } else { + quote! {} + }; + + // `cfg!(feature = ...)` below evaluates the MACRO crate's features, which + // Cargo unifies across the whole workspace — one crate enabling `client` + // forces client codegen into every consumer's expansion. With + // `feature_gated: true` the generated code is instead wrapped in + // `#[cfg(feature = ...)]` attributes that resolve against the CONSUMER + // crate's own `server`/`client` features, immune to unification. + let feature_gated = service_def.feature_gated; + let cfg_server = if feature_gated { + quote! { #[cfg(feature = "server")] } + } else { + quote! {} + }; + let cfg_client = if feature_gated { + quote! { #[cfg(feature = "client")] } + } else { + quote! {} + }; + + let server_code = if feature_gated || cfg!(feature = "server") { + let server_impl = crate::server::generate_server_code( + &service_def, + schema_checks, + openapi_code, + static_hosting_code, + ); + quote! { + #cfg_server + mod #server_mod { use super::*; #server_impl } + #cfg_server + pub use #server_mod::*; + } + } else { + quote! {} + }; + + let client_code = if feature_gated || cfg!(feature = "client") { + quote! { + #cfg_client + mod #client_mod { + use super::*; + + #client_impl + } + + #cfg_client + pub use #client_mod::*; + } + } else { + quote! {} + }; + + let output = quote! { + #permissions_code + #server_code + #client_code + }; + + Ok(output) +} diff --git a/crates/rest/ras-rest-macro/src/lib.rs b/crates/rest/ras-rest-macro/src/lib.rs index fdc78b2..902df2b 100644 --- a/crates/rest/ras-rest-macro/src/lib.rs +++ b/crates/rest/ras-rest-macro/src/lib.rs @@ -1,10 +1,11 @@ use ast::*; use proc_macro::TokenStream; -use quote::{format_ident, quote}; -use syn::{Ident, Type, parse_macro_input}; +use syn::parse_macro_input; mod ast; +mod expand; mod parser; +mod server; mod client; mod openapi; @@ -136,1552 +137,8 @@ mod static_hosting; pub fn rest_service(input: TokenStream) -> TokenStream { let service_definition = parse_macro_input!(input as ServiceDefinition); - match generate_service_code(service_definition) { + match expand::generate_service_code(service_definition) { Ok(tokens) => tokens.into(), Err(err) => err.to_compile_error().into(), } } - -fn generate_service_code(service_def: ServiceDefinition) -> syn::Result { - let service_name = &service_def.service_name; - let service_trait_name = quote::format_ident!("{}Trait", service_name); - let builder_name = quote::format_ident!("{}Builder", service_name); - let base_path = &service_def.base_path; - let service_name_lower = service_name.to_string().to_lowercase(); - let server_mod = format_ident!("__ras_rest_{}_server", service_name_lower); - let client_mod = format_ident!("__ras_rest_{}_client", service_name_lower); - - let (openapi_code, schema_checks) = if let Some(openapi_config) = &service_def.openapi { - ( - openapi::generate_openapi_code(&service_def, openapi_config), - openapi::generate_schema_impl_checks(&service_def), - ) - } else { - (quote! {}, quote! {}) - }; - - let static_hosting_code = if service_def.static_hosting.serve_docs { - static_hosting::generate_static_hosting_code(&service_def, &service_def.static_hosting) - } else { - quote! {} - }; - - let client_impl = crate::client::generate_client_code(&service_def); - let permissions_code = if cfg!(feature = "permissions") { - permissions::generate_permissions_code(&service_def) - } else { - quote! {} - }; - - let trait_methods = service_def.endpoints.iter().map(|endpoint| { - let handler_name = &endpoint.handler_name; - let response_type = &endpoint.response_type; - - let mut params = Vec::new(); - match &endpoint.auth { - AuthRequirement::Unauthorized => {} - AuthRequirement::OptionalAuth => { - params.push(quote! { caller: ras_auth_core::Caller }); - } - AuthRequirement::WithPermissions(_) => { - params.push(quote! { user: &ras_auth_core::AuthenticatedUser }); - } - } - - // Opt-in request headers (immediately after the caller/user) - if endpoint.with_headers { - params.push(quote! { headers: axum::http::HeaderMap }); - } - - for path_param in &endpoint.path_params { - let param_name = &path_param.name; - let param_type = &path_param.param_type; - params.push(quote! { #param_name: #param_type }); - } - - for query_param in &endpoint.query_params { - let param_name = &query_param.name; - let param_type = &query_param.param_type; - params.push(quote! { #param_name: #param_type }); - } - - if let Some(request_type) = &endpoint.request_type { - params.push(quote! { request: #request_type }); - } - - quote! { - async fn #handler_name(&self, #(#params),*) -> ras_rest_core::RestResult<#response_type>; - } - }); - - let request_part_structs = generate_rest_request_part_structs(&service_def); - - let mut query_structs: Vec = Vec::new(); - let mut route_registrations: Vec = Vec::new(); - let mut route_idx = 0usize; - - for endpoint in &service_def.endpoints { - let query_struct_name = quote::format_ident!("QueryParams{}", route_idx); - query_structs.push(generate_query_struct( - &query_struct_name, - &endpoint.query_params, - )); - route_registrations.push(generate_canonical_route_registration( - endpoint, - &query_struct_name, - service_def.require_json_content_type, - )); - route_idx += 1; - - for version in &endpoint.versions { - let query_struct_name = quote::format_ident!("QueryParams{}", route_idx); - query_structs.push(generate_query_struct( - &query_struct_name, - &version.query_params, - )); - route_registrations.push(generate_legacy_route_registration( - &service_def.service_name, - endpoint, - version, - &query_struct_name, - service_def.require_json_content_type, - )); - route_idx += 1; - } - } - - let static_routes = if service_def.static_hosting.serve_docs { - static_hosting::generate_static_routes(&service_def, &service_def.static_hosting) - } else { - quote! {} - }; - - let body_limit = service_def.body_limit.unwrap_or(DEFAULT_BODY_LIMIT); - - // Startup assertion: a service with any WITH_PERMISSIONS route needs an auth - // provider, otherwise every such route returns a runtime 500 (NoAuthProvider) - // on first request. Catch the misconfiguration at build() instead. - let any_route_requires_auth = service_def - .endpoints - .iter() - .any(|endpoint| matches!(endpoint.auth, AuthRequirement::WithPermissions(_))) - || (service_def.static_hosting.serve_docs && service_def.docs_require_auth); - let service_name_str = service_name.to_string(); - let provider_assertion = if any_route_requires_auth { - quote! { - if self.auth_provider.is_none() { - panic!(concat!( - "REST service `", - #service_name_str, - "` has endpoints requiring authorization (WITH_PERMISSIONS) but no ", - "auth_provider was configured; call .auth_provider(...) before build()" - )); - } - } - } else { - quote! {} - }; - - // `cfg!(feature = ...)` below evaluates the MACRO crate's features, which - // Cargo unifies across the whole workspace — one crate enabling `client` - // forces client codegen into every consumer's expansion. With - // `feature_gated: true` the generated code is instead wrapped in - // `#[cfg(feature = ...)]` attributes that resolve against the CONSUMER - // crate's own `server`/`client` features, immune to unification. - let feature_gated = service_def.feature_gated; - let cfg_server = if feature_gated { - quote! { #[cfg(feature = "server")] } - } else { - quote! {} - }; - let cfg_client = if feature_gated { - quote! { #[cfg(feature = "client")] } - } else { - quote! {} - }; - - let server_code = if feature_gated || cfg!(feature = "server") { - quote! { - #cfg_server - mod #server_mod { - use super::*; - - /// Maximum accepted JSON body size in bytes - #[allow(dead_code)] - const __RAS_BODY_LIMIT: usize = #body_limit; - - /// Map a shared authorization failure to this service's JSON error shape - #[allow(dead_code)] - fn __ras_authorize_error_response(error: ras_auth_core::AuthorizeError) -> axum::response::Response { - use axum::response::IntoResponse; - let (status, message) = match &error { - ras_auth_core::AuthorizeError::MissingCredential => ( - axum::http::StatusCode::UNAUTHORIZED, - "Missing or invalid Authorization header", - ), - ras_auth_core::AuthorizeError::CsrfValidationFailed => ( - axum::http::StatusCode::FORBIDDEN, - "CSRF validation failed", - ), - ras_auth_core::AuthorizeError::AuthenticationFailed(_) => ( - axum::http::StatusCode::UNAUTHORIZED, - "Authentication failed", - ), - ras_auth_core::AuthorizeError::NoAuthProvider => ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "No auth provider configured", - ), - ras_auth_core::AuthorizeError::InsufficientPermissions(_) => ( - axum::http::StatusCode::FORBIDDEN, - "Insufficient permissions", - ), - }; - // Rejections are otherwise invisible to the usage/duration trackers, - // which only run on the post-auth happy path; log them here so a - // client hammering an endpoint with a bad credential is observable. - // The `error` detail is logged server-side only; the client gets the - // generic `message`. - ras_rest_core::tracing::warn!( - status = status.as_u16(), - error = ?error, - "request rejected during authorization" - ); - (status, axum::Json(serde_json::json!({ "error": message }))).into_response() - } - - /// Build a success response, omitting the JSON body for status codes that - /// must not carry one (`204 No Content`, `205 Reset Content`, - /// `304 Not Modified`). Without this, `RestResponse::no_content()` would - /// emit a `204` with a serialized `null` body and - /// `Content-Type: application/json`, which violates RFC 9110 and is - /// rejected by some proxies and clients. - #[allow(dead_code)] - fn __ras_success_response( - status: axum::http::StatusCode, - body: T, - ) -> axum::response::Response { - use axum::response::IntoResponse; - if status == axum::http::StatusCode::NO_CONTENT - || status == axum::http::StatusCode::RESET_CONTENT - || status == axum::http::StatusCode::NOT_MODIFIED - { - status.into_response() - } else { - (status, axum::Json(body)).into_response() - } - } - - /// Generated service trait - #[async_trait::async_trait] - #[allow(private_interfaces, private_bounds)] - pub trait #service_trait_name: Send + Sync + 'static { - #(#trait_methods)* - } - - /// Generated builder for the REST service - pub struct #builder_name { - service: std::sync::Arc, - auth_provider: Option>, - auth_transport: ras_auth_core::AuthTransportConfig, - with_usage_tracker: Option, &str, &str) -> std::pin::Pin + Send>> + Send + Sync>>, - with_method_duration_tracker: Option, std::time::Duration) -> std::pin::Pin + Send>> + Send + Sync>>, - } - - const _: () = { - #schema_checks - }; - - #openapi_code - - #static_hosting_code - - use self::query_params::*; - - mod query_params { - #[allow(unused_imports)] - use super::*; - - #(#query_structs)* - } - - #request_part_structs - - impl #builder_name { - /// Create a new builder with the service implementation - pub fn new(service: T) -> Self { - Self { - service: std::sync::Arc::new(service), - auth_provider: None, - auth_transport: ras_auth_core::AuthTransportConfig::default(), - with_usage_tracker: None, - with_method_duration_tracker: None, - } - } - - /// Set the auth provider - pub fn auth_provider(mut self, provider: A) -> Self { - self.auth_provider = Some(std::sync::Arc::new(provider)); - self - } - - /// Enable cookie authentication alongside bearer tokens. - /// - /// Installs a default double-submit CSRF config when none is set, - /// because cookie credentials are CSRF-exploitable on unsafe methods. - /// Override with `csrf_protection`. - pub fn auth_cookie(mut self, cookie: ras_auth_core::AuthCookieConfig) -> Self { - self.auth_transport.cookie = Some(cookie); - if self.auth_transport.csrf.is_none() { - self.auth_transport.csrf = Some(ras_auth_core::CsrfConfig::default()); - } - self - } - - /// Replace the full auth transport configuration. - pub fn auth_transport(mut self, transport: ras_auth_core::AuthTransportConfig) -> Self { - self.auth_transport = transport; - self - } - - /// Require CSRF validation for cookie-authenticated unsafe requests. - pub fn csrf_protection(mut self, csrf: ras_auth_core::CsrfConfig) -> Self { - self.auth_transport.csrf = Some(csrf); - self - } - - /// Set the usage tracker - called before each request - /// The tracker receives the headers, authenticated user (if any), HTTP method, and path - pub fn with_usage_tracker(mut self, tracker: F) -> Self - where - F: Fn(&axum::http::HeaderMap, Option<&ras_auth_core::AuthenticatedUser>, &str, &str) -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, - { - self.with_usage_tracker = Some(std::sync::Arc::new(move |headers, user, method, path| { - Box::pin(tracker(headers, user, method, path)) - })); - self - } - - /// Set the method duration tracker - called after each request completes - /// The tracker receives the HTTP method, path, authenticated user (if any), and execution duration - pub fn with_method_duration_tracker(mut self, tracker: F) -> Self - where - F: Fn(&str, &str, Option<&ras_auth_core::AuthenticatedUser>, std::time::Duration) -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, - { - self.with_method_duration_tracker = Some(std::sync::Arc::new(move |method, path, user, duration| { - Box::pin(tracker(method, path, user, duration)) - })); - self - } - - /// Build the axum router for the REST service - pub fn build(self) -> axum::Router { - self.auth_transport - .validate() - .expect("invalid auth transport configuration"); - - #provider_assertion - - let mut router = axum::Router::new(); - - #(#route_registrations)* - - #static_routes - - if #base_path.is_empty() || #base_path == "/" { - router - } else { - axum::Router::new().nest(#base_path, router) - } - } - } - - } - - #cfg_server - pub use #server_mod::*; - } - } else { - quote! {} - }; - - let client_code = if feature_gated || cfg!(feature = "client") { - quote! { - #cfg_client - mod #client_mod { - use super::*; - - #client_impl - } - - #cfg_client - pub use #client_mod::*; - } - } else { - quote! {} - }; - - let output = quote! { - #permissions_code - #server_code - #client_code - }; - - Ok(output) -} - -fn rest_permission_groups_code(auth: &AuthRequirement) -> proc_macro2::TokenStream { - let permission_groups = match auth { - AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => Vec::new(), - AuthRequirement::WithPermissions(groups) => groups.clone(), - }; - - if permission_groups.is_empty() { - quote! { Vec::>::new() } - } else { - let groups = permission_groups.iter().map(|group| { - let perms = group.iter(); - quote! { vec![#(#perms.to_string()),*] } - }); - quote! { vec![#(#groups),*] as Vec> } - } -} - -fn pascal_ident_segment(value: &str) -> String { - let mut out = String::new(); - let mut uppercase_next = true; - - for ch in value.chars() { - if ch.is_ascii_alphanumeric() { - if uppercase_next { - out.push(ch.to_ascii_uppercase()); - uppercase_next = false; - } else { - out.push(ch); - } - } else { - uppercase_next = true; - } - } - - if out.is_empty() { - "Version".to_string() - } else if out.chars().next().is_some_and(|ch| ch.is_ascii_digit()) { - format!("V{out}") - } else { - out - } -} - -fn rest_request_part_idents( - service_name: &Ident, - handler_name: &Ident, - version: &str, -) -> (Ident, Ident, Ident) { - let service = service_name.to_string(); - let handler = pascal_ident_segment(&handler_name.to_string()); - let version = pascal_ident_segment(version); - let request_ident = quote::format_ident!("{}{}{}Request", service, handler, version); - let path_ident = quote::format_ident!("{}{}{}Path", service, handler, version); - let query_ident = quote::format_ident!("{}{}{}Query", service, handler, version); - (request_ident, path_ident, query_ident) -} - -fn rest_body_type_tokens(request_type: Option<&Type>) -> proc_macro2::TokenStream { - match request_type { - Some(request_type) => quote! { #request_type }, - None => quote! { () }, - } -} - -fn generate_rest_request_part_structs(service_def: &ServiceDefinition) -> proc_macro2::TokenStream { - let structs = service_def.endpoints.iter().flat_map(|endpoint| { - if endpoint.versions.is_empty() { - return Vec::new(); - } - - let canonical_version = endpoint.version.as_deref().unwrap_or("current"); - let mut structs = vec![generate_rest_request_part_struct( - &service_def.service_name, - &endpoint.handler_name, - canonical_version, - &endpoint.path_params, - &endpoint.query_params, - endpoint.request_type.as_ref(), - )]; - - structs.extend(endpoint.versions.iter().map(|version| { - generate_rest_request_part_struct( - &service_def.service_name, - &endpoint.handler_name, - &version.version, - &version.path_params, - &version.query_params, - version.request_type.as_ref(), - ) - })); - - structs - }); - - quote! { - #(#structs)* - } -} - -fn generate_rest_request_part_struct( - service_name: &Ident, - handler_name: &Ident, - version: &str, - path_params: &[PathParam], - query_params: &[QueryParam], - request_type: Option<&Type>, -) -> proc_macro2::TokenStream { - let (request_ident, path_ident, query_ident) = - rest_request_part_idents(service_name, handler_name, version); - let path_fields = path_params.iter().map(|param| { - let name = ¶m.name; - let param_type = ¶m.param_type; - quote! { pub #name: #param_type } - }); - let query_fields = query_params.iter().map(|param| { - let name = ¶m.name; - let param_type = ¶m.param_type; - quote! { pub #name: #param_type } - }); - let body_type = rest_body_type_tokens(request_type); - - quote! { - pub struct #path_ident { - #(#path_fields),* - } - - pub struct #query_ident { - #(#query_fields),* - } - - pub struct #request_ident { - pub path: #path_ident, - pub query: #query_ident, - pub body: #body_type, - } - } -} - -fn generate_rest_parts_init( - service_name: &Ident, - handler_name: &Ident, - version: &str, - path_params: &[PathParam], - query_params: &[QueryParam], - request_type: Option<&Type>, -) -> proc_macro2::TokenStream { - let (request_ident, path_ident, query_ident) = - rest_request_part_idents(service_name, handler_name, version); - - let path_values = path_params.iter().enumerate().map(|(idx, param)| { - let name = ¶m.name; - if path_params.len() == 1 { - quote! { #name: path_params } - } else { - let idx = syn::Index::from(idx); - quote! { #name: path_params.#idx } - } - }); - - let query_values = query_params.iter().map(|param| { - let name = ¶m.name; - quote! { #name: query_params.#name } - }); - - let body_value = if request_type.is_some() { - quote! { body } - } else { - quote! { () } - }; - - quote! { - #request_ident { - path: #path_ident { - #(#path_values),* - }, - query: #query_ident { - #(#query_values),* - }, - body: #body_value, - } - } -} - -fn rest_canonical_args_from_parts( - endpoint: &EndpointDefinition, - parts_ident: &Ident, -) -> Vec { - let mut args = Vec::new(); - - for path_param in &endpoint.path_params { - let name = &path_param.name; - args.push(quote! { #parts_ident.path.#name }); - } - - for query_param in &endpoint.query_params { - let name = &query_param.name; - args.push(quote! { #parts_ident.query.#name }); - } - - if endpoint.request_type.is_some() { - args.push(quote! { #parts_ident.body }); - } - - args -} - -fn generate_query_struct( - struct_name: &Ident, - query_params: &[QueryParam], -) -> proc_macro2::TokenStream { - if query_params.is_empty() { - return quote! {}; - } - - let fields = query_params.iter().map(|param| { - let name = ¶m.name; - let param_type = ¶m.param_type; - quote! { pub #name: #param_type } - }); - - quote! { - #[derive(serde::Deserialize)] - pub(super) struct #struct_name { - #(#fields),* - } - } -} - -fn generate_canonical_route_registration( - endpoint: &EndpointDefinition, - query_struct_name: &Ident, - require_json: bool, -) -> proc_macro2::TokenStream { - let method_routing = endpoint.method.as_axum_method(); - let path = &endpoint.path; - let handler_name = &endpoint.handler_name; - let method_str = endpoint.method.as_str(); - let AxumHandlerParts { - extractors: axum_handler, - prelude: extractor_prelude, - } = generate_axum_handler( - &endpoint.path_params, - &endpoint.query_params, - endpoint.request_type.as_ref(), - query_struct_name, - method_str, - path, - ); - let handler_body = - generate_handler_body(endpoint, handler_name, method_str, path, require_json); - let permission_groups_code = rest_permission_groups_code(&endpoint.auth); - - quote! { - { - let service = self.service.clone(); - let auth_provider = self.auth_provider.clone(); - let auth_transport = self.auth_transport.clone(); - let required_permission_groups: Vec> = #permission_groups_code; - let with_usage_tracker = self.with_usage_tracker.clone(); - let with_method_duration_tracker = self.with_method_duration_tracker.clone(); - - router = router.route(#path, #method_routing({ - move |#axum_handler| { - let service = service.clone(); - let auth_provider = auth_provider.clone(); - let auth_transport = auth_transport.clone(); - let required_permission_groups: Vec> = required_permission_groups.clone(); - let with_usage_tracker = with_usage_tracker.clone(); - let with_method_duration_tracker = with_method_duration_tracker.clone(); - - async move { - #extractor_prelude - #handler_body - } - } - })); - } - } -} - -fn generate_legacy_route_registration( - service_name: &Ident, - endpoint: &EndpointDefinition, - version: &EndpointVersionDefinition, - query_struct_name: &Ident, - require_json: bool, -) -> proc_macro2::TokenStream { - let method_routing = endpoint.method.as_axum_method(); - let path = &version.path; - let AxumHandlerParts { - extractors: axum_handler, - prelude: extractor_prelude, - } = generate_axum_handler( - &version.path_params, - &version.query_params, - version.request_type.as_ref(), - query_struct_name, - endpoint.method.as_str(), - path, - ); - let handler_body = generate_legacy_handler_body(service_name, endpoint, version, require_json); - let permission_groups_code = rest_permission_groups_code(&endpoint.auth); - - quote! { - { - let service = self.service.clone(); - let auth_provider = self.auth_provider.clone(); - let auth_transport = self.auth_transport.clone(); - let required_permission_groups: Vec> = #permission_groups_code; - let with_usage_tracker = self.with_usage_tracker.clone(); - let with_method_duration_tracker = self.with_method_duration_tracker.clone(); - - router = router.route(#path, #method_routing({ - move |#axum_handler| { - let service = service.clone(); - let auth_provider = auth_provider.clone(); - let auth_transport = auth_transport.clone(); - let required_permission_groups: Vec> = required_permission_groups.clone(); - let with_usage_tracker = with_usage_tracker.clone(); - let with_method_duration_tracker = with_method_duration_tracker.clone(); - - async move { - #extractor_prelude - #handler_body - } - } - })); - } - } -} - -fn generate_legacy_handler_body( - service_name: &Ident, - endpoint: &EndpointDefinition, - version: &EndpointVersionDefinition, - require_json: bool, -) -> proc_macro2::TokenStream { - let handler_name = &endpoint.handler_name; - let method = endpoint.method.as_str(); - let path = &version.path; - let body_limit_tokens = effective_body_limit_tokens(endpoint); - let migration_type = &version.migration_type; - let canonical_response_type = &endpoint.response_type; - let legacy_response_type = &version.response_type; - let canonical_version = endpoint.version.as_deref().unwrap_or("current"); - let (canonical_request_ident, _, _) = - rest_request_part_idents(service_name, handler_name, canonical_version); - let (legacy_request_ident, _, _) = - rest_request_part_idents(service_name, handler_name, &version.version); - let legacy_parts_init = generate_rest_parts_init( - service_name, - handler_name, - &version.version, - &version.path_params, - &version.query_params, - version.request_type.as_ref(), - ); - let canonical_parts_ident = quote::format_ident!("canonical_parts"); - let mut canonical_args = rest_canonical_args_from_parts(endpoint, &canonical_parts_ident); - - // Opt-in request headers, inserted before the auth arg is prepended below so - // the final order is [caller/user?, headers, path.., query.., body?]. - if endpoint.with_headers { - canonical_args.insert(0, quote! { headers.clone() }); - } - - let json_handling = if version.request_type.is_some() { - generate_body_extraction(require_json, &body_limit_tokens, method, path) - } else { - quote! {} - }; - - match &endpoint.auth { - AuthRequirement::Unauthorized => quote! { - #json_handling - - if let Some(tracker) = &with_usage_tracker { - let tracker_headers = - ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); - tracker(&tracker_headers, None, #method, #path).await; - } - - let legacy_parts: #legacy_request_ident = #legacy_parts_init; - let #canonical_parts_ident: #canonical_request_ident = - match <#migration_type as ras_rest_core::VersionMigration<#legacy_request_ident, #canonical_request_ident>>::migrate(legacy_parts) { - Ok(parts) => parts, - Err(e) => { - use axum::response::IntoResponse; - return ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ - "error": e.to_string() - })) - ).into_response(); - }, - }; - - let start_time = std::time::Instant::now(); - - let result = match service.#handler_name(#(#canonical_args),*).await { - Ok(rest_response) => { - use axum::response::IntoResponse; - let status_code = axum::http::StatusCode::from_u16(rest_response.status) - .unwrap_or(axum::http::StatusCode::OK); - let body: #legacy_response_type = - match <#migration_type as ras_rest_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(rest_response.body) { - Ok(body) => body, - Err(e) => { - ras_rest_core::tracing::error!(error = %e, "Response migration failed"); - return ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - axum::Json(serde_json::json!({ - "error": "Internal server error" - })) - ).into_response(); - }, - }; - __ras_success_response(status_code, body) - }, - Err(rest_error) => { - use axum::response::IntoResponse; - - if let Some(internal) = &rest_error.internal_error { - ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); - } - - let status_code = axum::http::StatusCode::from_u16(rest_error.status) - .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); - - ( - status_code, - axum::Json(serde_json::json!({ - "error": &rest_error.message - })) - ).into_response() - }, - }; - - let duration = start_time.elapsed(); - if let Some(tracker) = &with_method_duration_tracker { - tracker(#method, #path, None, duration).await; - } - - result - }, - AuthRequirement::OptionalAuth => { - canonical_args.insert(0, quote! { caller }); - - quote! { - // Best-effort authentication for an OPTIONAL_AUTH route — never - // rejected: resolves to Caller::Anonymous for a missing/invalid - // credential, Caller::Authenticated for a valid one. - let caller = ras_auth_core::resolve_caller( - #method, - &headers, - &auth_transport, - auth_provider.as_deref(), - ).await; - // Snapshot the user for tracking; `caller` is moved into the handler. - let __ras_caller_user = caller.authenticated().cloned(); - - #json_handling - - if let Some(tracker) = &with_usage_tracker { - let tracker_headers = - ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); - tracker(&tracker_headers, __ras_caller_user.as_ref(), #method, #path).await; - } - - let legacy_parts: #legacy_request_ident = #legacy_parts_init; - let #canonical_parts_ident: #canonical_request_ident = - match <#migration_type as ras_rest_core::VersionMigration<#legacy_request_ident, #canonical_request_ident>>::migrate(legacy_parts) { - Ok(parts) => parts, - Err(e) => { - use axum::response::IntoResponse; - return ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ - "error": e.to_string() - })) - ).into_response(); - }, - }; - - let start_time = std::time::Instant::now(); - - let result = match service.#handler_name(#(#canonical_args),*).await { - Ok(rest_response) => { - use axum::response::IntoResponse; - let status_code = axum::http::StatusCode::from_u16(rest_response.status) - .unwrap_or(axum::http::StatusCode::OK); - let body: #legacy_response_type = - match <#migration_type as ras_rest_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(rest_response.body) { - Ok(body) => body, - Err(e) => { - ras_rest_core::tracing::error!(error = %e, "Response migration failed"); - return ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - axum::Json(serde_json::json!({ - "error": "Internal server error" - })) - ).into_response(); - }, - }; - __ras_success_response(status_code, body) - }, - Err(rest_error) => { - use axum::response::IntoResponse; - - if let Some(internal) = &rest_error.internal_error { - ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); - } - - let status_code = axum::http::StatusCode::from_u16(rest_error.status) - .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); - - ( - status_code, - axum::Json(serde_json::json!({ - "error": &rest_error.message - })) - ).into_response() - }, - }; - - let duration = start_time.elapsed(); - if let Some(tracker) = &with_method_duration_tracker { - tracker(#method, #path, __ras_caller_user.as_ref(), duration).await; - } - - result - } - } - AuthRequirement::WithPermissions(_) => { - canonical_args.insert(0, quote! { &user }); - - quote! { - // Authenticate and authorize: credential → CSRF → authenticate - // → OR-of-AND permission groups (shared ras-auth-core pipeline) - let user = match ras_auth_core::authorize_request( - #method, - &headers, - &auth_transport, - auth_provider.as_deref(), - &required_permission_groups, - ).await { - Ok(user) => user, - Err(error) => return __ras_authorize_error_response(error), - }; - - // Read and parse the body only after auth has succeeded - #json_handling - - if let Some(tracker) = &with_usage_tracker { - let tracker_headers = - ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); - tracker(&tracker_headers, Some(&user), #method, #path).await; - } - - let legacy_parts: #legacy_request_ident = #legacy_parts_init; - let #canonical_parts_ident: #canonical_request_ident = - match <#migration_type as ras_rest_core::VersionMigration<#legacy_request_ident, #canonical_request_ident>>::migrate(legacy_parts) { - Ok(parts) => parts, - Err(e) => { - use axum::response::IntoResponse; - return ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ - "error": e.to_string() - })) - ).into_response(); - }, - }; - - let start_time = std::time::Instant::now(); - - let result = match service.#handler_name(#(#canonical_args),*).await { - Ok(rest_response) => { - use axum::response::IntoResponse; - let status_code = axum::http::StatusCode::from_u16(rest_response.status) - .unwrap_or(axum::http::StatusCode::OK); - let body: #legacy_response_type = - match <#migration_type as ras_rest_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(rest_response.body) { - Ok(body) => body, - Err(e) => { - ras_rest_core::tracing::error!(error = %e, "Response migration failed"); - return ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - axum::Json(serde_json::json!({ - "error": "Internal server error" - })) - ).into_response(); - }, - }; - __ras_success_response(status_code, body) - }, - Err(rest_error) => { - use axum::response::IntoResponse; - - if let Some(internal) = &rest_error.internal_error { - ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); - } - - let status_code = axum::http::StatusCode::from_u16(rest_error.status) - .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); - - ( - status_code, - axum::Json(serde_json::json!({ - "error": &rest_error.message - })) - ).into_response() - }, - }; - - let duration = start_time.elapsed(); - if let Some(tracker) = &with_method_duration_tracker { - tracker(#method, #path, Some(&user), duration).await; - } - - result - } - } - } -} - -/// Generated handler signature plus the prelude that unwraps fallible -/// extractors inside the handler body. -struct AxumHandlerParts { - /// Closure parameter list (`headers`, path/query extractors, raw request). - extractors: proc_macro2::TokenStream, - /// Emitted at the top of the handler body. Path and query extractors are - /// taken as `Result<_, Rejection>` so the axum default rejection body — - /// which echoes the offending value verbatim, e.g. - /// ``Invalid URL: Cannot parse `abc` to a `i32` `` — is never sent to the - /// client. The detail is logged at `warn` and a fixed message is returned. - prelude: proc_macro2::TokenStream, -} - -fn generate_axum_handler( - path_params: &[PathParam], - query_params: &[QueryParam], - request_type: Option<&Type>, - query_struct_name: &Ident, - method: &str, - path: &str, -) -> AxumHandlerParts { - let mut extractors = Vec::new(); - let mut prelude = Vec::new(); - - extractors.push(quote! { headers: axum::http::HeaderMap }); - - if !path_params.is_empty() { - let path_param_types = path_params.iter().map(|param| ¶m.param_type); - let path_ty = if path_params.len() == 1 { - quote! { axum::extract::Path<#(#path_param_types)*> } - } else { - quote! { axum::extract::Path<(#(#path_param_types),*)> } - }; - extractors.push(quote! { - __ras_path: Result<#path_ty, axum::extract::rejection::PathRejection> - }); - prelude.push(quote! { - let axum::extract::Path(path_params) = match __ras_path { - Ok(path) => path, - Err(__ras_rejection) => { - use axum::response::IntoResponse; - ras_rest_core::tracing::warn!( - method = #method, - path = #path, - status = __ras_rejection.status().as_u16(), - detail = %ras_rest_core::sanitize_log_detail(&__ras_rejection.body_text()), - "rejected request: invalid path parameters" - ); - return ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ "error": "Invalid path parameters" })) - ).into_response(); - } - }; - }); - } - - if !query_params.is_empty() { - extractors.push(quote! { - __ras_query: Result< - ::axum_extra::extract::Query, - ::axum_extra::extract::QueryRejection, - > - }); - prelude.push(quote! { - let ::axum_extra::extract::Query(query_params) = match __ras_query { - Ok(query) => query, - Err(__ras_rejection) => { - use axum::response::IntoResponse; - ras_rest_core::tracing::warn!( - method = #method, - path = #path, - status = __ras_rejection.status().as_u16(), - detail = %ras_rest_core::sanitize_log_detail(&__ras_rejection.body_text()), - "rejected request: invalid query parameters" - ); - return ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ "error": "Invalid query parameters" })) - ).into_response(); - } - }; - }); - } - - // Take the raw request when a body is declared. The body is read and - // deserialized inside the handler AFTER auth/CSRF/permission checks, so - // unauthenticated clients cannot make the server buffer or parse payloads. - if request_type.is_some() { - extractors.push(quote! { request: axum::extract::Request }); - } - - AxumHandlerParts { - extractors: quote! { #(#extractors),* }, - prelude: quote! { #(#prelude)* }, - } -} - -/// Generated code that reads and JSON-deserializes the request body from the -/// raw `request` extractor, bounded by `limit`. -/// -/// For authenticated endpoints this must be emitted AFTER the -/// auth/CSRF/permission block so unauthenticated clients cannot make the -/// server buffer or parse payloads. -/// -/// Behavior: -/// * When `require_json` is set, a request whose `Content-Type` is not -/// `application/json` (ignoring parameters like `; charset=utf-8`) is rejected -/// with `415 Unsupported Media Type` before the body is read. Requiring -/// `application/json` also forces a CORS preflight for cross-origin requests, -/// which no CORS layer answers by default — defense-in-depth against -/// simple-request CSRF on cookie-authenticated endpoints. -/// * A declared `Content-Length` over `limit` is rejected with `413` up front so -/// a subsequent `to_bytes` error is unambiguously a read failure (`400`), -/// rather than the two being conflated as "too large". -/// * A malformed JSON body is logged (category + line/column, never the rejected -/// value) at `warn` before returning `400`, matching the handler-error logging -/// convention. -fn generate_body_extraction( - require_json: bool, - limit: &proc_macro2::TokenStream, - method: &str, - path: &str, -) -> proc_macro2::TokenStream { - let content_type_check = if require_json { - quote! { - { - let __ras_content_type_ok = headers - .get(axum::http::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(|value| { - value - .split(';') - .next() - .unwrap_or("") - .trim() - .eq_ignore_ascii_case("application/json") - }) - .unwrap_or(false); - if !__ras_content_type_ok { - use axum::response::IntoResponse; - ras_rest_core::tracing::warn!( - method = #method, - path = #path, - "rejected request: Content-Type is not application/json" - ); - return ( - axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, - axum::Json(serde_json::json!({ - "error": "Unsupported Media Type: expected application/json" - })) - ).into_response(); - } - } - } - } else { - quote! {} - }; - - quote! { - #content_type_check - - let body = { - // Reject an over-declared Content-Length up front so a 413 is - // unambiguous without reading the body. A chunked body with no - // declared length is still capped by `to_bytes`; that error is then - // classified below (over-limit -> 413, genuine read error -> 400). - if let Some(__ras_declared_len) = headers - .get(axum::http::header::CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()) - { - if __ras_declared_len > #limit { - use axum::response::IntoResponse; - ras_rest_core::tracing::warn!( - method = #method, - path = #path, - declared_len = __ras_declared_len, - limit = #limit, - "rejected request: body exceeds limit" - ); - return ( - axum::http::StatusCode::PAYLOAD_TOO_LARGE, - axum::Json(serde_json::json!({ - "error": "Request body too large" - })) - ).into_response(); - } - } - - let body_bytes = match ::axum::body::to_bytes(request.into_body(), #limit).await { - Ok(bytes) => bytes, - Err(__ras_body_err) => { - use axum::response::IntoResponse; - // `to_bytes` fails for both an over-limit body and a genuine - // stream read error. axum wraps http_body_util's - // `LengthLimitError` (Display: "length limit exceeded") for - // the former; classify on it so a read failure is a 400 and - // only a real overflow is a 413. - let (__ras_status, __ras_client_msg) = - if __ras_body_err.to_string().contains("length limit exceeded") { - ( - axum::http::StatusCode::PAYLOAD_TOO_LARGE, - "Request body too large", - ) - } else { - ( - axum::http::StatusCode::BAD_REQUEST, - "Could not read request body", - ) - }; - ras_rest_core::tracing::warn!( - method = #method, - path = #path, - status = __ras_status.as_u16(), - "rejected request: {}", - __ras_client_msg - ); - return ( - __ras_status, - axum::Json(serde_json::json!({ "error": __ras_client_msg })) - ).into_response(); - }, - }; - match serde_json::from_slice(&body_bytes) { - Ok(body) => body, - Err(__ras_json_err) => { - use axum::response::IntoResponse; - // Log the classification and location so the reason is - // recoverable server-side; never log the rejected value. - ras_rest_core::tracing::warn!( - method = #method, - path = #path, - category = ?__ras_json_err.classify(), - line = __ras_json_err.line(), - column = __ras_json_err.column(), - "rejected request: malformed JSON body" - ); - return ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ - "error": "Invalid JSON" - })) - ).into_response(); - }, - } - }; - } -} - -/// The effective body-size limit expression for an endpoint: its per-endpoint -/// `body_limit` override when set, otherwise the service-level `__RAS_BODY_LIMIT`. -fn effective_body_limit_tokens(endpoint: &EndpointDefinition) -> proc_macro2::TokenStream { - match endpoint.body_limit { - Some(limit) => quote! { #limit }, - None => quote! { __RAS_BODY_LIMIT }, - } -} - -fn generate_handler_body( - endpoint: &EndpointDefinition, - handler_name: &Ident, - method: &str, - path: &str, - require_json: bool, -) -> proc_macro2::TokenStream { - let body_limit_tokens = effective_body_limit_tokens(endpoint); - match &endpoint.auth { - AuthRequirement::Unauthorized => { - let mut args = Vec::new(); - - // Opt-in request headers (before path params) - if endpoint.with_headers { - args.push(quote! { headers.clone() }); - } - - if endpoint.path_params.len() == 1 { - args.push(quote! { path_params }); - } else { - for (i, _) in endpoint.path_params.iter().enumerate() { - let idx = syn::Index::from(i); - args.push(quote! { path_params.#idx }); - } - } - - for query_param in &endpoint.query_params { - let param_name = &query_param.name; - args.push(quote! { query_params.#param_name }); - } - - let json_handling = if endpoint.request_type.is_some() { - args.push(quote! { body }); - generate_body_extraction(require_json, &body_limit_tokens, method, path) - } else { - quote! {} - }; - - quote! { - #json_handling - - if let Some(tracker) = &with_usage_tracker { - let tracker_headers = - ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); - tracker(&tracker_headers, None, #method, #path).await; - } - - let start_time = std::time::Instant::now(); - - let result = match service.#handler_name(#(#args),*).await { - Ok(rest_response) => { - let status_code = axum::http::StatusCode::from_u16(rest_response.status) - .unwrap_or(axum::http::StatusCode::OK); - __ras_success_response(status_code, rest_response.body) - }, - Err(rest_error) => { - use axum::response::IntoResponse; - - if let Some(internal) = &rest_error.internal_error { - ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); - } - - let status_code = axum::http::StatusCode::from_u16(rest_error.status) - .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); - - ( - status_code, - axum::Json(serde_json::json!({ - "error": &rest_error.message - })) - ).into_response() - }, - }; - - let duration = start_time.elapsed(); - if let Some(tracker) = &with_method_duration_tracker { - tracker(#method, #path, None, duration).await; - } - - result - } - } - AuthRequirement::OptionalAuth => { - // Build argument list; the caller is passed by value as the first arg. - let mut args = vec![quote! { caller }]; - - // Opt-in request headers (after the caller, before path params) - if endpoint.with_headers { - args.push(quote! { headers.clone() }); - } - - if endpoint.path_params.len() == 1 { - args.push(quote! { path_params }); - } else { - for (i, _) in endpoint.path_params.iter().enumerate() { - let idx = syn::Index::from(i); - args.push(quote! { path_params.#idx }); - } - } - - for query_param in &endpoint.query_params { - let param_name = &query_param.name; - args.push(quote! { query_params.#param_name }); - } - - let json_handling = if endpoint.request_type.is_some() { - args.push(quote! { body }); - generate_body_extraction(require_json, &body_limit_tokens, method, path) - } else { - quote! {} - }; - - quote! { - // Best-effort authentication for an OPTIONAL_AUTH route: never - // rejected — Caller::Anonymous when no/invalid credential is - // present, Caller::Authenticated otherwise. - let caller = ras_auth_core::resolve_caller( - #method, - &headers, - &auth_transport, - auth_provider.as_deref(), - ).await; - // Snapshot the user for tracking; `caller` is moved into the handler. - let __ras_caller_user = caller.authenticated().cloned(); - - #json_handling - - if let Some(tracker) = &with_usage_tracker { - let tracker_headers = - ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); - tracker(&tracker_headers, __ras_caller_user.as_ref(), #method, #path).await; - } - - let start_time = std::time::Instant::now(); - - let result = match service.#handler_name(#(#args),*).await { - Ok(rest_response) => { - let status_code = axum::http::StatusCode::from_u16(rest_response.status) - .unwrap_or(axum::http::StatusCode::OK); - __ras_success_response(status_code, rest_response.body) - }, - Err(rest_error) => { - use axum::response::IntoResponse; - - if let Some(internal) = &rest_error.internal_error { - ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); - } - - let status_code = axum::http::StatusCode::from_u16(rest_error.status) - .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); - - ( - status_code, - axum::Json(serde_json::json!({ - "error": &rest_error.message - })) - ).into_response() - }, - }; - - let duration = start_time.elapsed(); - if let Some(tracker) = &with_method_duration_tracker { - tracker(#method, #path, __ras_caller_user.as_ref(), duration).await; - } - - result - } - } - AuthRequirement::WithPermissions(_) => { - let mut args = vec![quote! { &user }]; - - // Opt-in request headers (after the user, before path params) - if endpoint.with_headers { - args.push(quote! { headers.clone() }); - } - - if endpoint.path_params.len() == 1 { - args.push(quote! { path_params }); - } else { - for (i, _) in endpoint.path_params.iter().enumerate() { - let idx = syn::Index::from(i); - args.push(quote! { path_params.#idx }); - } - } - - for query_param in &endpoint.query_params { - let param_name = &query_param.name; - args.push(quote! { query_params.#param_name }); - } - - let json_handling = if endpoint.request_type.is_some() { - args.push(quote! { body }); - generate_body_extraction(require_json, &body_limit_tokens, method, path) - } else { - quote! {} - }; - - quote! { - // Authenticate and authorize: credential → CSRF → authenticate - // → OR-of-AND permission groups (shared ras-auth-core pipeline) - let user = match ras_auth_core::authorize_request( - #method, - &headers, - &auth_transport, - auth_provider.as_deref(), - &required_permission_groups, - ).await { - Ok(user) => user, - Err(error) => return __ras_authorize_error_response(error), - }; - - // Read and parse the body only after auth has succeeded - #json_handling - - if let Some(tracker) = &with_usage_tracker { - let tracker_headers = - ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); - tracker(&tracker_headers, Some(&user), #method, #path).await; - } - - let start_time = std::time::Instant::now(); - - let result = match service.#handler_name(#(#args),*).await { - Ok(rest_response) => { - let status_code = axum::http::StatusCode::from_u16(rest_response.status) - .unwrap_or(axum::http::StatusCode::OK); - __ras_success_response(status_code, rest_response.body) - }, - Err(rest_error) => { - use axum::response::IntoResponse; - - if let Some(internal) = &rest_error.internal_error { - ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); - } - - let status_code = axum::http::StatusCode::from_u16(rest_error.status) - .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); - - ( - status_code, - axum::Json(serde_json::json!({ - "error": &rest_error.message - })) - ).into_response() - }, - }; - - let duration = start_time.elapsed(); - if let Some(tracker) = &with_method_duration_tracker { - tracker(#method, #path, Some(&user), duration).await; - } - - result - } - } - } -} - -impl HttpMethod { - fn as_axum_method(&self) -> proc_macro2::TokenStream { - match self { - HttpMethod::Get => quote! { axum::routing::get }, - HttpMethod::Post => quote! { axum::routing::post }, - HttpMethod::Put => quote! { axum::routing::put }, - HttpMethod::Delete => quote! { axum::routing::delete }, - HttpMethod::Patch => quote! { axum::routing::patch }, - } - } -} diff --git a/crates/rest/ras-rest-macro/src/server/auth.rs b/crates/rest/ras-rest-macro/src/server/auth.rs new file mode 100644 index 0000000..a15c576 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/server/auth.rs @@ -0,0 +1,21 @@ +//! Permission requirements for route registration. + +use crate::ast::*; +use quote::quote; + +pub(super) fn rest_permission_groups_code(auth: &AuthRequirement) -> proc_macro2::TokenStream { + let permission_groups = match auth { + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => Vec::new(), + AuthRequirement::WithPermissions(groups) => groups.clone(), + }; + + if permission_groups.is_empty() { + quote! { Vec::>::new() } + } else { + let groups = permission_groups.iter().map(|group| { + let perms = group.iter(); + quote! { vec![#(#perms.to_string()),*] } + }); + quote! { vec![#(#groups),*] as Vec> } + } +} diff --git a/crates/rest/ras-rest-macro/src/server/handlers.rs b/crates/rest/ras-rest-macro/src/server/handlers.rs new file mode 100644 index 0000000..7a16f58 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/server/handlers.rs @@ -0,0 +1,264 @@ +//! Canonical handler invocation and response handling. + +use super::request::{effective_body_limit_tokens, generate_body_extraction}; +use crate::ast::*; +use quote::quote; +use syn::Ident; + +pub(super) fn generate_handler_body( + endpoint: &EndpointDefinition, + handler_name: &Ident, + method: &str, + path: &str, + require_json: bool, +) -> proc_macro2::TokenStream { + let body_limit_tokens = effective_body_limit_tokens(endpoint); + match &endpoint.auth { + AuthRequirement::Unauthorized => { + let mut args = Vec::new(); + + // Opt-in request headers (before path params) + if endpoint.with_headers { + args.push(quote! { headers.clone() }); + } + + if endpoint.path_params.len() == 1 { + args.push(quote! { path_params }); + } else { + for (i, _) in endpoint.path_params.iter().enumerate() { + let idx = syn::Index::from(i); + args.push(quote! { path_params.#idx }); + } + } + + for query_param in &endpoint.query_params { + let param_name = &query_param.name; + args.push(quote! { query_params.#param_name }); + } + + let json_handling = if endpoint.request_type.is_some() { + args.push(quote! { body }); + generate_body_extraction(require_json, &body_limit_tokens, method, path) + } else { + quote! {} + }; + + quote! { + #json_handling + + if let Some(tracker) = &with_usage_tracker { + let tracker_headers = + ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); + tracker(&tracker_headers, None, #method, #path).await; + } + + let start_time = std::time::Instant::now(); + + let result = match service.#handler_name(#(#args),*).await { + Ok(rest_response) => { + let status_code = axum::http::StatusCode::from_u16(rest_response.status) + .unwrap_or(axum::http::StatusCode::OK); + __ras_success_response(status_code, rest_response.body) + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + } + + let status_code = axum::http::StatusCode::from_u16(rest_error.status) + .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + + ( + status_code, + axum::Json(serde_json::json!({ + "error": &rest_error.message + })) + ).into_response() + }, + }; + + let duration = start_time.elapsed(); + if let Some(tracker) = &with_method_duration_tracker { + tracker(#method, #path, None, duration).await; + } + + result + } + } + AuthRequirement::OptionalAuth => { + // Build argument list; the caller is passed by value as the first arg. + let mut args = vec![quote! { caller }]; + + // Opt-in request headers (after the caller, before path params) + if endpoint.with_headers { + args.push(quote! { headers.clone() }); + } + + if endpoint.path_params.len() == 1 { + args.push(quote! { path_params }); + } else { + for (i, _) in endpoint.path_params.iter().enumerate() { + let idx = syn::Index::from(i); + args.push(quote! { path_params.#idx }); + } + } + + for query_param in &endpoint.query_params { + let param_name = &query_param.name; + args.push(quote! { query_params.#param_name }); + } + + let json_handling = if endpoint.request_type.is_some() { + args.push(quote! { body }); + generate_body_extraction(require_json, &body_limit_tokens, method, path) + } else { + quote! {} + }; + + quote! { + // Best-effort authentication for an OPTIONAL_AUTH route: never + // rejected — Caller::Anonymous when no/invalid credential is + // present, Caller::Authenticated otherwise. + let caller = ras_auth_core::resolve_caller( + #method, + &headers, + &auth_transport, + auth_provider.as_deref(), + ).await; + // Snapshot the user for tracking; `caller` is moved into the handler. + let __ras_caller_user = caller.authenticated().cloned(); + + #json_handling + + if let Some(tracker) = &with_usage_tracker { + let tracker_headers = + ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); + tracker(&tracker_headers, __ras_caller_user.as_ref(), #method, #path).await; + } + + let start_time = std::time::Instant::now(); + + let result = match service.#handler_name(#(#args),*).await { + Ok(rest_response) => { + let status_code = axum::http::StatusCode::from_u16(rest_response.status) + .unwrap_or(axum::http::StatusCode::OK); + __ras_success_response(status_code, rest_response.body) + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + } + + let status_code = axum::http::StatusCode::from_u16(rest_error.status) + .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + + ( + status_code, + axum::Json(serde_json::json!({ + "error": &rest_error.message + })) + ).into_response() + }, + }; + + let duration = start_time.elapsed(); + if let Some(tracker) = &with_method_duration_tracker { + tracker(#method, #path, __ras_caller_user.as_ref(), duration).await; + } + + result + } + } + AuthRequirement::WithPermissions(_) => { + let mut args = vec![quote! { &user }]; + + // Opt-in request headers (after the user, before path params) + if endpoint.with_headers { + args.push(quote! { headers.clone() }); + } + + if endpoint.path_params.len() == 1 { + args.push(quote! { path_params }); + } else { + for (i, _) in endpoint.path_params.iter().enumerate() { + let idx = syn::Index::from(i); + args.push(quote! { path_params.#idx }); + } + } + + for query_param in &endpoint.query_params { + let param_name = &query_param.name; + args.push(quote! { query_params.#param_name }); + } + + let json_handling = if endpoint.request_type.is_some() { + args.push(quote! { body }); + generate_body_extraction(require_json, &body_limit_tokens, method, path) + } else { + quote! {} + }; + + quote! { + // Authenticate and authorize: credential → CSRF → authenticate + // → OR-of-AND permission groups (shared ras-auth-core pipeline) + let user = match ras_auth_core::authorize_request( + #method, + &headers, + &auth_transport, + auth_provider.as_deref(), + &required_permission_groups, + ).await { + Ok(user) => user, + Err(error) => return __ras_authorize_error_response(error), + }; + + // Read and parse the body only after auth has succeeded + #json_handling + + if let Some(tracker) = &with_usage_tracker { + let tracker_headers = + ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); + tracker(&tracker_headers, Some(&user), #method, #path).await; + } + + let start_time = std::time::Instant::now(); + + let result = match service.#handler_name(#(#args),*).await { + Ok(rest_response) => { + let status_code = axum::http::StatusCode::from_u16(rest_response.status) + .unwrap_or(axum::http::StatusCode::OK); + __ras_success_response(status_code, rest_response.body) + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + } + + let status_code = axum::http::StatusCode::from_u16(rest_error.status) + .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + + ( + status_code, + axum::Json(serde_json::json!({ + "error": &rest_error.message + })) + ).into_response() + }, + }; + + let duration = start_time.elapsed(); + if let Some(tracker) = &with_method_duration_tracker { + tracker(#method, #path, Some(&user), duration).await; + } + + result + } + } + } +} diff --git a/crates/rest/ras-rest-macro/src/server/mod.rs b/crates/rest/ras-rest-macro/src/server/mod.rs new file mode 100644 index 0000000..7b157a0 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/server/mod.rs @@ -0,0 +1,325 @@ +//! Server trait, builder, and route assembly. + +use crate::{ast::*, static_hosting}; +use quote::quote; +mod auth; +mod handlers; +mod request; +mod routes; +mod versioned; +use request::{generate_query_struct, generate_rest_request_part_structs}; +use routes::{generate_canonical_route_registration, generate_legacy_route_registration}; + +pub(crate) fn generate_server_code( + service_def: &ServiceDefinition, + schema_checks: proc_macro2::TokenStream, + openapi_code: proc_macro2::TokenStream, + static_hosting_code: proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + let service_name = &service_def.service_name; + let service_trait_name = quote::format_ident!("{}Trait", service_name); + let builder_name = quote::format_ident!("{}Builder", service_name); + let base_path = &service_def.base_path; + let trait_methods = service_def.endpoints.iter().map(|endpoint| { + let handler_name = &endpoint.handler_name; + let response_type = &endpoint.response_type; + + let mut params = Vec::new(); + match &endpoint.auth { + AuthRequirement::Unauthorized => {} + AuthRequirement::OptionalAuth => { + params.push(quote! { caller: ras_auth_core::Caller }); + } + AuthRequirement::WithPermissions(_) => { + params.push(quote! { user: &ras_auth_core::AuthenticatedUser }); + } + } + + // Opt-in request headers (immediately after the caller/user) + if endpoint.with_headers { + params.push(quote! { headers: axum::http::HeaderMap }); + } + + for path_param in &endpoint.path_params { + let param_name = &path_param.name; + let param_type = &path_param.param_type; + params.push(quote! { #param_name: #param_type }); + } + + for query_param in &endpoint.query_params { + let param_name = &query_param.name; + let param_type = &query_param.param_type; + params.push(quote! { #param_name: #param_type }); + } + + if let Some(request_type) = &endpoint.request_type { + params.push(quote! { request: #request_type }); + } + + quote! { + async fn #handler_name(&self, #(#params),*) -> ras_rest_core::RestResult<#response_type>; + } + }); + + let request_part_structs = generate_rest_request_part_structs(service_def); + + let mut query_structs: Vec = Vec::new(); + let mut route_registrations: Vec = Vec::new(); + let mut route_idx = 0usize; + + for endpoint in &service_def.endpoints { + let query_struct_name = quote::format_ident!("QueryParams{}", route_idx); + query_structs.push(generate_query_struct( + &query_struct_name, + &endpoint.query_params, + )); + route_registrations.push(generate_canonical_route_registration( + endpoint, + &query_struct_name, + service_def.require_json_content_type, + )); + route_idx += 1; + + for version in &endpoint.versions { + let query_struct_name = quote::format_ident!("QueryParams{}", route_idx); + query_structs.push(generate_query_struct( + &query_struct_name, + &version.query_params, + )); + route_registrations.push(generate_legacy_route_registration( + &service_def.service_name, + endpoint, + version, + &query_struct_name, + service_def.require_json_content_type, + )); + route_idx += 1; + } + } + + let static_routes = if service_def.static_hosting.serve_docs { + static_hosting::generate_static_routes(service_def, &service_def.static_hosting) + } else { + quote! {} + }; + + let body_limit = service_def.body_limit.unwrap_or(DEFAULT_BODY_LIMIT); + + // Startup assertion: a service with any WITH_PERMISSIONS route needs an auth + // provider, otherwise every such route returns a runtime 500 (NoAuthProvider) + // on first request. Catch the misconfiguration at build() instead. + let any_route_requires_auth = service_def + .endpoints + .iter() + .any(|endpoint| matches!(endpoint.auth, AuthRequirement::WithPermissions(_))) + || (service_def.static_hosting.serve_docs && service_def.docs_require_auth); + let service_name_str = service_name.to_string(); + let provider_assertion = if any_route_requires_auth { + quote! { + if self.auth_provider.is_none() { + panic!(concat!( + "REST service `", + #service_name_str, + "` has endpoints requiring authorization (WITH_PERMISSIONS) but no ", + "auth_provider was configured; call .auth_provider(...) before build()" + )); + } + } + } else { + quote! {} + }; + + quote! { + /// Maximum accepted JSON body size in bytes + #[allow(dead_code)] + const __RAS_BODY_LIMIT: usize = #body_limit; + + /// Map a shared authorization failure to this service's JSON error shape + #[allow(dead_code)] + fn __ras_authorize_error_response(error: ras_auth_core::AuthorizeError) -> axum::response::Response { + use axum::response::IntoResponse; + let (status, message) = match &error { + ras_auth_core::AuthorizeError::MissingCredential => ( + axum::http::StatusCode::UNAUTHORIZED, + "Missing or invalid Authorization header", + ), + ras_auth_core::AuthorizeError::CsrfValidationFailed => ( + axum::http::StatusCode::FORBIDDEN, + "CSRF validation failed", + ), + ras_auth_core::AuthorizeError::AuthenticationFailed(_) => ( + axum::http::StatusCode::UNAUTHORIZED, + "Authentication failed", + ), + ras_auth_core::AuthorizeError::NoAuthProvider => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "No auth provider configured", + ), + ras_auth_core::AuthorizeError::InsufficientPermissions(_) => ( + axum::http::StatusCode::FORBIDDEN, + "Insufficient permissions", + ), + }; + // Rejections are otherwise invisible to the usage/duration trackers, + // which only run on the post-auth happy path; log them here so a + // client hammering an endpoint with a bad credential is observable. + // The `error` detail is logged server-side only; the client gets the + // generic `message`. + ras_rest_core::tracing::warn!( + status = status.as_u16(), + error = ?error, + "request rejected during authorization" + ); + (status, axum::Json(serde_json::json!({ "error": message }))).into_response() + } + + /// Build a success response, omitting the JSON body for status codes that + /// must not carry one (`204 No Content`, `205 Reset Content`, + /// `304 Not Modified`). Without this, `RestResponse::no_content()` would + /// emit a `204` with a serialized `null` body and + /// `Content-Type: application/json`, which violates RFC 9110 and is + /// rejected by some proxies and clients. + #[allow(dead_code)] + fn __ras_success_response( + status: axum::http::StatusCode, + body: T, + ) -> axum::response::Response { + use axum::response::IntoResponse; + if status == axum::http::StatusCode::NO_CONTENT + || status == axum::http::StatusCode::RESET_CONTENT + || status == axum::http::StatusCode::NOT_MODIFIED + { + status.into_response() + } else { + (status, axum::Json(body)).into_response() + } + } + + /// Generated service trait + #[async_trait::async_trait] + #[allow(private_interfaces, private_bounds)] + pub trait #service_trait_name: Send + Sync + 'static { + #(#trait_methods)* + } + + /// Generated builder for the REST service + pub struct #builder_name { + service: std::sync::Arc, + auth_provider: Option>, + auth_transport: ras_auth_core::AuthTransportConfig, + with_usage_tracker: Option, &str, &str) -> std::pin::Pin + Send>> + Send + Sync>>, + with_method_duration_tracker: Option, std::time::Duration) -> std::pin::Pin + Send>> + Send + Sync>>, + } + + const _: () = { + #schema_checks + }; + + #openapi_code + + #static_hosting_code + + use self::query_params::*; + + mod query_params { + #[allow(unused_imports)] + use super::*; + + #(#query_structs)* + } + + #request_part_structs + + impl #builder_name { + /// Create a new builder with the service implementation + pub fn new(service: T) -> Self { + Self { + service: std::sync::Arc::new(service), + auth_provider: None, + auth_transport: ras_auth_core::AuthTransportConfig::default(), + with_usage_tracker: None, + with_method_duration_tracker: None, + } + } + + /// Set the auth provider + pub fn auth_provider(mut self, provider: A) -> Self { + self.auth_provider = Some(std::sync::Arc::new(provider)); + self + } + + /// Enable cookie authentication alongside bearer tokens. + /// + /// Installs a default double-submit CSRF config when none is set, + /// because cookie credentials are CSRF-exploitable on unsafe methods. + /// Override with `csrf_protection`. + pub fn auth_cookie(mut self, cookie: ras_auth_core::AuthCookieConfig) -> Self { + self.auth_transport.cookie = Some(cookie); + if self.auth_transport.csrf.is_none() { + self.auth_transport.csrf = Some(ras_auth_core::CsrfConfig::default()); + } + self + } + + /// Replace the full auth transport configuration. + pub fn auth_transport(mut self, transport: ras_auth_core::AuthTransportConfig) -> Self { + self.auth_transport = transport; + self + } + + /// Require CSRF validation for cookie-authenticated unsafe requests. + pub fn csrf_protection(mut self, csrf: ras_auth_core::CsrfConfig) -> Self { + self.auth_transport.csrf = Some(csrf); + self + } + + /// Set the usage tracker - called before each request + /// The tracker receives the headers, authenticated user (if any), HTTP method, and path + pub fn with_usage_tracker(mut self, tracker: F) -> Self + where + F: Fn(&axum::http::HeaderMap, Option<&ras_auth_core::AuthenticatedUser>, &str, &str) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + self.with_usage_tracker = Some(std::sync::Arc::new(move |headers, user, method, path| { + Box::pin(tracker(headers, user, method, path)) + })); + self + } + + /// Set the method duration tracker - called after each request completes + /// The tracker receives the HTTP method, path, authenticated user (if any), and execution duration + pub fn with_method_duration_tracker(mut self, tracker: F) -> Self + where + F: Fn(&str, &str, Option<&ras_auth_core::AuthenticatedUser>, std::time::Duration) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + self.with_method_duration_tracker = Some(std::sync::Arc::new(move |method, path, user, duration| { + Box::pin(tracker(method, path, user, duration)) + })); + self + } + + /// Build the axum router for the REST service + pub fn build(self) -> axum::Router { + self.auth_transport + .validate() + .expect("invalid auth transport configuration"); + + #provider_assertion + + let mut router = axum::Router::new(); + + #(#route_registrations)* + + #static_routes + + if #base_path.is_empty() || #base_path == "/" { + router + } else { + axum::Router::new().nest(#base_path, router) + } + } + } + + } +} diff --git a/crates/rest/ras-rest-macro/src/server/request.rs b/crates/rest/ras-rest-macro/src/server/request.rs new file mode 100644 index 0000000..6929b81 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/server/request.rs @@ -0,0 +1,479 @@ +//! Request-part models and fallible extraction. + +use crate::ast::*; +use quote::quote; +use syn::{Ident, Type}; + +pub(super) fn pascal_ident_segment(value: &str) -> String { + let mut out = String::new(); + let mut uppercase_next = true; + + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + if uppercase_next { + out.push(ch.to_ascii_uppercase()); + uppercase_next = false; + } else { + out.push(ch); + } + } else { + uppercase_next = true; + } + } + + if out.is_empty() { + "Version".to_string() + } else if out.chars().next().is_some_and(|ch| ch.is_ascii_digit()) { + format!("V{out}") + } else { + out + } +} + +pub(super) fn rest_request_part_idents( + service_name: &Ident, + handler_name: &Ident, + version: &str, +) -> (Ident, Ident, Ident) { + let service = service_name.to_string(); + let handler = pascal_ident_segment(&handler_name.to_string()); + let version = pascal_ident_segment(version); + let request_ident = quote::format_ident!("{}{}{}Request", service, handler, version); + let path_ident = quote::format_ident!("{}{}{}Path", service, handler, version); + let query_ident = quote::format_ident!("{}{}{}Query", service, handler, version); + (request_ident, path_ident, query_ident) +} + +pub(super) fn rest_body_type_tokens(request_type: Option<&Type>) -> proc_macro2::TokenStream { + match request_type { + Some(request_type) => quote! { #request_type }, + None => quote! { () }, + } +} + +pub(super) fn generate_rest_request_part_structs( + service_def: &ServiceDefinition, +) -> proc_macro2::TokenStream { + let structs = service_def.endpoints.iter().flat_map(|endpoint| { + if endpoint.versions.is_empty() { + return Vec::new(); + } + + let canonical_version = endpoint.version.as_deref().unwrap_or("current"); + let mut structs = vec![generate_rest_request_part_struct( + &service_def.service_name, + &endpoint.handler_name, + canonical_version, + &endpoint.path_params, + &endpoint.query_params, + endpoint.request_type.as_ref(), + )]; + + structs.extend(endpoint.versions.iter().map(|version| { + generate_rest_request_part_struct( + &service_def.service_name, + &endpoint.handler_name, + &version.version, + &version.path_params, + &version.query_params, + version.request_type.as_ref(), + ) + })); + + structs + }); + + quote! { + #(#structs)* + } +} + +pub(super) fn generate_rest_request_part_struct( + service_name: &Ident, + handler_name: &Ident, + version: &str, + path_params: &[PathParam], + query_params: &[QueryParam], + request_type: Option<&Type>, +) -> proc_macro2::TokenStream { + let (request_ident, path_ident, query_ident) = + rest_request_part_idents(service_name, handler_name, version); + let path_fields = path_params.iter().map(|param| { + let name = ¶m.name; + let param_type = ¶m.param_type; + quote! { pub #name: #param_type } + }); + let query_fields = query_params.iter().map(|param| { + let name = ¶m.name; + let param_type = ¶m.param_type; + quote! { pub #name: #param_type } + }); + let body_type = rest_body_type_tokens(request_type); + + quote! { + pub struct #path_ident { + #(#path_fields),* + } + + pub struct #query_ident { + #(#query_fields),* + } + + pub struct #request_ident { + pub path: #path_ident, + pub query: #query_ident, + pub body: #body_type, + } + } +} + +pub(super) fn generate_rest_parts_init( + service_name: &Ident, + handler_name: &Ident, + version: &str, + path_params: &[PathParam], + query_params: &[QueryParam], + request_type: Option<&Type>, +) -> proc_macro2::TokenStream { + let (request_ident, path_ident, query_ident) = + rest_request_part_idents(service_name, handler_name, version); + + let path_values = path_params.iter().enumerate().map(|(idx, param)| { + let name = ¶m.name; + if path_params.len() == 1 { + quote! { #name: path_params } + } else { + let idx = syn::Index::from(idx); + quote! { #name: path_params.#idx } + } + }); + + let query_values = query_params.iter().map(|param| { + let name = ¶m.name; + quote! { #name: query_params.#name } + }); + + let body_value = if request_type.is_some() { + quote! { body } + } else { + quote! { () } + }; + + quote! { + #request_ident { + path: #path_ident { + #(#path_values),* + }, + query: #query_ident { + #(#query_values),* + }, + body: #body_value, + } + } +} + +pub(super) fn rest_canonical_args_from_parts( + endpoint: &EndpointDefinition, + parts_ident: &Ident, +) -> Vec { + let mut args = Vec::new(); + + for path_param in &endpoint.path_params { + let name = &path_param.name; + args.push(quote! { #parts_ident.path.#name }); + } + + for query_param in &endpoint.query_params { + let name = &query_param.name; + args.push(quote! { #parts_ident.query.#name }); + } + + if endpoint.request_type.is_some() { + args.push(quote! { #parts_ident.body }); + } + + args +} + +pub(super) fn generate_query_struct( + struct_name: &Ident, + query_params: &[QueryParam], +) -> proc_macro2::TokenStream { + if query_params.is_empty() { + return quote! {}; + } + + let fields = query_params.iter().map(|param| { + let name = ¶m.name; + let param_type = ¶m.param_type; + quote! { pub #name: #param_type } + }); + + quote! { + #[derive(serde::Deserialize)] + pub(super) struct #struct_name { + #(#fields),* + } + } +} + +/// Generated handler signature plus the prelude that unwraps fallible +/// extractors inside the handler body. +pub(super) struct AxumHandlerParts { + /// Closure parameter list (`headers`, path/query extractors, raw request). + pub(super) extractors: proc_macro2::TokenStream, + /// Emitted at the top of the handler body. Path and query extractors are + /// taken as `Result<_, Rejection>` so the axum default rejection body — + /// which echoes the offending value verbatim, e.g. + /// ``Invalid URL: Cannot parse `abc` to a `i32` `` — is never sent to the + /// client. The detail is logged at `warn` and a fixed message is returned. + pub(super) prelude: proc_macro2::TokenStream, +} + +pub(super) fn generate_axum_handler( + path_params: &[PathParam], + query_params: &[QueryParam], + request_type: Option<&Type>, + query_struct_name: &Ident, + method: &str, + path: &str, +) -> AxumHandlerParts { + let mut extractors = Vec::new(); + let mut prelude = Vec::new(); + + extractors.push(quote! { headers: axum::http::HeaderMap }); + + if !path_params.is_empty() { + let path_param_types = path_params.iter().map(|param| ¶m.param_type); + let path_ty = if path_params.len() == 1 { + quote! { axum::extract::Path<#(#path_param_types)*> } + } else { + quote! { axum::extract::Path<(#(#path_param_types),*)> } + }; + extractors.push(quote! { + __ras_path: Result<#path_ty, axum::extract::rejection::PathRejection> + }); + prelude.push(quote! { + let axum::extract::Path(path_params) = match __ras_path { + Ok(path) => path, + Err(__ras_rejection) => { + use axum::response::IntoResponse; + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + status = __ras_rejection.status().as_u16(), + detail = %ras_rest_core::sanitize_log_detail(&__ras_rejection.body_text()), + "rejected request: invalid path parameters" + ); + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ "error": "Invalid path parameters" })) + ).into_response(); + } + }; + }); + } + + if !query_params.is_empty() { + extractors.push(quote! { + __ras_query: Result< + ::axum_extra::extract::Query, + ::axum_extra::extract::QueryRejection, + > + }); + prelude.push(quote! { + let ::axum_extra::extract::Query(query_params) = match __ras_query { + Ok(query) => query, + Err(__ras_rejection) => { + use axum::response::IntoResponse; + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + status = __ras_rejection.status().as_u16(), + detail = %ras_rest_core::sanitize_log_detail(&__ras_rejection.body_text()), + "rejected request: invalid query parameters" + ); + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ "error": "Invalid query parameters" })) + ).into_response(); + } + }; + }); + } + + // Take the raw request when a body is declared. The body is read and + // deserialized inside the handler AFTER auth/CSRF/permission checks, so + // unauthenticated clients cannot make the server buffer or parse payloads. + if request_type.is_some() { + extractors.push(quote! { request: axum::extract::Request }); + } + + AxumHandlerParts { + extractors: quote! { #(#extractors),* }, + prelude: quote! { #(#prelude)* }, + } +} + +/// Generated code that reads and JSON-deserializes the request body from the +/// raw `request` extractor, bounded by `limit`. +/// +/// For authenticated endpoints this must be emitted AFTER the +/// auth/CSRF/permission block so unauthenticated clients cannot make the +/// server buffer or parse payloads. +/// +/// Behavior: +/// * When `require_json` is set, a request whose `Content-Type` is not +/// `application/json` (ignoring parameters like `; charset=utf-8`) is rejected +/// with `415 Unsupported Media Type` before the body is read. Requiring +/// `application/json` also forces a CORS preflight for cross-origin requests, +/// which no CORS layer answers by default — defense-in-depth against +/// simple-request CSRF on cookie-authenticated endpoints. +/// * A declared `Content-Length` over `limit` is rejected with `413` up front so +/// a subsequent `to_bytes` error is unambiguously a read failure (`400`), +/// rather than the two being conflated as "too large". +/// * A malformed JSON body is logged (category + line/column, never the rejected +/// value) at `warn` before returning `400`, matching the handler-error logging +/// convention. +pub(super) fn generate_body_extraction( + require_json: bool, + limit: &proc_macro2::TokenStream, + method: &str, + path: &str, +) -> proc_macro2::TokenStream { + let content_type_check = if require_json { + quote! { + { + let __ras_content_type_ok = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or("") + .trim() + .eq_ignore_ascii_case("application/json") + }) + .unwrap_or(false); + if !__ras_content_type_ok { + use axum::response::IntoResponse; + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + "rejected request: Content-Type is not application/json" + ); + return ( + axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, + axum::Json(serde_json::json!({ + "error": "Unsupported Media Type: expected application/json" + })) + ).into_response(); + } + } + } + } else { + quote! {} + }; + + quote! { + #content_type_check + + let body = { + // Reject an over-declared Content-Length up front so a 413 is + // unambiguous without reading the body. A chunked body with no + // declared length is still capped by `to_bytes`; that error is then + // classified below (over-limit -> 413, genuine read error -> 400). + if let Some(__ras_declared_len) = headers + .get(axum::http::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + { + if __ras_declared_len > #limit { + use axum::response::IntoResponse; + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + declared_len = __ras_declared_len, + limit = #limit, + "rejected request: body exceeds limit" + ); + return ( + axum::http::StatusCode::PAYLOAD_TOO_LARGE, + axum::Json(serde_json::json!({ + "error": "Request body too large" + })) + ).into_response(); + } + } + + let body_bytes = match ::axum::body::to_bytes(request.into_body(), #limit).await { + Ok(bytes) => bytes, + Err(__ras_body_err) => { + use axum::response::IntoResponse; + // `to_bytes` fails for both an over-limit body and a genuine + // stream read error. axum wraps http_body_util's + // `LengthLimitError` (Display: "length limit exceeded") for + // the former; classify on it so a read failure is a 400 and + // only a real overflow is a 413. + let (__ras_status, __ras_client_msg) = + if __ras_body_err.to_string().contains("length limit exceeded") { + ( + axum::http::StatusCode::PAYLOAD_TOO_LARGE, + "Request body too large", + ) + } else { + ( + axum::http::StatusCode::BAD_REQUEST, + "Could not read request body", + ) + }; + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + status = __ras_status.as_u16(), + "rejected request: {}", + __ras_client_msg + ); + return ( + __ras_status, + axum::Json(serde_json::json!({ "error": __ras_client_msg })) + ).into_response(); + }, + }; + match serde_json::from_slice(&body_bytes) { + Ok(body) => body, + Err(__ras_json_err) => { + use axum::response::IntoResponse; + // Log the classification and location so the reason is + // recoverable server-side; never log the rejected value. + ras_rest_core::tracing::warn!( + method = #method, + path = #path, + category = ?__ras_json_err.classify(), + line = __ras_json_err.line(), + column = __ras_json_err.column(), + "rejected request: malformed JSON body" + ); + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": "Invalid JSON" + })) + ).into_response(); + }, + } + }; + } +} + +/// The effective body-size limit expression for an endpoint: its per-endpoint +/// `body_limit` override when set, otherwise the service-level `__RAS_BODY_LIMIT`. +pub(super) fn effective_body_limit_tokens( + endpoint: &EndpointDefinition, +) -> proc_macro2::TokenStream { + match endpoint.body_limit { + Some(limit) => quote! { #limit }, + None => quote! { __RAS_BODY_LIMIT }, + } +} diff --git a/crates/rest/ras-rest-macro/src/server/routes.rs b/crates/rest/ras-rest-macro/src/server/routes.rs new file mode 100644 index 0000000..2745647 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/server/routes.rs @@ -0,0 +1,124 @@ +//! Canonical and versioned route registration. + +use super::auth::rest_permission_groups_code; +use super::handlers::generate_handler_body; +use super::request::{AxumHandlerParts, generate_axum_handler}; +use super::versioned::generate_legacy_handler_body; +use crate::ast::*; +use quote::quote; +use syn::Ident; + +pub(super) fn generate_canonical_route_registration( + endpoint: &EndpointDefinition, + query_struct_name: &Ident, + require_json: bool, +) -> proc_macro2::TokenStream { + let method_routing = endpoint.method.as_axum_method(); + let path = &endpoint.path; + let handler_name = &endpoint.handler_name; + let method_str = endpoint.method.as_str(); + let AxumHandlerParts { + extractors: axum_handler, + prelude: extractor_prelude, + } = generate_axum_handler( + &endpoint.path_params, + &endpoint.query_params, + endpoint.request_type.as_ref(), + query_struct_name, + method_str, + path, + ); + let handler_body = + generate_handler_body(endpoint, handler_name, method_str, path, require_json); + let permission_groups_code = rest_permission_groups_code(&endpoint.auth); + + quote! { + { + let service = self.service.clone(); + let auth_provider = self.auth_provider.clone(); + let auth_transport = self.auth_transport.clone(); + let required_permission_groups: Vec> = #permission_groups_code; + let with_usage_tracker = self.with_usage_tracker.clone(); + let with_method_duration_tracker = self.with_method_duration_tracker.clone(); + + router = router.route(#path, #method_routing({ + move |#axum_handler| { + let service = service.clone(); + let auth_provider = auth_provider.clone(); + let auth_transport = auth_transport.clone(); + let required_permission_groups: Vec> = required_permission_groups.clone(); + let with_usage_tracker = with_usage_tracker.clone(); + let with_method_duration_tracker = with_method_duration_tracker.clone(); + + async move { + #extractor_prelude + #handler_body + } + } + })); + } + } +} + +pub(super) fn generate_legacy_route_registration( + service_name: &Ident, + endpoint: &EndpointDefinition, + version: &EndpointVersionDefinition, + query_struct_name: &Ident, + require_json: bool, +) -> proc_macro2::TokenStream { + let method_routing = endpoint.method.as_axum_method(); + let path = &version.path; + let AxumHandlerParts { + extractors: axum_handler, + prelude: extractor_prelude, + } = generate_axum_handler( + &version.path_params, + &version.query_params, + version.request_type.as_ref(), + query_struct_name, + endpoint.method.as_str(), + path, + ); + let handler_body = generate_legacy_handler_body(service_name, endpoint, version, require_json); + let permission_groups_code = rest_permission_groups_code(&endpoint.auth); + + quote! { + { + let service = self.service.clone(); + let auth_provider = self.auth_provider.clone(); + let auth_transport = self.auth_transport.clone(); + let required_permission_groups: Vec> = #permission_groups_code; + let with_usage_tracker = self.with_usage_tracker.clone(); + let with_method_duration_tracker = self.with_method_duration_tracker.clone(); + + router = router.route(#path, #method_routing({ + move |#axum_handler| { + let service = service.clone(); + let auth_provider = auth_provider.clone(); + let auth_transport = auth_transport.clone(); + let required_permission_groups: Vec> = required_permission_groups.clone(); + let with_usage_tracker = with_usage_tracker.clone(); + let with_method_duration_tracker = with_method_duration_tracker.clone(); + + async move { + #extractor_prelude + #handler_body + } + } + })); + } + } +} + +impl HttpMethod { + fn as_axum_method(&self) -> proc_macro2::TokenStream { + match self { + HttpMethod::Get => quote! { axum::routing::get }, + HttpMethod::Post => quote! { axum::routing::post }, + HttpMethod::Put => quote! { axum::routing::put }, + HttpMethod::Delete => quote! { axum::routing::delete }, + HttpMethod::Patch => quote! { axum::routing::patch }, + } + } +} diff --git a/crates/rest/ras-rest-macro/src/server/versioned.rs b/crates/rest/ras-rest-macro/src/server/versioned.rs new file mode 100644 index 0000000..a5e4d07 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/server/versioned.rs @@ -0,0 +1,304 @@ +//! Request and response adaptation for versioned routes. + +use super::request::{ + effective_body_limit_tokens, generate_body_extraction, generate_rest_parts_init, + rest_canonical_args_from_parts, rest_request_part_idents, +}; +use crate::ast::*; +use quote::quote; +use syn::Ident; + +pub(super) fn generate_legacy_handler_body( + service_name: &Ident, + endpoint: &EndpointDefinition, + version: &EndpointVersionDefinition, + require_json: bool, +) -> proc_macro2::TokenStream { + let handler_name = &endpoint.handler_name; + let method = endpoint.method.as_str(); + let path = &version.path; + let body_limit_tokens = effective_body_limit_tokens(endpoint); + let migration_type = &version.migration_type; + let canonical_response_type = &endpoint.response_type; + let legacy_response_type = &version.response_type; + let canonical_version = endpoint.version.as_deref().unwrap_or("current"); + let (canonical_request_ident, _, _) = + rest_request_part_idents(service_name, handler_name, canonical_version); + let (legacy_request_ident, _, _) = + rest_request_part_idents(service_name, handler_name, &version.version); + let legacy_parts_init = generate_rest_parts_init( + service_name, + handler_name, + &version.version, + &version.path_params, + &version.query_params, + version.request_type.as_ref(), + ); + let canonical_parts_ident = quote::format_ident!("canonical_parts"); + let mut canonical_args = rest_canonical_args_from_parts(endpoint, &canonical_parts_ident); + + // Opt-in request headers, inserted before the auth arg is prepended below so + // the final order is [caller/user?, headers, path.., query.., body?]. + if endpoint.with_headers { + canonical_args.insert(0, quote! { headers.clone() }); + } + + let json_handling = if version.request_type.is_some() { + generate_body_extraction(require_json, &body_limit_tokens, method, path) + } else { + quote! {} + }; + + match &endpoint.auth { + AuthRequirement::Unauthorized => quote! { + #json_handling + + if let Some(tracker) = &with_usage_tracker { + let tracker_headers = + ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); + tracker(&tracker_headers, None, #method, #path).await; + } + + let legacy_parts: #legacy_request_ident = #legacy_parts_init; + let #canonical_parts_ident: #canonical_request_ident = + match <#migration_type as ras_rest_core::VersionMigration<#legacy_request_ident, #canonical_request_ident>>::migrate(legacy_parts) { + Ok(parts) => parts, + Err(e) => { + use axum::response::IntoResponse; + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": e.to_string() + })) + ).into_response(); + }, + }; + + let start_time = std::time::Instant::now(); + + let result = match service.#handler_name(#(#canonical_args),*).await { + Ok(rest_response) => { + use axum::response::IntoResponse; + let status_code = axum::http::StatusCode::from_u16(rest_response.status) + .unwrap_or(axum::http::StatusCode::OK); + let body: #legacy_response_type = + match <#migration_type as ras_rest_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(rest_response.body) { + Ok(body) => body, + Err(e) => { + ras_rest_core::tracing::error!(error = %e, "Response migration failed"); + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({ + "error": "Internal server error" + })) + ).into_response(); + }, + }; + __ras_success_response(status_code, body) + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + } + + let status_code = axum::http::StatusCode::from_u16(rest_error.status) + .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + + ( + status_code, + axum::Json(serde_json::json!({ + "error": &rest_error.message + })) + ).into_response() + }, + }; + + let duration = start_time.elapsed(); + if let Some(tracker) = &with_method_duration_tracker { + tracker(#method, #path, None, duration).await; + } + + result + }, + AuthRequirement::OptionalAuth => { + canonical_args.insert(0, quote! { caller }); + + quote! { + // Best-effort authentication for an OPTIONAL_AUTH route — never + // rejected: resolves to Caller::Anonymous for a missing/invalid + // credential, Caller::Authenticated for a valid one. + let caller = ras_auth_core::resolve_caller( + #method, + &headers, + &auth_transport, + auth_provider.as_deref(), + ).await; + // Snapshot the user for tracking; `caller` is moved into the handler. + let __ras_caller_user = caller.authenticated().cloned(); + + #json_handling + + if let Some(tracker) = &with_usage_tracker { + let tracker_headers = + ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); + tracker(&tracker_headers, __ras_caller_user.as_ref(), #method, #path).await; + } + + let legacy_parts: #legacy_request_ident = #legacy_parts_init; + let #canonical_parts_ident: #canonical_request_ident = + match <#migration_type as ras_rest_core::VersionMigration<#legacy_request_ident, #canonical_request_ident>>::migrate(legacy_parts) { + Ok(parts) => parts, + Err(e) => { + use axum::response::IntoResponse; + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": e.to_string() + })) + ).into_response(); + }, + }; + + let start_time = std::time::Instant::now(); + + let result = match service.#handler_name(#(#canonical_args),*).await { + Ok(rest_response) => { + use axum::response::IntoResponse; + let status_code = axum::http::StatusCode::from_u16(rest_response.status) + .unwrap_or(axum::http::StatusCode::OK); + let body: #legacy_response_type = + match <#migration_type as ras_rest_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(rest_response.body) { + Ok(body) => body, + Err(e) => { + ras_rest_core::tracing::error!(error = %e, "Response migration failed"); + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({ + "error": "Internal server error" + })) + ).into_response(); + }, + }; + __ras_success_response(status_code, body) + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + } + + let status_code = axum::http::StatusCode::from_u16(rest_error.status) + .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + + ( + status_code, + axum::Json(serde_json::json!({ + "error": &rest_error.message + })) + ).into_response() + }, + }; + + let duration = start_time.elapsed(); + if let Some(tracker) = &with_method_duration_tracker { + tracker(#method, #path, __ras_caller_user.as_ref(), duration).await; + } + + result + } + } + AuthRequirement::WithPermissions(_) => { + canonical_args.insert(0, quote! { &user }); + + quote! { + // Authenticate and authorize: credential → CSRF → authenticate + // → OR-of-AND permission groups (shared ras-auth-core pipeline) + let user = match ras_auth_core::authorize_request( + #method, + &headers, + &auth_transport, + auth_provider.as_deref(), + &required_permission_groups, + ).await { + Ok(user) => user, + Err(error) => return __ras_authorize_error_response(error), + }; + + // Read and parse the body only after auth has succeeded + #json_handling + + if let Some(tracker) = &with_usage_tracker { + let tracker_headers = + ras_auth_core::redact_sensitive_headers_for_auth_transport(&headers, &auth_transport); + tracker(&tracker_headers, Some(&user), #method, #path).await; + } + + let legacy_parts: #legacy_request_ident = #legacy_parts_init; + let #canonical_parts_ident: #canonical_request_ident = + match <#migration_type as ras_rest_core::VersionMigration<#legacy_request_ident, #canonical_request_ident>>::migrate(legacy_parts) { + Ok(parts) => parts, + Err(e) => { + use axum::response::IntoResponse; + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": e.to_string() + })) + ).into_response(); + }, + }; + + let start_time = std::time::Instant::now(); + + let result = match service.#handler_name(#(#canonical_args),*).await { + Ok(rest_response) => { + use axum::response::IntoResponse; + let status_code = axum::http::StatusCode::from_u16(rest_response.status) + .unwrap_or(axum::http::StatusCode::OK); + let body: #legacy_response_type = + match <#migration_type as ras_rest_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(rest_response.body) { + Ok(body) => body, + Err(e) => { + ras_rest_core::tracing::error!(error = %e, "Response migration failed"); + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({ + "error": "Internal server error" + })) + ).into_response(); + }, + }; + __ras_success_response(status_code, body) + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + ras_rest_core::tracing::error!(error = ?internal, "Request failed with status {}", rest_error.status); + } + + let status_code = axum::http::StatusCode::from_u16(rest_error.status) + .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + + ( + status_code, + axum::Json(serde_json::json!({ + "error": &rest_error.message + })) + ).into_response() + }, + }; + + let duration = start_time.elapsed(); + if let Some(tracker) = &with_method_duration_tracker { + tracker(#method, #path, Some(&user), duration).await; + } + + result + } + } + } +} diff --git a/documentation/reviews/refactor-progress.md b/documentation/reviews/refactor-progress.md index 4e5c0cc..5188a05 100644 --- a/documentation/reviews/refactor-progress.md +++ b/documentation/reviews/refactor-progress.md @@ -15,3 +15,4 @@ REST baseline: 61/61 passed. | Step | Change | Verification | | --- | --- | --- | | 1 | REST model and parser | 61 tests, 1 doctest; Clippy; no-default/server/client macro builds; no-default/server native and client WASM `rest-api` builds. | +| 2 | REST expansion, routing, request extraction, canonical/versioned handlers | 61 tests, 1 doctest; Clippy; all three macro feature modes; server native and client WASM consumer builds. | From ae345e7e7f2d8c84b916278db68bb1f09fd4c822 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 11:06:35 +0200 Subject: [PATCH 03/35] refactor(jsonrpc): separate service model and parser --- crates/rpc/ras-jsonrpc-macro/src/ast.rs | 89 +++++ crates/rpc/ras-jsonrpc-macro/src/lib.rs | 439 +-------------------- crates/rpc/ras-jsonrpc-macro/src/parser.rs | 351 ++++++++++++++++ documentation/reviews/refactor-progress.md | 1 + 4 files changed, 446 insertions(+), 434 deletions(-) create mode 100644 crates/rpc/ras-jsonrpc-macro/src/ast.rs create mode 100644 crates/rpc/ras-jsonrpc-macro/src/parser.rs diff --git a/crates/rpc/ras-jsonrpc-macro/src/ast.rs b/crates/rpc/ras-jsonrpc-macro/src/ast.rs new file mode 100644 index 0000000..53e21da --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/ast.rs @@ -0,0 +1,89 @@ +//! Parsed JSON-RPC service contract shared by parser and emitters. + +use syn::{Ident, Type}; + +#[derive(Debug)] +pub(crate) struct ServiceDefinition { + pub(crate) service_name: Ident, + pub(crate) openrpc: Option, + pub(crate) explorer: Option, + pub(crate) feature_gated: bool, + /// Require an `application/json` request `Content-Type`. Defaults to `true`. + /// Set `require_json_content_type: false` to accept any content type. + pub(crate) require_json_content_type: bool, + /// Maximum request body size in bytes. Defaults to 2 MiB (axum's default). + pub(crate) body_limit: Option, + /// Gate the explorer page and `openrpc.json` behind authentication (any + /// authenticated user). Defaults to `false` — the explorer is public when + /// enabled, matching conventional API-explorer behavior. + pub(crate) docs_require_auth: bool, + pub(crate) methods: Vec, +} + +/// Default maximum JSON body size in bytes (matches axum's default). +pub(crate) const DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024; + +#[derive(Debug)] +pub(crate) enum OpenRpcConfig { + Enabled, + WithPath(String), +} + +#[derive(Debug)] +pub(crate) enum ExplorerConfig { + Enabled, + WithPath(String), +} + +#[derive(Debug)] +pub(crate) struct MethodDefinition { + pub(crate) docs: Option, + pub(crate) auth: AuthRequirement, + pub(crate) name: Ident, + pub(crate) request_type: Type, + pub(crate) response_type: Type, + pub(crate) version: Option, + pub(crate) wire_name: Option, + pub(crate) versions: Vec, +} + +#[derive(Debug)] +pub(crate) struct MethodVersionDefinition { + pub(crate) version: String, + pub(crate) wire_name: String, + pub(crate) request_type: Type, + pub(crate) response_type: Type, + pub(crate) migration_type: Type, +} + +#[derive(Debug)] +pub(crate) struct DocComment { + pub(crate) summary: String, + pub(crate) description: String, +} + +impl DocComment { + pub(crate) fn from_lines(lines: Vec) -> Option { + let lines: Vec = lines + .into_iter() + .map(|line| line.trim().to_string()) + .collect(); + let start = lines.iter().position(|line| !line.is_empty())?; + let end = lines.iter().rposition(|line| !line.is_empty())?; + let lines = &lines[start..=end]; + + Some(Self { + summary: lines[0].clone(), + description: lines.join("\n"), + }) + } +} + +#[derive(Debug)] +pub(crate) enum AuthRequirement { + Unauthorized, + /// Public method that opportunistically identifies its caller. Never rejected + /// for auth reasons; the handler receives a `ras_jsonrpc_core::Caller`. + OptionalAuth, + WithPermissions(Vec>), // Vec of permission groups - OR between groups, AND within groups +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/lib.rs b/crates/rpc/ras-jsonrpc-macro/src/lib.rs index fbcc4d6..7cd5f53 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/lib.rs @@ -1,6 +1,10 @@ +use ast::*; use proc_macro::TokenStream; use quote::{format_ident, quote}; -use syn::{Ident, LitStr, Token, Type, parse::Parse, parse_macro_input}; +use syn::{Ident, Type, parse_macro_input}; + +mod ast; +mod parser; mod client; mod openrpc; @@ -66,439 +70,6 @@ pub fn jsonrpc_service(input: TokenStream) -> TokenStream { } } -#[derive(Debug)] -struct ServiceDefinition { - service_name: Ident, - openrpc: Option, - explorer: Option, - feature_gated: bool, - /// Require an `application/json` request `Content-Type`. Defaults to `true`. - /// Set `require_json_content_type: false` to accept any content type. - require_json_content_type: bool, - /// Maximum request body size in bytes. Defaults to 2 MiB (axum's default). - body_limit: Option, - /// Gate the explorer page and `openrpc.json` behind authentication (any - /// authenticated user). Defaults to `false` — the explorer is public when - /// enabled, matching conventional API-explorer behavior. - docs_require_auth: bool, - methods: Vec, -} - -/// Default maximum JSON body size in bytes (matches axum's default). -const DEFAULT_BODY_LIMIT: usize = 2 * 1024 * 1024; - -#[derive(Debug)] -enum OpenRpcConfig { - Enabled, - WithPath(String), -} - -#[derive(Debug)] -enum ExplorerConfig { - Enabled, - WithPath(String), -} - -#[derive(Debug)] -struct MethodDefinition { - docs: Option, - auth: AuthRequirement, - name: Ident, - request_type: Type, - response_type: Type, - version: Option, - wire_name: Option, - versions: Vec, -} - -#[derive(Debug)] -struct MethodVersionDefinition { - version: String, - wire_name: String, - request_type: Type, - response_type: Type, - migration_type: Type, -} - -#[derive(Debug)] -struct DocComment { - summary: String, - description: String, -} - -impl DocComment { - fn from_lines(lines: Vec) -> Option { - let lines: Vec = lines - .into_iter() - .map(|line| line.trim().to_string()) - .collect(); - let start = lines.iter().position(|line| !line.is_empty())?; - let end = lines.iter().rposition(|line| !line.is_empty())?; - let lines = &lines[start..=end]; - - Some(Self { - summary: lines[0].clone(), - description: lines.join("\n"), - }) - } -} - -#[derive(Debug)] -enum AuthRequirement { - Unauthorized, - /// Public method that opportunistically identifies its caller. Never rejected - /// for auth reasons; the handler receives a `ras_jsonrpc_core::Caller`. - OptionalAuth, - WithPermissions(Vec>), // Vec of permission groups - OR between groups, AND within groups -} - -const DOC_COMMENT_EXPECTED: &str = "Expected doc comment in the form `/// ...`"; - -fn parse_label(input: syn::parse::ParseStream) -> syn::Result { - if input.peek(LitStr) { - Ok(input.parse::()?.value()) - } else { - Ok(input.parse::()?.to_string()) - } -} - -fn parse_doc_comment_attrs( - attrs: Vec, - entry_kind: &str, -) -> syn::Result> { - let lines = attrs - .into_iter() - .map(|attr| parse_doc_comment_attr(attr, entry_kind)) - .collect::>>()?; - - Ok(DocComment::from_lines(lines)) -} - -fn parse_doc_comment_attr(attr: syn::Attribute, entry_kind: &str) -> syn::Result { - if !attr.path().is_ident("doc") { - return Err(syn::Error::new_spanned( - attr, - format!("Only doc comments (`/// ...`) are supported before {entry_kind} definitions"), - )); - } - - if let syn::Meta::NameValue(name_value) = &attr.meta - && let syn::Expr::Lit(expr_lit) = &name_value.value - && let syn::Lit::Str(doc_line) = &expr_lit.lit - { - return Ok(doc_line.value()); - } - - Err(syn::Error::new_spanned(attr, DOC_COMMENT_EXPECTED)) -} - -impl Parse for ServiceDefinition { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let content; - syn::braced!(content in input); - - let _ = content.parse::()?; // "service_name" - let _ = content.parse::()?; - let service_name = content.parse::()?; - let _ = content.parse::()?; - - let mut openrpc = None; - let mut explorer = None; - let mut feature_gated = false; - let mut require_json_content_type = true; - let mut body_limit = None; - let mut docs_require_auth = false; - - while content.peek(Ident) { - let field_name = content.fork().parse::()?; - if field_name == "methods" { - break; - } - - let _ = content.parse::()?; // field name - let _ = content.parse::()?; - - if field_name == "openrpc" { - if content.peek(syn::LitBool) { - let enabled = content.parse::()?; - if enabled.value() { - openrpc = Some(OpenRpcConfig::Enabled); - } - } else if content.peek(syn::token::Brace) { - let openrpc_content; - syn::braced!(openrpc_content in content); - - let _ = openrpc_content.parse::()?; // "output" - let _ = openrpc_content.parse::()?; - let path = openrpc_content.parse::()?; - openrpc = Some(OpenRpcConfig::WithPath(path.value())); - } - } else if field_name == "explorer" { - if content.peek(syn::LitBool) { - let enabled = content.parse::()?; - if enabled.value() { - explorer = Some(ExplorerConfig::Enabled); - } - } else if content.peek(syn::token::Brace) { - let explorer_content; - syn::braced!(explorer_content in content); - - let _ = explorer_content.parse::()?; // "path" - let _ = explorer_content.parse::()?; - let path = explorer_content.parse::()?; - explorer = Some(ExplorerConfig::WithPath(path.value())); - } - } else if field_name == "feature_gated" { - let enabled = content.parse::()?; - feature_gated = enabled.value(); - } else if field_name == "require_json_content_type" { - let enabled = content.parse::()?; - require_json_content_type = enabled.value(); - } else if field_name == "body_limit" { - let limit = content.parse::()?; - body_limit = Some(limit.base10_parse::()?); - } else if field_name == "docs_require_auth" { - let enabled = content.parse::()?; - docs_require_auth = enabled.value(); - } else { - return Err(syn::Error::new( - field_name.span(), - format!("Unknown field: {field_name}"), - )); - } - - let _ = content.parse::()?; - } - - let _ = content.parse::()?; // "methods" - let _ = content.parse::()?; - - let methods_content; - syn::bracketed!(methods_content in content); - - let mut methods = Vec::new(); - while !methods_content.is_empty() { - let method = methods_content.parse::()?; - methods.push(method); - - if methods_content.peek(Token![,]) { - let _ = methods_content.parse::()?; - } - } - - Ok(ServiceDefinition { - service_name, - openrpc, - explorer, - feature_gated, - require_json_content_type, - body_limit, - docs_require_auth, - methods, - }) - } -} - -impl Parse for MethodDefinition { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let docs = parse_doc_comment_attrs(input.call(syn::Attribute::parse_outer)?, "method")?; - - let auth = if input.peek(syn::Ident) { - let auth_ident = input.parse::()?; - match auth_ident.to_string().as_str() { - "UNAUTHORIZED" => AuthRequirement::Unauthorized, - "OPTIONAL_AUTH" => AuthRequirement::OptionalAuth, - "WITH_PERMISSIONS" => { - let perms_content; - syn::parenthesized!(perms_content in input); - - let mut permission_groups = Vec::new(); - - let first_group_content; - syn::bracketed!(first_group_content in perms_content); - - let mut first_group = Vec::new(); - while !first_group_content.is_empty() { - let perm = first_group_content.parse::()?; - first_group.push(perm.value()); - - if first_group_content.peek(Token![,]) { - let _ = first_group_content.parse::()?; - } - } - permission_groups.push(first_group); - - while perms_content.peek(Token![|]) { - let _ = perms_content.parse::()?; - - let group_content; - syn::bracketed!(group_content in perms_content); - - let mut group = Vec::new(); - while !group_content.is_empty() { - let perm = group_content.parse::()?; - group.push(perm.value()); - - if group_content.peek(Token![,]) { - let _ = group_content.parse::()?; - } - } - permission_groups.push(group); - } - - if permission_groups.len() > 1 - && permission_groups.iter().any(|group| group.is_empty()) - { - return Err(syn::Error::new( - auth_ident.span(), - "an empty permission group is only valid as the entire requirement \ - (WITH_PERMISSIONS([]), meaning any authenticated user); mixing an \ - empty group with non-empty groups would silently grant access to any \ - authenticated user", - )); - } - - AuthRequirement::WithPermissions(permission_groups) - } - _ => { - return Err(syn::Error::new( - auth_ident.span(), - "Expected UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS", - )); - } - } - } else { - return Err(syn::Error::new( - input.span(), - "Expected authentication requirement", - )); - }; - - let name = input.parse::()?; - - let request_content; - syn::parenthesized!(request_content in input); - let request_type = request_content.parse::()?; - - let _ = input.parse::]>()?; - let response_type = input.parse::()?; - - let mut version = None; - let mut wire_name = None; - let mut versions = Vec::new(); - - if input.peek(syn::token::Brace) { - let content; - syn::braced!(content in input); - - while !content.is_empty() { - let field_name = content.parse::()?; - let _ = content.parse::()?; - - match field_name.to_string().as_str() { - "version" => { - version = Some(parse_label(&content)?); - } - "wire" => { - wire_name = Some(content.parse::()?.value()); - } - "versions" => { - let versions_content; - syn::bracketed!(versions_content in content); - - while !versions_content.is_empty() { - versions.push(versions_content.parse::()?); - - if versions_content.peek(Token![,]) { - let _ = versions_content.parse::()?; - } - } - } - _ => { - return Err(syn::Error::new( - field_name.span(), - "Expected version, wire, or versions", - )); - } - } - - if content.peek(Token![,]) { - let _ = content.parse::()?; - } - } - } - - Ok(MethodDefinition { - docs, - auth, - name, - request_type, - response_type, - version, - wire_name, - versions, - }) - } -} - -impl Parse for MethodVersionDefinition { - fn parse(input: syn::parse::ParseStream) -> syn::Result { - let version = parse_label(input)?; - - let content; - syn::braced!(content in input); - - let mut wire_name = None; - let mut request_type = None; - let mut response_type = None; - let mut migration_type = None; - - while !content.is_empty() { - let field_name = content.parse::()?; - let _ = content.parse::()?; - - match field_name.to_string().as_str() { - "wire" => { - wire_name = Some(content.parse::()?.value()); - } - "request" => { - request_type = Some(content.parse::()?); - } - "response" => { - response_type = Some(content.parse::()?); - } - "migration" => { - migration_type = Some(content.parse::()?); - } - _ => { - return Err(syn::Error::new( - field_name.span(), - "Expected wire, request, response, or migration", - )); - } - } - - if content.peek(Token![,]) { - let _ = content.parse::()?; - } - } - - Ok(Self { - version, - wire_name: wire_name - .ok_or_else(|| syn::Error::new(input.span(), "Version entry is missing wire"))?, - request_type: request_type - .ok_or_else(|| syn::Error::new(input.span(), "Version entry is missing request"))?, - response_type: response_type.ok_or_else(|| { - syn::Error::new(input.span(), "Version entry is missing response") - })?, - migration_type: migration_type.ok_or_else(|| { - syn::Error::new(input.span(), "Version entry is missing migration") - })?, - }) - } -} - fn generate_service_code(service_def: ServiceDefinition) -> syn::Result { let service_name_lower = service_def.service_name.to_string().to_lowercase(); let server_mod = format_ident!("__ras_jsonrpc_{}_server", service_name_lower); diff --git a/crates/rpc/ras-jsonrpc-macro/src/parser.rs b/crates/rpc/ras-jsonrpc-macro/src/parser.rs new file mode 100644 index 0000000..e485fdd --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/parser.rs @@ -0,0 +1,351 @@ +//! JSON-RPC service syntax and diagnostics. + +use crate::ast::*; +use syn::{Ident, LitStr, Token, Type, parse::Parse}; + +const DOC_COMMENT_EXPECTED: &str = "Expected doc comment in the form `/// ...`"; + +fn parse_label(input: syn::parse::ParseStream) -> syn::Result { + if input.peek(LitStr) { + Ok(input.parse::()?.value()) + } else { + Ok(input.parse::()?.to_string()) + } +} + +fn parse_doc_comment_attrs( + attrs: Vec, + entry_kind: &str, +) -> syn::Result> { + let lines = attrs + .into_iter() + .map(|attr| parse_doc_comment_attr(attr, entry_kind)) + .collect::>>()?; + + Ok(DocComment::from_lines(lines)) +} + +fn parse_doc_comment_attr(attr: syn::Attribute, entry_kind: &str) -> syn::Result { + if !attr.path().is_ident("doc") { + return Err(syn::Error::new_spanned( + attr, + format!("Only doc comments (`/// ...`) are supported before {entry_kind} definitions"), + )); + } + + if let syn::Meta::NameValue(name_value) = &attr.meta + && let syn::Expr::Lit(expr_lit) = &name_value.value + && let syn::Lit::Str(doc_line) = &expr_lit.lit + { + return Ok(doc_line.value()); + } + + Err(syn::Error::new_spanned(attr, DOC_COMMENT_EXPECTED)) +} + +impl Parse for ServiceDefinition { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let content; + syn::braced!(content in input); + + let _ = content.parse::()?; // "service_name" + let _ = content.parse::()?; + let service_name = content.parse::()?; + let _ = content.parse::()?; + + let mut openrpc = None; + let mut explorer = None; + let mut feature_gated = false; + let mut require_json_content_type = true; + let mut body_limit = None; + let mut docs_require_auth = false; + + while content.peek(Ident) { + let field_name = content.fork().parse::()?; + if field_name == "methods" { + break; + } + + let _ = content.parse::()?; // field name + let _ = content.parse::()?; + + if field_name == "openrpc" { + if content.peek(syn::LitBool) { + let enabled = content.parse::()?; + if enabled.value() { + openrpc = Some(OpenRpcConfig::Enabled); + } + } else if content.peek(syn::token::Brace) { + let openrpc_content; + syn::braced!(openrpc_content in content); + + let _ = openrpc_content.parse::()?; // "output" + let _ = openrpc_content.parse::()?; + let path = openrpc_content.parse::()?; + openrpc = Some(OpenRpcConfig::WithPath(path.value())); + } + } else if field_name == "explorer" { + if content.peek(syn::LitBool) { + let enabled = content.parse::()?; + if enabled.value() { + explorer = Some(ExplorerConfig::Enabled); + } + } else if content.peek(syn::token::Brace) { + let explorer_content; + syn::braced!(explorer_content in content); + + let _ = explorer_content.parse::()?; // "path" + let _ = explorer_content.parse::()?; + let path = explorer_content.parse::()?; + explorer = Some(ExplorerConfig::WithPath(path.value())); + } + } else if field_name == "feature_gated" { + let enabled = content.parse::()?; + feature_gated = enabled.value(); + } else if field_name == "require_json_content_type" { + let enabled = content.parse::()?; + require_json_content_type = enabled.value(); + } else if field_name == "body_limit" { + let limit = content.parse::()?; + body_limit = Some(limit.base10_parse::()?); + } else if field_name == "docs_require_auth" { + let enabled = content.parse::()?; + docs_require_auth = enabled.value(); + } else { + return Err(syn::Error::new( + field_name.span(), + format!("Unknown field: {field_name}"), + )); + } + + let _ = content.parse::()?; + } + + let _ = content.parse::()?; // "methods" + let _ = content.parse::()?; + + let methods_content; + syn::bracketed!(methods_content in content); + + let mut methods = Vec::new(); + while !methods_content.is_empty() { + let method = methods_content.parse::()?; + methods.push(method); + + if methods_content.peek(Token![,]) { + let _ = methods_content.parse::()?; + } + } + + Ok(ServiceDefinition { + service_name, + openrpc, + explorer, + feature_gated, + require_json_content_type, + body_limit, + docs_require_auth, + methods, + }) + } +} + +impl Parse for MethodDefinition { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let docs = parse_doc_comment_attrs(input.call(syn::Attribute::parse_outer)?, "method")?; + + let auth = if input.peek(syn::Ident) { + let auth_ident = input.parse::()?; + match auth_ident.to_string().as_str() { + "UNAUTHORIZED" => AuthRequirement::Unauthorized, + "OPTIONAL_AUTH" => AuthRequirement::OptionalAuth, + "WITH_PERMISSIONS" => { + let perms_content; + syn::parenthesized!(perms_content in input); + + let mut permission_groups = Vec::new(); + + let first_group_content; + syn::bracketed!(first_group_content in perms_content); + + let mut first_group = Vec::new(); + while !first_group_content.is_empty() { + let perm = first_group_content.parse::()?; + first_group.push(perm.value()); + + if first_group_content.peek(Token![,]) { + let _ = first_group_content.parse::()?; + } + } + permission_groups.push(first_group); + + while perms_content.peek(Token![|]) { + let _ = perms_content.parse::()?; + + let group_content; + syn::bracketed!(group_content in perms_content); + + let mut group = Vec::new(); + while !group_content.is_empty() { + let perm = group_content.parse::()?; + group.push(perm.value()); + + if group_content.peek(Token![,]) { + let _ = group_content.parse::()?; + } + } + permission_groups.push(group); + } + + if permission_groups.len() > 1 + && permission_groups.iter().any(|group| group.is_empty()) + { + return Err(syn::Error::new( + auth_ident.span(), + "an empty permission group is only valid as the entire requirement \ + (WITH_PERMISSIONS([]), meaning any authenticated user); mixing an \ + empty group with non-empty groups would silently grant access to any \ + authenticated user", + )); + } + + AuthRequirement::WithPermissions(permission_groups) + } + _ => { + return Err(syn::Error::new( + auth_ident.span(), + "Expected UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS", + )); + } + } + } else { + return Err(syn::Error::new( + input.span(), + "Expected authentication requirement", + )); + }; + + let name = input.parse::()?; + + let request_content; + syn::parenthesized!(request_content in input); + let request_type = request_content.parse::()?; + + let _ = input.parse::]>()?; + let response_type = input.parse::()?; + + let mut version = None; + let mut wire_name = None; + let mut versions = Vec::new(); + + if input.peek(syn::token::Brace) { + let content; + syn::braced!(content in input); + + while !content.is_empty() { + let field_name = content.parse::()?; + let _ = content.parse::()?; + + match field_name.to_string().as_str() { + "version" => { + version = Some(parse_label(&content)?); + } + "wire" => { + wire_name = Some(content.parse::()?.value()); + } + "versions" => { + let versions_content; + syn::bracketed!(versions_content in content); + + while !versions_content.is_empty() { + versions.push(versions_content.parse::()?); + + if versions_content.peek(Token![,]) { + let _ = versions_content.parse::()?; + } + } + } + _ => { + return Err(syn::Error::new( + field_name.span(), + "Expected version, wire, or versions", + )); + } + } + + if content.peek(Token![,]) { + let _ = content.parse::()?; + } + } + } + + Ok(MethodDefinition { + docs, + auth, + name, + request_type, + response_type, + version, + wire_name, + versions, + }) + } +} + +impl Parse for MethodVersionDefinition { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let version = parse_label(input)?; + + let content; + syn::braced!(content in input); + + let mut wire_name = None; + let mut request_type = None; + let mut response_type = None; + let mut migration_type = None; + + while !content.is_empty() { + let field_name = content.parse::()?; + let _ = content.parse::()?; + + match field_name.to_string().as_str() { + "wire" => { + wire_name = Some(content.parse::()?.value()); + } + "request" => { + request_type = Some(content.parse::()?); + } + "response" => { + response_type = Some(content.parse::()?); + } + "migration" => { + migration_type = Some(content.parse::()?); + } + _ => { + return Err(syn::Error::new( + field_name.span(), + "Expected wire, request, response, or migration", + )); + } + } + + if content.peek(Token![,]) { + let _ = content.parse::()?; + } + } + + Ok(Self { + version, + wire_name: wire_name + .ok_or_else(|| syn::Error::new(input.span(), "Version entry is missing wire"))?, + request_type: request_type + .ok_or_else(|| syn::Error::new(input.span(), "Version entry is missing request"))?, + response_type: response_type.ok_or_else(|| { + syn::Error::new(input.span(), "Version entry is missing response") + })?, + migration_type: migration_type.ok_or_else(|| { + syn::Error::new(input.span(), "Version entry is missing migration") + })?, + }) + } +} diff --git a/documentation/reviews/refactor-progress.md b/documentation/reviews/refactor-progress.md index 5188a05..d9df256 100644 --- a/documentation/reviews/refactor-progress.md +++ b/documentation/reviews/refactor-progress.md @@ -16,3 +16,4 @@ REST baseline: 61/61 passed. | --- | --- | --- | | 1 | REST model and parser | 61 tests, 1 doctest; Clippy; no-default/server/client macro builds; no-default/server native and client WASM `rest-api` builds. | | 2 | REST expansion, routing, request extraction, canonical/versioned handlers | 61 tests, 1 doctest; Clippy; all three macro feature modes; server native and client WASM consumer builds. | +| 3 | JSON-RPC model and parser | 59 tests; doctests (1 pre-existing ignored example); Clippy; all macro feature modes and no-default/server/client-WASM `basic-jsonrpc-api` builds. | From 1c315876ab65a9d5b52ad7e085dadddd5e5fbe7e Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 11:08:43 +0200 Subject: [PATCH 04/35] refactor(jsonrpc): separate HTTP handling and method dispatch --- crates/rpc/ras-jsonrpc-macro/src/expand.rs | 112 +++ crates/rpc/ras-jsonrpc-macro/src/lib.rs | 793 +----------------- .../ras-jsonrpc-macro/src/server/dispatch.rs | 241 ++++++ .../rpc/ras-jsonrpc-macro/src/server/http.rs | 330 ++++++++ .../rpc/ras-jsonrpc-macro/src/server/mod.rs | 137 +++ documentation/reviews/refactor-progress.md | 1 + 6 files changed, 825 insertions(+), 789 deletions(-) create mode 100644 crates/rpc/ras-jsonrpc-macro/src/expand.rs create mode 100644 crates/rpc/ras-jsonrpc-macro/src/server/dispatch.rs create mode 100644 crates/rpc/ras-jsonrpc-macro/src/server/http.rs create mode 100644 crates/rpc/ras-jsonrpc-macro/src/server/mod.rs diff --git a/crates/rpc/ras-jsonrpc-macro/src/expand.rs b/crates/rpc/ras-jsonrpc-macro/src/expand.rs new file mode 100644 index 0000000..98d286d --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/expand.rs @@ -0,0 +1,112 @@ +//! Assemble server, client, and specification expansions. + +use crate::{ast::*, openrpc, permissions, server::generate_server_code, static_hosting}; +use quote::{format_ident, quote}; + +pub(crate) fn generate_service_code( + service_def: ServiceDefinition, +) -> syn::Result { + let service_name_lower = service_def.service_name.to_string().to_lowercase(); + let server_mod = format_ident!("__ras_jsonrpc_{}_server", service_name_lower); + let client_mod = format_ident!("__ras_jsonrpc_{}_client", service_name_lower); + + let (openrpc_code, schema_checks) = if let Some(openrpc_config) = &service_def.openrpc { + ( + openrpc::generate_openrpc_code(&service_def, openrpc_config), + openrpc::generate_schema_impl_checks(&service_def), + ) + } else { + (quote! {}, quote! {}) + }; + + let server_impl = generate_server_code(&service_def); + + let explorer_code = if service_def.explorer.is_some() && service_def.openrpc.is_some() { + let explorer_config = match &service_def.explorer { + Some(ExplorerConfig::Enabled) => static_hosting::StaticHostingConfig { + serve_explorer: true, + explorer_path: "/explorer".to_string(), + }, + Some(ExplorerConfig::WithPath(path)) => static_hosting::StaticHostingConfig { + serve_explorer: true, + explorer_path: path.clone(), + }, + None => static_hosting::StaticHostingConfig::default(), + }; + + // The explorer and RPC endpoint share the service root. + static_hosting::generate_static_hosting_code( + &explorer_config, + &service_def.service_name, + "", + ) + } else { + quote! {} + }; + + // With `feature_gated: true` the generated code is wrapped in + // `#[cfg(feature = ...)]` attributes resolved against the CONSUMER + // crate's features, immune to workspace feature unification of the + // macro crate's own features (which `cfg!` evaluates). + let feature_gated = service_def.feature_gated; + let cfg_server = if feature_gated { + quote! { #[cfg(feature = "server")] } + } else { + quote! {} + }; + let cfg_client = if feature_gated { + quote! { #[cfg(feature = "client")] } + } else { + quote! {} + }; + + let server_code = if feature_gated || cfg!(feature = "server") { + quote! { + #cfg_server + mod #server_mod { + use super::*; + + #server_impl + #explorer_code + } + + #cfg_server + pub use #server_mod::*; + } + } else { + quote! {} + }; + + let client_impl = crate::client::generate_client_code(&service_def); + let permissions_code = if cfg!(feature = "permissions") { + permissions::generate_permissions_code(&service_def) + } else { + quote! {} + }; + + let client_code = if feature_gated || cfg!(feature = "client") { + quote! { + #cfg_client + mod #client_mod { + use super::*; + + #client_impl + } + + #cfg_client + pub use #client_mod::*; + } + } else { + quote! {} + }; + + let output = quote! { + #permissions_code + #openrpc_code + #schema_checks + #server_code + #client_code + }; + + Ok(output) +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/lib.rs b/crates/rpc/ras-jsonrpc-macro/src/lib.rs index 7cd5f53..c62760f 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/lib.rs @@ -1,10 +1,11 @@ use ast::*; use proc_macro::TokenStream; -use quote::{format_ident, quote}; -use syn::{Ident, Type, parse_macro_input}; +use syn::parse_macro_input; mod ast; +mod expand; mod parser; +mod server; mod client; mod openrpc; @@ -64,794 +65,8 @@ mod static_hosting; pub fn jsonrpc_service(input: TokenStream) -> TokenStream { let service_definition = parse_macro_input!(input as ServiceDefinition); - match generate_service_code(service_definition) { + match expand::generate_service_code(service_definition) { Ok(tokens) => tokens.into(), Err(err) => err.to_compile_error().into(), } } - -fn generate_service_code(service_def: ServiceDefinition) -> syn::Result { - let service_name_lower = service_def.service_name.to_string().to_lowercase(); - let server_mod = format_ident!("__ras_jsonrpc_{}_server", service_name_lower); - let client_mod = format_ident!("__ras_jsonrpc_{}_client", service_name_lower); - - let (openrpc_code, schema_checks) = if let Some(openrpc_config) = &service_def.openrpc { - ( - openrpc::generate_openrpc_code(&service_def, openrpc_config), - openrpc::generate_schema_impl_checks(&service_def), - ) - } else { - (quote! {}, quote! {}) - }; - - let server_impl = generate_server_code(&service_def); - - let explorer_code = if service_def.explorer.is_some() && service_def.openrpc.is_some() { - let explorer_config = match &service_def.explorer { - Some(ExplorerConfig::Enabled) => static_hosting::StaticHostingConfig { - serve_explorer: true, - explorer_path: "/explorer".to_string(), - }, - Some(ExplorerConfig::WithPath(path)) => static_hosting::StaticHostingConfig { - serve_explorer: true, - explorer_path: path.clone(), - }, - None => static_hosting::StaticHostingConfig::default(), - }; - - // The explorer and RPC endpoint share the service root. - static_hosting::generate_static_hosting_code( - &explorer_config, - &service_def.service_name, - "", - ) - } else { - quote! {} - }; - - // With `feature_gated: true` the generated code is wrapped in - // `#[cfg(feature = ...)]` attributes resolved against the CONSUMER - // crate's features, immune to workspace feature unification of the - // macro crate's own features (which `cfg!` evaluates). - let feature_gated = service_def.feature_gated; - let cfg_server = if feature_gated { - quote! { #[cfg(feature = "server")] } - } else { - quote! {} - }; - let cfg_client = if feature_gated { - quote! { #[cfg(feature = "client")] } - } else { - quote! {} - }; - - let server_code = if feature_gated || cfg!(feature = "server") { - quote! { - #cfg_server - mod #server_mod { - use super::*; - - #server_impl - #explorer_code - } - - #cfg_server - pub use #server_mod::*; - } - } else { - quote! {} - }; - - let client_impl = crate::client::generate_client_code(&service_def); - let permissions_code = if cfg!(feature = "permissions") { - permissions::generate_permissions_code(&service_def) - } else { - quote! {} - }; - - let client_code = if feature_gated || cfg!(feature = "client") { - quote! { - #cfg_client - mod #client_mod { - use super::*; - - #client_impl - } - - #cfg_client - pub use #client_mod::*; - } - } else { - quote! {} - }; - - let output = quote! { - #permissions_code - #openrpc_code - #schema_checks - #server_code - #client_code - }; - - Ok(output) -} - -fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenStream { - let service_name = &service_def.service_name; - let service_name_str = service_name.to_string(); - let service_trait_name = quote::format_ident!("{}Trait", service_name); - let builder_name = quote::format_ident!("{}Builder", service_name); - - let explorer_enabled = service_def.explorer.is_some() && service_def.openrpc.is_some(); - - // Content-Type gate: reject a non-`application/json` body with 415 before - // parsing. Requiring `application/json` forces a CORS preflight for - // cross-origin requests, closing the simple-request CSRF shape. - let content_type_gate = if service_def.require_json_content_type { - quote! { - { - let __ras_content_type_ok = headers - .get(axum::http::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(|value| { - value - .split(';') - .next() - .unwrap_or("") - .trim() - .eq_ignore_ascii_case("application/json") - }) - .unwrap_or(false); - if !__ras_content_type_ok { - ras_jsonrpc_core::tracing::warn!( - "rejected JSON-RPC request: Content-Type is not application/json" - ); - return ( - axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, - [("Content-Type", "application/json")], - serde_json::to_string(&ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::invalid_request(), - None, - )) - .unwrap_or_else(|_| "{}".to_string()), - ); - } - } - } - } else { - quote! {} - }; - - // Body-size cap: apply as a DefaultBodyLimit layer so an over-limit body - // is rejected by the extractor before the handler runs. - let body_limit_value = service_def.body_limit.unwrap_or(DEFAULT_BODY_LIMIT); - - // Startup assertion: a service with any WITH_PERMISSIONS method (or a - // gated explorer) needs an auth provider, else every such call silently fails - // authentication at runtime. Fail the build instead. - let any_route_requires_auth = service_def - .methods - .iter() - .any(|method| matches!(method.auth, AuthRequirement::WithPermissions(_))) - || (explorer_enabled && service_def.docs_require_auth); - let provider_check = if any_route_requires_auth { - quote! { - if self.auth_provider.is_none() { - return Err(concat!( - "JSON-RPC service `", - #service_name_str, - "` has methods requiring authorization (WITH_PERMISSIONS) but no ", - "auth_provider was configured; call .auth_provider(...) before build()" - ) - .to_string()); - } - } - } else { - quote! {} - }; - - // Apply the explorer's auth policy where the service auth configuration - // is available. - let explorer_route_integration = if explorer_enabled { - let service_name_lower = service_name_str.to_lowercase(); - let explorer_routes_fn_str = [&service_name_lower, "_explorer_routes"].concat(); - let explorer_routes_fn = syn::Ident::new(&explorer_routes_fn_str, service_name.span()); - if service_def.docs_require_auth { - quote! { - { - let __ras_docs_service = service.clone(); - // `route_layer` (not `layer`) so the gate runs only for the - // explorer/openrpc routes that actually match — an unrelated - // path 404s without the middleware, and the RPC endpoint - // (a separate route) is never gated. - let __ras_explorer = #explorer_routes_fn(&base_url).route_layer( - axum::middleware::from_fn(move |__ras_req: axum::extract::Request, __ras_next: axum::middleware::Next| { - let __ras_docs_service = __ras_docs_service.clone(); - async move { - use axum::response::IntoResponse; - let __ras_headers = __ras_req.headers().clone(); - let __ras_empty: Vec> = Vec::new(); - match ras_jsonrpc_core::authorize_request( - "GET", - &__ras_headers, - &__ras_docs_service.auth_transport, - __ras_docs_service.auth_provider.as_deref(), - &__ras_empty, - ).await { - Ok(_) => __ras_next.run(__ras_req).await, - Err(__ras_err) => { - let __ras_status = match __ras_err { - ras_jsonrpc_core::AuthorizeError::CsrfValidationFailed - | ras_jsonrpc_core::AuthorizeError::InsufficientPermissions(_) => - axum::http::StatusCode::FORBIDDEN, - ras_jsonrpc_core::AuthorizeError::NoAuthProvider => - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - _ => axum::http::StatusCode::UNAUTHORIZED, - }; - ras_jsonrpc_core::tracing::warn!(status = __ras_status.as_u16(), "explorer request rejected"); - ( - __ras_status, - [("Content-Type", "application/json")], - serde_json::json!({ "error": "Authentication required" }).to_string(), - ).into_response() - } - } - } - }) - ); - router = router.merge(__ras_explorer); - } - } - } else { - quote! { router = router.merge(#explorer_routes_fn(&base_url)); } - } - } else { - quote! {} - }; - - let trait_methods = service_def.methods.iter().map(|method| { - let method_name = &method.name; - let request_type = &method.request_type; - let response_type = &method.response_type; - - match &method.auth { - AuthRequirement::Unauthorized => { - quote! { - fn #method_name(&self, request: #request_type) -> impl std::future::Future>> + Send; - } - } - AuthRequirement::OptionalAuth => { - quote! { - fn #method_name(&self, caller: ras_jsonrpc_core::Caller, request: #request_type) -> impl std::future::Future>> + Send; - } - } - AuthRequirement::WithPermissions(_) => { - quote! { - fn #method_name(&self, user: &ras_jsonrpc_core::AuthenticatedUser, request: #request_type) -> impl std::future::Future>> + Send; - } - } - } - }); - - // Wire names of OPTIONAL_AUTH methods (canonical + legacy versions). The - // request-level auth step rejects bad credentials globally; for these methods - // we instead downgrade to anonymous so the route stays lenient/public. - let optional_auth_wire_names: Vec = service_def - .methods - .iter() - .filter(|method| matches!(method.auth, AuthRequirement::OptionalAuth)) - .flat_map(|method| { - std::iter::once(jsonrpc_method_wire_name(method)).chain( - method - .versions - .iter() - .map(|version| version.wire_name.clone()), - ) - }) - .collect(); - let optional_method_check = if optional_auth_wire_names.is_empty() { - quote! { false } - } else { - quote! { matches!(request.method.as_str(), #(#optional_auth_wire_names)|*) } - }; - - let method_dispatch = service_def - .methods - .iter() - .flat_map(generate_jsonrpc_method_dispatches); - - quote! { - /// Generated service trait - #[allow(private_interfaces, private_bounds)] - pub trait #service_trait_name: Send + Sync + 'static { - #(#trait_methods)* - } - - /// Generated builder for the JSON-RPC service - pub struct #builder_name { - base_url: String, - service: std::sync::Arc, - auth_provider: Option>, - auth_transport: ras_jsonrpc_core::AuthTransportConfig, - usage_tracker: Option, &ras_jsonrpc_types::JsonRpcRequest) -> std::pin::Pin + Send>> + Send + Sync>>, - method_duration_tracker: Option, std::time::Duration) -> std::pin::Pin + Send>> + Send + Sync>>, - } - - impl #builder_name { - /// Create a new builder with the service implementation. - /// - /// The JSON-RPC route defaults to `/rpc`; use `base_url` to override it. - pub fn new(service: T) -> Self { - Self { - base_url: "/rpc".to_string(), - service: std::sync::Arc::new(service), - auth_provider: None, - auth_transport: ras_jsonrpc_core::AuthTransportConfig::default(), - usage_tracker: None, - method_duration_tracker: None, - } - } - - /// Override the JSON-RPC route path. - pub fn base_url(mut self, base_url: impl Into) -> Self { - self.base_url = base_url.into(); - self - } - - /// Set the auth provider - pub fn auth_provider(mut self, provider: A) -> Self { - self.auth_provider = Some(Box::new(provider)); - self - } - - /// Enable cookie authentication alongside bearer tokens. - /// - /// Installs a default double-submit CSRF config when none is set, - /// because cookie credentials are CSRF-exploitable on unsafe methods. - /// Override with `csrf_protection`. - pub fn auth_cookie(mut self, cookie: ras_jsonrpc_core::AuthCookieConfig) -> Self { - self.auth_transport.cookie = Some(cookie); - if self.auth_transport.csrf.is_none() { - self.auth_transport.csrf = Some(ras_jsonrpc_core::CsrfConfig::default()); - } - self - } - - /// Replace the full auth transport configuration. - pub fn auth_transport(mut self, transport: ras_jsonrpc_core::AuthTransportConfig) -> Self { - self.auth_transport = transport; - self - } - - /// Require CSRF validation for cookie-authenticated JSON-RPC requests. - pub fn csrf_protection(mut self, csrf: ras_jsonrpc_core::CsrfConfig) -> Self { - self.auth_transport.csrf = Some(csrf); - self - } - - /// Set the usage tracker function - /// This function will be called for each request with headers, authenticated user (if any), and the JSON-RPC request - pub fn with_usage_tracker(mut self, tracker: F) -> Self - where - F: Fn(&axum::http::HeaderMap, Option<&ras_jsonrpc_core::AuthenticatedUser>, &ras_jsonrpc_types::JsonRpcRequest) -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, - { - self.usage_tracker = Some(Box::new(move |headers, user, request| { - Box::pin(tracker(headers, user, request)) - })); - self - } - - /// Set the method duration tracker function - /// This function will be called after each method completes with the method name, authenticated user (if any), and the duration - pub fn with_method_duration_tracker(mut self, tracker: F) -> Self - where - F: Fn(&str, Option<&ras_jsonrpc_core::AuthenticatedUser>, std::time::Duration) -> Fut + Send + Sync + 'static, - Fut: std::future::Future + Send + 'static, - { - self.method_duration_tracker = Some(Box::new(move |method, user, duration| { - Box::pin(tracker(method, user, duration)) - })); - self - } - - /// Build the axum router for the JSON-RPC service - pub fn build(self) -> Result { - self.auth_transport - .validate() - .map_err(|err| err.to_string())?; - - #provider_check - - let base_url = self.base_url.clone(); - let service = std::sync::Arc::new(self); - - let rpc_handler = axum::routing::post({ - // Clone into the handler so the outer `service` survives for - // the (optional) docs auth gate below. - let service = service.clone(); - move |headers: axum::http::HeaderMap, body: String| { - let service = service.clone(); - async move { - // Reject a non-`application/json` body with 415 before parsing. - #content_type_gate - - let response = service.handle_request(headers, body).await; - - // Determine HTTP status code based on JSON-RPC error code - // Map authentication/authorization errors to appropriate HTTP status codes - // while maintaining JSON-RPC protocol compatibility - let status_code = if let Some(ref error) = response.error { - match error.code { - ras_jsonrpc_types::error_codes::AUTHENTICATION_REQUIRED => axum::http::StatusCode::UNAUTHORIZED, - ras_jsonrpc_types::error_codes::INSUFFICIENT_PERMISSIONS => axum::http::StatusCode::FORBIDDEN, - ras_jsonrpc_types::error_codes::TOKEN_EXPIRED => axum::http::StatusCode::UNAUTHORIZED, - ras_jsonrpc_types::error_codes::CSRF_VALIDATION_FAILED => axum::http::StatusCode::FORBIDDEN, - _ => axum::http::StatusCode::OK, // Other JSON-RPC errors still return 200 OK - } - } else { - axum::http::StatusCode::OK - }; - - // Rejections otherwise bypass the usage/duration trackers - // (which run mid-dispatch); log auth/CSRF/permission - // rejections here so a bad-credential caller is observable. - if status_code != axum::http::StatusCode::OK { - if let Some(ref error) = response.error { - ras_jsonrpc_core::tracing::warn!( - status = status_code.as_u16(), - code = error.code, - "JSON-RPC request rejected" - ); - } - } - - ( - status_code, - [("Content-Type", "application/json")], - serde_json::to_string(&response).unwrap_or_else(|_| "{}".to_string()) - ) - } - } - }); - - let mut router = axum::Router::new(); - - router = router.route(&base_url, rpc_handler); - - // Bound the request body size for the JSON-RPC endpoint. - router = router.layer(axum::extract::DefaultBodyLimit::max(#body_limit_value)); - - #explorer_route_integration - - Ok(router) - } - - async fn handle_request(&self, headers: axum::http::HeaderMap, body: String) -> ras_jsonrpc_types::JsonRpcResponse { - let request: ras_jsonrpc_types::JsonRpcRequest = match serde_json::from_str(&body) { - Ok(req) => req, - Err(__ras_json_err) => { - // Log the classification and location so the reason is - // recoverable server-side; never log the rejected value. - ras_jsonrpc_core::tracing::warn!( - category = ?__ras_json_err.classify(), - line = __ras_json_err.line(), - column = __ras_json_err.column(), - "rejected JSON-RPC request: malformed JSON body" - ); - return ras_jsonrpc_types::JsonRpcResponse::error(ras_jsonrpc_types::JsonRpcError::parse_error(), None); - } - }; - - let request_id = request.id.clone(); - - if request.jsonrpc != "2.0" { - return ras_jsonrpc_types::JsonRpcResponse::error(ras_jsonrpc_types::JsonRpcError::invalid_request(), request_id); - } - - // Resolve the credential to Ok(Some/None) or Err(error response), then - // apply a single downgrade decision: OPTIONAL_AUTH methods are public, - // so any credential failure (failed CSRF, invalid/expired token) - // downgrades to anonymous rather than rejecting the whole request. - let __ras_method_is_optional = #optional_method_check; - - let auth_outcome: Result< - Option, - ras_jsonrpc_types::JsonRpcResponse, - > = if let Some(auth_provider) = &self.auth_provider { - match ras_jsonrpc_core::extract_auth_credential(&headers, &self.auth_transport) { - Ok(credential) => { - if ras_jsonrpc_core::validate_csrf_for_credential("POST", &headers, &credential, &self.auth_transport).is_err() { - Err(ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::csrf_validation_failed(), - request_id.clone(), - )) - } else { - match auth_provider.authenticate(credential.token().to_string()).await { - Ok(user) => Ok(Some(user)), - Err(ras_jsonrpc_core::AuthError::TokenExpired) => { - Err(ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::token_expired(), - request_id.clone(), - )) - } - Err(_) => Err(ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::authentication_required(), - request_id.clone(), - )), - } - } - } - Err(ras_jsonrpc_core::AuthTransportError::MissingCredentials) => Ok(None), - Err(_) => Err(ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::authentication_required(), - request_id.clone(), - )), - } - } else { - Ok(None) - }; - - let authenticated_user = match auth_outcome { - Ok(user) => user, - Err(error_response) => { - if __ras_method_is_optional { - None - } else { - return error_response; - } - } - }; - - if let Some(tracker) = &self.usage_tracker { - let user_ref = authenticated_user.as_ref(); - let tracker_headers = - ras_jsonrpc_core::redact_sensitive_headers_for_auth_transport(&headers, &self.auth_transport); - tracker(&tracker_headers, user_ref, &request).await; - } - - match request.method.as_str() { - #(#method_dispatch)* - _ => ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::method_not_found(&request.method), - request_id - ) - } - } - } - } -} - -fn jsonrpc_method_wire_name(method: &MethodDefinition) -> String { - method - .wire_name - .clone() - .unwrap_or_else(|| method.name.to_string()) -} - -fn jsonrpc_permission_groups_code(auth: &AuthRequirement) -> proc_macro2::TokenStream { - let permission_groups = match auth { - AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => Vec::new(), - AuthRequirement::WithPermissions(groups) => groups.clone(), - }; - - if permission_groups.is_empty() { - quote! { Vec::>::new() } - } else { - let groups = permission_groups.iter().map(|group| { - let perms = group.iter(); - quote! { vec![#(#perms.to_string()),*] } - }); - quote! { vec![#(#groups),*] as Vec> } - } -} - -fn jsonrpc_auth_check_code( - auth: &AuthRequirement, -) -> (proc_macro2::TokenStream, proc_macro2::TokenStream) { - match auth { - AuthRequirement::Unauthorized => (quote! {}, quote! { None }), - AuthRequirement::OptionalAuth => ( - quote! { - // OPTIONAL_AUTH: surface the (optional) caller. The request-level - // auth step already resolved `authenticated_user` best-effort; a - // present-but-bad credential was downgraded to None for this method. - // Cloned because `authenticated_user` is still needed for tracking below. - let caller = ras_jsonrpc_core::Caller::from(authenticated_user.clone()); - }, - quote! { authenticated_user.as_ref() }, - ), - AuthRequirement::WithPermissions(_) => { - let permission_groups_code = jsonrpc_permission_groups_code(auth); - ( - quote! { - let user = match &authenticated_user { - Some(u) => u, - None => return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::authentication_required(), - request.id.clone() - ), - }; - - // OR-of-AND permission check (shared ras-auth-core implementation) - let required_permission_groups: Vec> = #permission_groups_code; - let provider = self.auth_provider.as_ref().expect("auth provider required for WITH_PERMISSIONS methods"); - if let Err(error) = ras_jsonrpc_core::check_permission_groups(provider.as_ref(), user, &required_permission_groups) { - // Only `required` is surfaced to the client; the caller's - // full grant set (`has`) stays server-side. - let required = match error { - ras_jsonrpc_core::AuthError::InsufficientPermissions { required, .. } => required, - _ => Vec::new(), - }; - return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::insufficient_permissions(required), - request.id.clone() - ); - } - }, - quote! { Some(user) }, - ) - } - } -} - -fn jsonrpc_parse_params_code( - params_ident: &Ident, - request_type: &Type, -) -> proc_macro2::TokenStream { - quote! { - let #params_ident: #request_type = match request.params { - Some(params) => match serde_json::from_value(params) { - Ok(p) => p, - Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::invalid_params(e.to_string()), - request.id.clone() - ), - }, - None => match serde_json::from_value(serde_json::Value::Null) { - Ok(p) => p, - Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::invalid_params(e.to_string()), - request.id.clone() - ), - } - }; - } -} - -fn generate_jsonrpc_method_dispatches(method: &MethodDefinition) -> Vec { - let mut dispatches = vec![generate_jsonrpc_canonical_dispatch(method)]; - dispatches.extend( - method - .versions - .iter() - .map(|version| generate_jsonrpc_legacy_dispatch(method, version)), - ); - dispatches -} - -fn generate_jsonrpc_canonical_dispatch(method: &MethodDefinition) -> proc_macro2::TokenStream { - let method_name = &method.name; - let method_wire = jsonrpc_method_wire_name(method); - let request_type = &method.request_type; - let params_ident = quote::format_ident!("params"); - let parse_params = jsonrpc_parse_params_code(¶ms_ident, request_type); - let (auth_check, tracker_user) = jsonrpc_auth_check_code(&method.auth); - - let handler_call = match &method.auth { - AuthRequirement::Unauthorized => quote! { self.service.#method_name(#params_ident).await }, - AuthRequirement::OptionalAuth => { - quote! { self.service.#method_name(caller, #params_ident).await } - } - AuthRequirement::WithPermissions(_) => { - quote! { self.service.#method_name(user, #params_ident).await } - } - }; - - quote! { - #method_wire => { - #auth_check - #parse_params - - let start_time = std::time::Instant::now(); - let handler_result = #handler_call; - let duration = start_time.elapsed(); - - if let Some(duration_tracker) = &self.method_duration_tracker { - duration_tracker(#method_wire, #tracker_user, duration).await; - } - - match handler_result { - Ok(result) => { - match serde_json::to_value(result) { - Ok(result_value) => ras_jsonrpc_types::JsonRpcResponse::success(result_value, request.id.clone()), - Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), - request.id.clone() - ), - } - } - Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), - request.id.clone() - ), - } - } - } -} - -fn generate_jsonrpc_legacy_dispatch( - method: &MethodDefinition, - version: &MethodVersionDefinition, -) -> proc_macro2::TokenStream { - let method_name = &method.name; - let method_wire = &version.wire_name; - let canonical_request_type = &method.request_type; - let canonical_response_type = &method.response_type; - let legacy_request_type = &version.request_type; - let legacy_response_type = &version.response_type; - let migration_type = &version.migration_type; - let legacy_params_ident = quote::format_ident!("legacy_params"); - let params_ident = quote::format_ident!("params"); - let parse_params = jsonrpc_parse_params_code(&legacy_params_ident, legacy_request_type); - let (auth_check, tracker_user) = jsonrpc_auth_check_code(&method.auth); - - let handler_call = match &method.auth { - AuthRequirement::Unauthorized => quote! { self.service.#method_name(#params_ident).await }, - AuthRequirement::OptionalAuth => { - quote! { self.service.#method_name(caller, #params_ident).await } - } - AuthRequirement::WithPermissions(_) => { - quote! { self.service.#method_name(user, #params_ident).await } - } - }; - - quote! { - #method_wire => { - #auth_check - #parse_params - - let #params_ident: #canonical_request_type = - match <#migration_type as ras_jsonrpc_core::VersionMigration<#legacy_request_type, #canonical_request_type>>::migrate(#legacy_params_ident) { - Ok(params) => params, - Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::invalid_params(e.to_string()), - request.id.clone() - ), - }; - - let start_time = std::time::Instant::now(); - let handler_result = #handler_call; - let duration = start_time.elapsed(); - - if let Some(duration_tracker) = &self.method_duration_tracker { - duration_tracker(#method_wire, #tracker_user, duration).await; - } - - match handler_result { - Ok(result) => { - let result: #legacy_response_type = - match <#migration_type as ras_jsonrpc_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(result) { - Ok(result) => result, - Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), - request.id.clone() - ), - }; - - match serde_json::to_value(result) { - Ok(result_value) => ras_jsonrpc_types::JsonRpcResponse::success(result_value, request.id.clone()), - Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), - request.id.clone() - ), - } - } - Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), - request.id.clone() - ), - } - } - } -} diff --git a/crates/rpc/ras-jsonrpc-macro/src/server/dispatch.rs b/crates/rpc/ras-jsonrpc-macro/src/server/dispatch.rs new file mode 100644 index 0000000..2c2ddc8 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/server/dispatch.rs @@ -0,0 +1,241 @@ +//! Method authorization, parameter decoding, invocation, and version adaptation. + +use crate::ast::*; +use quote::quote; +use syn::{Ident, Type}; + +pub(super) fn jsonrpc_method_wire_name(method: &MethodDefinition) -> String { + method + .wire_name + .clone() + .unwrap_or_else(|| method.name.to_string()) +} + +fn jsonrpc_permission_groups_code(auth: &AuthRequirement) -> proc_macro2::TokenStream { + let permission_groups = match auth { + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => Vec::new(), + AuthRequirement::WithPermissions(groups) => groups.clone(), + }; + + if permission_groups.is_empty() { + quote! { Vec::>::new() } + } else { + let groups = permission_groups.iter().map(|group| { + let perms = group.iter(); + quote! { vec![#(#perms.to_string()),*] } + }); + quote! { vec![#(#groups),*] as Vec> } + } +} + +fn jsonrpc_auth_check_code( + auth: &AuthRequirement, +) -> (proc_macro2::TokenStream, proc_macro2::TokenStream) { + match auth { + AuthRequirement::Unauthorized => (quote! {}, quote! { None }), + AuthRequirement::OptionalAuth => ( + quote! { + // OPTIONAL_AUTH: surface the (optional) caller. The request-level + // auth step already resolved `authenticated_user` best-effort; a + // present-but-bad credential was downgraded to None for this method. + // Cloned because `authenticated_user` is still needed for tracking below. + let caller = ras_jsonrpc_core::Caller::from(authenticated_user.clone()); + }, + quote! { authenticated_user.as_ref() }, + ), + AuthRequirement::WithPermissions(_) => { + let permission_groups_code = jsonrpc_permission_groups_code(auth); + ( + quote! { + let user = match &authenticated_user { + Some(u) => u, + None => return ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::authentication_required(), + request.id.clone() + ), + }; + + // OR-of-AND permission check (shared ras-auth-core implementation) + let required_permission_groups: Vec> = #permission_groups_code; + let provider = self.auth_provider.as_ref().expect("auth provider required for WITH_PERMISSIONS methods"); + if let Err(error) = ras_jsonrpc_core::check_permission_groups(provider.as_ref(), user, &required_permission_groups) { + // Only `required` is surfaced to the client; the caller's + // full grant set (`has`) stays server-side. + let required = match error { + ras_jsonrpc_core::AuthError::InsufficientPermissions { required, .. } => required, + _ => Vec::new(), + }; + return ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::insufficient_permissions(required), + request.id.clone() + ); + } + }, + quote! { Some(user) }, + ) + } + } +} + +fn jsonrpc_parse_params_code( + params_ident: &Ident, + request_type: &Type, +) -> proc_macro2::TokenStream { + quote! { + let #params_ident: #request_type = match request.params { + Some(params) => match serde_json::from_value(params) { + Ok(p) => p, + Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::invalid_params(e.to_string()), + request.id.clone() + ), + }, + None => match serde_json::from_value(serde_json::Value::Null) { + Ok(p) => p, + Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::invalid_params(e.to_string()), + request.id.clone() + ), + } + }; + } +} + +pub(super) fn generate_jsonrpc_method_dispatches( + method: &MethodDefinition, +) -> Vec { + let mut dispatches = vec![generate_jsonrpc_canonical_dispatch(method)]; + dispatches.extend( + method + .versions + .iter() + .map(|version| generate_jsonrpc_legacy_dispatch(method, version)), + ); + dispatches +} + +fn generate_jsonrpc_canonical_dispatch(method: &MethodDefinition) -> proc_macro2::TokenStream { + let method_name = &method.name; + let method_wire = jsonrpc_method_wire_name(method); + let request_type = &method.request_type; + let params_ident = quote::format_ident!("params"); + let parse_params = jsonrpc_parse_params_code(¶ms_ident, request_type); + let (auth_check, tracker_user) = jsonrpc_auth_check_code(&method.auth); + + let handler_call = match &method.auth { + AuthRequirement::Unauthorized => quote! { self.service.#method_name(#params_ident).await }, + AuthRequirement::OptionalAuth => { + quote! { self.service.#method_name(caller, #params_ident).await } + } + AuthRequirement::WithPermissions(_) => { + quote! { self.service.#method_name(user, #params_ident).await } + } + }; + + quote! { + #method_wire => { + #auth_check + #parse_params + + let start_time = std::time::Instant::now(); + let handler_result = #handler_call; + let duration = start_time.elapsed(); + + if let Some(duration_tracker) = &self.method_duration_tracker { + duration_tracker(#method_wire, #tracker_user, duration).await; + } + + match handler_result { + Ok(result) => { + match serde_json::to_value(result) { + Ok(result_value) => ras_jsonrpc_types::JsonRpcResponse::success(result_value, request.id.clone()), + Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), + request.id.clone() + ), + } + } + Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), + request.id.clone() + ), + } + } + } +} + +fn generate_jsonrpc_legacy_dispatch( + method: &MethodDefinition, + version: &MethodVersionDefinition, +) -> proc_macro2::TokenStream { + let method_name = &method.name; + let method_wire = &version.wire_name; + let canonical_request_type = &method.request_type; + let canonical_response_type = &method.response_type; + let legacy_request_type = &version.request_type; + let legacy_response_type = &version.response_type; + let migration_type = &version.migration_type; + let legacy_params_ident = quote::format_ident!("legacy_params"); + let params_ident = quote::format_ident!("params"); + let parse_params = jsonrpc_parse_params_code(&legacy_params_ident, legacy_request_type); + let (auth_check, tracker_user) = jsonrpc_auth_check_code(&method.auth); + + let handler_call = match &method.auth { + AuthRequirement::Unauthorized => quote! { self.service.#method_name(#params_ident).await }, + AuthRequirement::OptionalAuth => { + quote! { self.service.#method_name(caller, #params_ident).await } + } + AuthRequirement::WithPermissions(_) => { + quote! { self.service.#method_name(user, #params_ident).await } + } + }; + + quote! { + #method_wire => { + #auth_check + #parse_params + + let #params_ident: #canonical_request_type = + match <#migration_type as ras_jsonrpc_core::VersionMigration<#legacy_request_type, #canonical_request_type>>::migrate(#legacy_params_ident) { + Ok(params) => params, + Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::invalid_params(e.to_string()), + request.id.clone() + ), + }; + + let start_time = std::time::Instant::now(); + let handler_result = #handler_call; + let duration = start_time.elapsed(); + + if let Some(duration_tracker) = &self.method_duration_tracker { + duration_tracker(#method_wire, #tracker_user, duration).await; + } + + match handler_result { + Ok(result) => { + let result: #legacy_response_type = + match <#migration_type as ras_jsonrpc_core::VersionMigration<#canonical_response_type, #legacy_response_type>>::migrate(result) { + Ok(result) => result, + Err(e) => return ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), + request.id.clone() + ), + }; + + match serde_json::to_value(result) { + Ok(result_value) => ras_jsonrpc_types::JsonRpcResponse::success(result_value, request.id.clone()), + Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), + request.id.clone() + ), + } + } + Err(e) => ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::internal_error(e.to_string()), + request.id.clone() + ), + } + } + } +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/server/http.rs b/crates/rpc/ras-jsonrpc-macro/src/server/http.rs new file mode 100644 index 0000000..ed2d080 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/server/http.rs @@ -0,0 +1,330 @@ +//! HTTP routing, envelope handling, and request-level authentication. + +use super::dispatch::{generate_jsonrpc_method_dispatches, jsonrpc_method_wire_name}; +use crate::ast::*; +use quote::quote; + +pub(super) fn generate_http_methods(service_def: &ServiceDefinition) -> proc_macro2::TokenStream { + let service_name = &service_def.service_name; + let service_name_str = service_name.to_string(); + + let explorer_enabled = service_def.explorer.is_some() && service_def.openrpc.is_some(); + + // Content-Type gate: reject a non-`application/json` body with 415 before + // parsing. Requiring `application/json` forces a CORS preflight for + // cross-origin requests, closing the simple-request CSRF shape. + let content_type_gate = if service_def.require_json_content_type { + quote! { + { + let __ras_content_type_ok = headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or("") + .trim() + .eq_ignore_ascii_case("application/json") + }) + .unwrap_or(false); + if !__ras_content_type_ok { + ras_jsonrpc_core::tracing::warn!( + "rejected JSON-RPC request: Content-Type is not application/json" + ); + return ( + axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, + [("Content-Type", "application/json")], + serde_json::to_string(&ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::invalid_request(), + None, + )) + .unwrap_or_else(|_| "{}".to_string()), + ); + } + } + } + } else { + quote! {} + }; + + // Body-size cap: apply as a DefaultBodyLimit layer so an over-limit body + // is rejected by the extractor before the handler runs. + let body_limit_value = service_def.body_limit.unwrap_or(DEFAULT_BODY_LIMIT); + + // Startup assertion: a service with any WITH_PERMISSIONS method (or a + // gated explorer) needs an auth provider, else every such call silently fails + // authentication at runtime. Fail the build instead. + let any_route_requires_auth = service_def + .methods + .iter() + .any(|method| matches!(method.auth, AuthRequirement::WithPermissions(_))) + || (explorer_enabled && service_def.docs_require_auth); + let provider_check = if any_route_requires_auth { + quote! { + if self.auth_provider.is_none() { + return Err(concat!( + "JSON-RPC service `", + #service_name_str, + "` has methods requiring authorization (WITH_PERMISSIONS) but no ", + "auth_provider was configured; call .auth_provider(...) before build()" + ) + .to_string()); + } + } + } else { + quote! {} + }; + + // Apply the explorer's auth policy where the service auth configuration + // is available. + let explorer_route_integration = if explorer_enabled { + let service_name_lower = service_name_str.to_lowercase(); + let explorer_routes_fn_str = [&service_name_lower, "_explorer_routes"].concat(); + let explorer_routes_fn = syn::Ident::new(&explorer_routes_fn_str, service_name.span()); + if service_def.docs_require_auth { + quote! { + { + let __ras_docs_service = service.clone(); + // `route_layer` (not `layer`) so the gate runs only for the + // explorer/openrpc routes that actually match — an unrelated + // path 404s without the middleware, and the RPC endpoint + // (a separate route) is never gated. + let __ras_explorer = #explorer_routes_fn(&base_url).route_layer( + axum::middleware::from_fn(move |__ras_req: axum::extract::Request, __ras_next: axum::middleware::Next| { + let __ras_docs_service = __ras_docs_service.clone(); + async move { + use axum::response::IntoResponse; + let __ras_headers = __ras_req.headers().clone(); + let __ras_empty: Vec> = Vec::new(); + match ras_jsonrpc_core::authorize_request( + "GET", + &__ras_headers, + &__ras_docs_service.auth_transport, + __ras_docs_service.auth_provider.as_deref(), + &__ras_empty, + ).await { + Ok(_) => __ras_next.run(__ras_req).await, + Err(__ras_err) => { + let __ras_status = match __ras_err { + ras_jsonrpc_core::AuthorizeError::CsrfValidationFailed + | ras_jsonrpc_core::AuthorizeError::InsufficientPermissions(_) => + axum::http::StatusCode::FORBIDDEN, + ras_jsonrpc_core::AuthorizeError::NoAuthProvider => + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + _ => axum::http::StatusCode::UNAUTHORIZED, + }; + ras_jsonrpc_core::tracing::warn!(status = __ras_status.as_u16(), "explorer request rejected"); + ( + __ras_status, + [("Content-Type", "application/json")], + serde_json::json!({ "error": "Authentication required" }).to_string(), + ).into_response() + } + } + } + }) + ); + router = router.merge(__ras_explorer); + } + } + } else { + quote! { router = router.merge(#explorer_routes_fn(&base_url)); } + } + } else { + quote! {} + }; + + // Wire names of OPTIONAL_AUTH methods (canonical + legacy versions). The + // request-level auth step rejects bad credentials globally; for these methods + // we instead downgrade to anonymous so the route stays lenient/public. + let optional_auth_wire_names: Vec = service_def + .methods + .iter() + .filter(|method| matches!(method.auth, AuthRequirement::OptionalAuth)) + .flat_map(|method| { + std::iter::once(jsonrpc_method_wire_name(method)).chain( + method + .versions + .iter() + .map(|version| version.wire_name.clone()), + ) + }) + .collect(); + let optional_method_check = if optional_auth_wire_names.is_empty() { + quote! { false } + } else { + quote! { matches!(request.method.as_str(), #(#optional_auth_wire_names)|*) } + }; + + let method_dispatch = service_def + .methods + .iter() + .flat_map(generate_jsonrpc_method_dispatches); + + quote! { + /// Build the axum router for the JSON-RPC service + pub fn build(self) -> Result { + self.auth_transport + .validate() + .map_err(|err| err.to_string())?; + + #provider_check + + let base_url = self.base_url.clone(); + let service = std::sync::Arc::new(self); + + let rpc_handler = axum::routing::post({ + // Clone into the handler so the outer `service` survives for + // the (optional) docs auth gate below. + let service = service.clone(); + move |headers: axum::http::HeaderMap, body: String| { + let service = service.clone(); + async move { + // Reject a non-`application/json` body with 415 before parsing. + #content_type_gate + + let response = service.handle_request(headers, body).await; + + // Determine HTTP status code based on JSON-RPC error code + // Map authentication/authorization errors to appropriate HTTP status codes + // while maintaining JSON-RPC protocol compatibility + let status_code = if let Some(ref error) = response.error { + match error.code { + ras_jsonrpc_types::error_codes::AUTHENTICATION_REQUIRED => axum::http::StatusCode::UNAUTHORIZED, + ras_jsonrpc_types::error_codes::INSUFFICIENT_PERMISSIONS => axum::http::StatusCode::FORBIDDEN, + ras_jsonrpc_types::error_codes::TOKEN_EXPIRED => axum::http::StatusCode::UNAUTHORIZED, + ras_jsonrpc_types::error_codes::CSRF_VALIDATION_FAILED => axum::http::StatusCode::FORBIDDEN, + _ => axum::http::StatusCode::OK, // Other JSON-RPC errors still return 200 OK + } + } else { + axum::http::StatusCode::OK + }; + + // Rejections otherwise bypass the usage/duration trackers + // (which run mid-dispatch); log auth/CSRF/permission + // rejections here so a bad-credential caller is observable. + if status_code != axum::http::StatusCode::OK { + if let Some(ref error) = response.error { + ras_jsonrpc_core::tracing::warn!( + status = status_code.as_u16(), + code = error.code, + "JSON-RPC request rejected" + ); + } + } + + ( + status_code, + [("Content-Type", "application/json")], + serde_json::to_string(&response).unwrap_or_else(|_| "{}".to_string()) + ) + } + } + }); + + let mut router = axum::Router::new(); + + router = router.route(&base_url, rpc_handler); + + // Bound the request body size for the JSON-RPC endpoint. + router = router.layer(axum::extract::DefaultBodyLimit::max(#body_limit_value)); + + #explorer_route_integration + + Ok(router) + } + + async fn handle_request(&self, headers: axum::http::HeaderMap, body: String) -> ras_jsonrpc_types::JsonRpcResponse { + let request: ras_jsonrpc_types::JsonRpcRequest = match serde_json::from_str(&body) { + Ok(req) => req, + Err(__ras_json_err) => { + // Log the classification and location so the reason is + // recoverable server-side; never log the rejected value. + ras_jsonrpc_core::tracing::warn!( + category = ?__ras_json_err.classify(), + line = __ras_json_err.line(), + column = __ras_json_err.column(), + "rejected JSON-RPC request: malformed JSON body" + ); + return ras_jsonrpc_types::JsonRpcResponse::error(ras_jsonrpc_types::JsonRpcError::parse_error(), None); + } + }; + + let request_id = request.id.clone(); + + if request.jsonrpc != "2.0" { + return ras_jsonrpc_types::JsonRpcResponse::error(ras_jsonrpc_types::JsonRpcError::invalid_request(), request_id); + } + + // Resolve the credential to Ok(Some/None) or Err(error response), then + // apply a single downgrade decision: OPTIONAL_AUTH methods are public, + // so any credential failure (failed CSRF, invalid/expired token) + // downgrades to anonymous rather than rejecting the whole request. + let __ras_method_is_optional = #optional_method_check; + + let auth_outcome: Result< + Option, + ras_jsonrpc_types::JsonRpcResponse, + > = if let Some(auth_provider) = &self.auth_provider { + match ras_jsonrpc_core::extract_auth_credential(&headers, &self.auth_transport) { + Ok(credential) => { + if ras_jsonrpc_core::validate_csrf_for_credential("POST", &headers, &credential, &self.auth_transport).is_err() { + Err(ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::csrf_validation_failed(), + request_id.clone(), + )) + } else { + match auth_provider.authenticate(credential.token().to_string()).await { + Ok(user) => Ok(Some(user)), + Err(ras_jsonrpc_core::AuthError::TokenExpired) => { + Err(ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::token_expired(), + request_id.clone(), + )) + } + Err(_) => Err(ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::authentication_required(), + request_id.clone(), + )), + } + } + } + Err(ras_jsonrpc_core::AuthTransportError::MissingCredentials) => Ok(None), + Err(_) => Err(ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::authentication_required(), + request_id.clone(), + )), + } + } else { + Ok(None) + }; + + let authenticated_user = match auth_outcome { + Ok(user) => user, + Err(error_response) => { + if __ras_method_is_optional { + None + } else { + return error_response; + } + } + }; + + if let Some(tracker) = &self.usage_tracker { + let user_ref = authenticated_user.as_ref(); + let tracker_headers = + ras_jsonrpc_core::redact_sensitive_headers_for_auth_transport(&headers, &self.auth_transport); + tracker(&tracker_headers, user_ref, &request).await; + } + + match request.method.as_str() { + #(#method_dispatch)* + _ => ras_jsonrpc_types::JsonRpcResponse::error( + ras_jsonrpc_types::JsonRpcError::method_not_found(&request.method), + request_id + ) + } + } + } +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/server/mod.rs b/crates/rpc/ras-jsonrpc-macro/src/server/mod.rs new file mode 100644 index 0000000..1283f7a --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/server/mod.rs @@ -0,0 +1,137 @@ +//! JSON-RPC service trait and builder. + +use crate::ast::*; +use quote::quote; +mod dispatch; +mod http; + +pub(crate) fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenStream { + let service_name = &service_def.service_name; + let service_trait_name = quote::format_ident!("{}Trait", service_name); + let builder_name = quote::format_ident!("{}Builder", service_name); + + let trait_methods = service_def.methods.iter().map(|method| { + let method_name = &method.name; + let request_type = &method.request_type; + let response_type = &method.response_type; + + match &method.auth { + AuthRequirement::Unauthorized => { + quote! { + fn #method_name(&self, request: #request_type) -> impl std::future::Future>> + Send; + } + } + AuthRequirement::OptionalAuth => { + quote! { + fn #method_name(&self, caller: ras_jsonrpc_core::Caller, request: #request_type) -> impl std::future::Future>> + Send; + } + } + AuthRequirement::WithPermissions(_) => { + quote! { + fn #method_name(&self, user: &ras_jsonrpc_core::AuthenticatedUser, request: #request_type) -> impl std::future::Future>> + Send; + } + } + } + }); + + let http_methods = http::generate_http_methods(service_def); + + quote! { + /// Generated service trait + #[allow(private_interfaces, private_bounds)] + pub trait #service_trait_name: Send + Sync + 'static { + #(#trait_methods)* + } + + /// Generated builder for the JSON-RPC service + pub struct #builder_name { + base_url: String, + service: std::sync::Arc, + auth_provider: Option>, + auth_transport: ras_jsonrpc_core::AuthTransportConfig, + usage_tracker: Option, &ras_jsonrpc_types::JsonRpcRequest) -> std::pin::Pin + Send>> + Send + Sync>>, + method_duration_tracker: Option, std::time::Duration) -> std::pin::Pin + Send>> + Send + Sync>>, + } + + impl #builder_name { + /// Create a new builder with the service implementation. + /// + /// The JSON-RPC route defaults to `/rpc`; use `base_url` to override it. + pub fn new(service: T) -> Self { + Self { + base_url: "/rpc".to_string(), + service: std::sync::Arc::new(service), + auth_provider: None, + auth_transport: ras_jsonrpc_core::AuthTransportConfig::default(), + usage_tracker: None, + method_duration_tracker: None, + } + } + + /// Override the JSON-RPC route path. + pub fn base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into(); + self + } + + /// Set the auth provider + pub fn auth_provider(mut self, provider: A) -> Self { + self.auth_provider = Some(Box::new(provider)); + self + } + + /// Enable cookie authentication alongside bearer tokens. + /// + /// Installs a default double-submit CSRF config when none is set, + /// because cookie credentials are CSRF-exploitable on unsafe methods. + /// Override with `csrf_protection`. + pub fn auth_cookie(mut self, cookie: ras_jsonrpc_core::AuthCookieConfig) -> Self { + self.auth_transport.cookie = Some(cookie); + if self.auth_transport.csrf.is_none() { + self.auth_transport.csrf = Some(ras_jsonrpc_core::CsrfConfig::default()); + } + self + } + + /// Replace the full auth transport configuration. + pub fn auth_transport(mut self, transport: ras_jsonrpc_core::AuthTransportConfig) -> Self { + self.auth_transport = transport; + self + } + + /// Require CSRF validation for cookie-authenticated JSON-RPC requests. + pub fn csrf_protection(mut self, csrf: ras_jsonrpc_core::CsrfConfig) -> Self { + self.auth_transport.csrf = Some(csrf); + self + } + + /// Set the usage tracker function + /// This function will be called for each request with headers, authenticated user (if any), and the JSON-RPC request + pub fn with_usage_tracker(mut self, tracker: F) -> Self + where + F: Fn(&axum::http::HeaderMap, Option<&ras_jsonrpc_core::AuthenticatedUser>, &ras_jsonrpc_types::JsonRpcRequest) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + self.usage_tracker = Some(Box::new(move |headers, user, request| { + Box::pin(tracker(headers, user, request)) + })); + self + } + + /// Set the method duration tracker function + /// This function will be called after each method completes with the method name, authenticated user (if any), and the duration + pub fn with_method_duration_tracker(mut self, tracker: F) -> Self + where + F: Fn(&str, Option<&ras_jsonrpc_core::AuthenticatedUser>, std::time::Duration) -> Fut + Send + Sync + 'static, + Fut: std::future::Future + Send + 'static, + { + self.method_duration_tracker = Some(Box::new(move |method, user, duration| { + Box::pin(tracker(method, user, duration)) + })); + self + } + + #http_methods + } + } +} diff --git a/documentation/reviews/refactor-progress.md b/documentation/reviews/refactor-progress.md index d9df256..55f79ff 100644 --- a/documentation/reviews/refactor-progress.md +++ b/documentation/reviews/refactor-progress.md @@ -17,3 +17,4 @@ REST baseline: 61/61 passed. | 1 | REST model and parser | 61 tests, 1 doctest; Clippy; no-default/server/client macro builds; no-default/server native and client WASM `rest-api` builds. | | 2 | REST expansion, routing, request extraction, canonical/versioned handlers | 61 tests, 1 doctest; Clippy; all three macro feature modes; server native and client WASM consumer builds. | | 3 | JSON-RPC model and parser | 59 tests; doctests (1 pre-existing ignored example); Clippy; all macro feature modes and no-default/server/client-WASM `basic-jsonrpc-api` builds. | +| 4 | JSON-RPC builder, HTTP envelope/auth policy, method/version dispatch | 59 tests; doctests; Clippy; all macro feature modes; native-server and WASM-client consumer builds. | From 4b5edefc85bb4b1f6ec9d396b8eff25935c4c0e5 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 11:12:24 +0200 Subject: [PATCH 05/35] refactor(explorer): give shared assets explicit package ownership --- Cargo.lock | 6 ++++++ crates/rest/ras-rest-macro/Cargo.toml | 1 + crates/rest/ras-rest-macro/src/static_hosting.rs | 2 +- crates/rest/ras-rest-macro/tests/xss_protection_test.rs | 2 +- crates/rpc/ras-jsonrpc-macro/Cargo.toml | 1 + crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs | 3 +-- .../tests/explorer_token_storage_test.rs | 2 +- crates/specs/ras-api-explorer-assets/Cargo.toml | 9 +++++++++ crates/specs/ras-api-explorer-assets/README.md | 4 ++++ crates/specs/ras-api-explorer-assets/src/lib.rs | 5 +++++ .../ras-api-explorer-assets/src/template.html} | 0 documentation/reviews/refactor-progress.md | 1 + 12 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 crates/specs/ras-api-explorer-assets/Cargo.toml create mode 100644 crates/specs/ras-api-explorer-assets/README.md create mode 100644 crates/specs/ras-api-explorer-assets/src/lib.rs rename crates/{rest/ras-rest-macro/src/api_explorer_template.html => specs/ras-api-explorer-assets/src/template.html} (100%) diff --git a/Cargo.lock b/Cargo.lock index 854cf4f..aa60c77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2685,6 +2685,10 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ras-api-explorer-assets" +version = "0.1.0" + [[package]] name = "ras-auth-core" version = "0.3.0" @@ -2929,6 +2933,7 @@ dependencies = [ "proc-macro2", "quote", "rand 0.8.6", + "ras-api-explorer-assets", "ras-auth-core", "ras-identity-session", "ras-jsonrpc-core", @@ -3027,6 +3032,7 @@ dependencies = [ "proc-macro2", "quote", "rand 0.8.6", + "ras-api-explorer-assets", "ras-auth-core", "ras-identity-session", "ras-jsonrpc-core", diff --git a/crates/rest/ras-rest-macro/Cargo.toml b/crates/rest/ras-rest-macro/Cargo.toml index 5b2700d..be80204 100644 --- a/crates/rest/ras-rest-macro/Cargo.toml +++ b/crates/rest/ras-rest-macro/Cargo.toml @@ -20,6 +20,7 @@ reqwest = ["client", "ras-transport-core/reqwest"] permissions = [] [dependencies] +ras-api-explorer-assets = { path = "../../specs/ras-api-explorer-assets", version = "0.1.0" } syn = { workspace = true } quote = { workspace = true } proc-macro2 = { workspace = true } diff --git a/crates/rest/ras-rest-macro/src/static_hosting.rs b/crates/rest/ras-rest-macro/src/static_hosting.rs index 559a64a..749d380 100644 --- a/crates/rest/ras-rest-macro/src/static_hosting.rs +++ b/crates/rest/ras-rest-macro/src/static_hosting.rs @@ -34,7 +34,7 @@ pub fn generate_static_hosting_code( return quote! {}; } - const TEMPLATE_CONTENT: &str = include_str!("api_explorer_template.html"); + const TEMPLATE_CONTENT: &str = ras_api_explorer_assets::TEMPLATE; let service_name = &service_def.service_name; let base_path = service_def.base_path.trim_end_matches('/').to_string(); diff --git a/crates/rest/ras-rest-macro/tests/xss_protection_test.rs b/crates/rest/ras-rest-macro/tests/xss_protection_test.rs index 0b5ec1c..71c2a5a 100644 --- a/crates/rest/ras-rest-macro/tests/xss_protection_test.rs +++ b/crates/rest/ras-rest-macro/tests/xss_protection_test.rs @@ -19,7 +19,7 @@ fn test_xss_protection_in_generated_html() { #[test] fn test_generated_docs_do_not_store_bearer_token_in_local_storage() { - let template = include_str!("../src/api_explorer_template.html"); + let template = ras_api_explorer_assets::TEMPLATE; assert!(!template.contains("localStorage.getItem('bearer-token')")); assert!(!template.contains("localStorage.setItem('bearer-token'")); assert!(!template.contains("localStorage.removeItem('bearer-token'")); diff --git a/crates/rpc/ras-jsonrpc-macro/Cargo.toml b/crates/rpc/ras-jsonrpc-macro/Cargo.toml index b903829..c29ac87 100644 --- a/crates/rpc/ras-jsonrpc-macro/Cargo.toml +++ b/crates/rpc/ras-jsonrpc-macro/Cargo.toml @@ -20,6 +20,7 @@ reqwest = ["client", "ras-transport-core/reqwest"] permissions = [] [dependencies] +ras-api-explorer-assets = { path = "../../specs/ras-api-explorer-assets", version = "0.1.0" } syn = { workspace = true } quote = { workspace = true } proc-macro2 = { workspace = true } diff --git a/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs b/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs index 2503d1e..82c2128 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/static_hosting.rs @@ -29,8 +29,7 @@ pub fn generate_static_hosting_code( return TokenStream::new(); } - const TEMPLATE_CONTENT: &str = - include_str!("../../../rest/ras-rest-macro/src/api_explorer_template.html"); + const TEMPLATE_CONTENT: &str = ras_api_explorer_assets::TEMPLATE; let explorer_path_suffix = normalize_explorer_path(&config.explorer_path); let service_name_str = service_name.to_string(); diff --git a/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs b/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs index 90bf485..29ab26d 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/explorer_token_storage_test.rs @@ -1,6 +1,6 @@ #[test] fn test_generated_explorer_does_not_store_bearer_token_in_local_storage() { - let template = include_str!("../../../rest/ras-rest-macro/src/api_explorer_template.html"); + let template = ras_api_explorer_assets::TEMPLATE; assert!(!template.contains("localStorage.getItem('bearer-token')")); assert!(!template.contains("localStorage.setItem('bearer-token'")); assert!(!template.contains("localStorage.removeItem('bearer-token'")); diff --git a/crates/specs/ras-api-explorer-assets/Cargo.toml b/crates/specs/ras-api-explorer-assets/Cargo.toml new file mode 100644 index 0000000..fdfc002 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "ras-api-explorer-assets" +version = "0.1.0" +edition = "2024" +rust-version = "1.88" +description = "Embedded API explorer assets shared by RAS service macros" +license = "MIT OR Apache-2.0" +repository = "https://github.com/JedimEmO/rust-api-stack" +readme = "README.md" diff --git a/crates/specs/ras-api-explorer-assets/README.md b/crates/specs/ras-api-explorer-assets/README.md new file mode 100644 index 0000000..6652df9 --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/README.md @@ -0,0 +1,4 @@ +Embedded API explorer assets for the REST and JSON-RPC service macros. + +The macros embed `TEMPLATE` and replace its configuration placeholder with +escaped JSON. This crate owns the shared browser asset and has no runtime dependencies. diff --git a/crates/specs/ras-api-explorer-assets/src/lib.rs b/crates/specs/ras-api-explorer-assets/src/lib.rs new file mode 100644 index 0000000..f2b9fee --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/lib.rs @@ -0,0 +1,5 @@ +//! Embedded API explorer shared by REST and JSON-RPC service macros. + +/// Self-contained explorer HTML. Replace `{EXPLORER_CONFIG_JSON}` with JSON +/// whose `<` characters are escaped to keep it inside the configuration script. +pub const TEMPLATE: &str = include_str!("template.html"); diff --git a/crates/rest/ras-rest-macro/src/api_explorer_template.html b/crates/specs/ras-api-explorer-assets/src/template.html similarity index 100% rename from crates/rest/ras-rest-macro/src/api_explorer_template.html rename to crates/specs/ras-api-explorer-assets/src/template.html diff --git a/documentation/reviews/refactor-progress.md b/documentation/reviews/refactor-progress.md index 55f79ff..76c54f2 100644 --- a/documentation/reviews/refactor-progress.md +++ b/documentation/reviews/refactor-progress.md @@ -18,3 +18,4 @@ REST baseline: 61/61 passed. | 2 | REST expansion, routing, request extraction, canonical/versioned handlers | 61 tests, 1 doctest; Clippy; all three macro feature modes; server native and client WASM consumer builds. | | 3 | JSON-RPC model and parser | 59 tests; doctests (1 pre-existing ignored example); Clippy; all macro feature modes and no-default/server/client-WASM `basic-jsonrpc-api` builds. | | 4 | JSON-RPC builder, HTTP envelope/auth policy, method/version dispatch | 59 tests; doctests; Clippy; all macro feature modes; native-server and WASM-client consumer builds. | +| 5 | Shared explorer assets crate | Original template SHA-256 preserved; 120 macro tests; docs/Clippy/features; 11/11 browser tests (baseline also 11/11); asset and macro packages created offline, unpacked macro builds pass using local dependency patches. | From dc800443b38aab39c41cc06086396c68936a0705 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 11:15:49 +0200 Subject: [PATCH 06/35] refactor(websocket): separate subscription policy and handler tests --- .../src/connection.rs | 26 +- .../src/handler.rs | 2060 ----------------- .../src/handler/mod.rs | 786 +++++++ .../src/handler/tests/keepalive.rs | 61 + .../src/handler/tests/lifecycle.rs | 140 ++ .../src/handler/tests/mod.rs | 364 +++ .../src/handler/tests/protocol.rs | 182 ++ .../src/handler/tests/revalidation.rs | 243 ++ .../src/handler/tests/subscriptions.rs | 231 ++ .../src/lib.rs | 1 + .../src/subscriptions.rs | 87 + documentation/reviews/refactor-progress.md | 1 + 12 files changed, 2098 insertions(+), 2084 deletions(-) delete mode 100644 crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs create mode 100644 crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/mod.rs create mode 100644 crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/keepalive.rs create mode 100644 crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/lifecycle.rs create mode 100644 crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/mod.rs create mode 100644 crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/protocol.rs create mode 100644 crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/revalidation.rs create mode 100644 crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/subscriptions.rs create mode 100644 crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/subscriptions.rs diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/connection.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/connection.rs index f04ef88..012fa0e 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/connection.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/connection.rs @@ -1,9 +1,9 @@ //! Connection context and management -use crate::handler::{SubscriptionAccounting, SubscriptionLimits}; +pub use crate::subscriptions::SubscriptionPolicy; use ras_auth_core::AuthenticatedUser; use ras_jsonrpc_bidirectional_types::{ - BidirectionalError, BidirectionalMessage, ConnectionId, ConnectionInfo, ConnectionManager, + BidirectionalError, BidirectionalMessage, ConnectionId, ConnectionInfo, }; use std::sync::Arc; use tokio::sync::{RwLock, mpsc}; @@ -77,28 +77,6 @@ impl ChannelMessageSender { } } -/// Everything a subscription mutation has to be checked against and mirrored -/// into. Owned by the service and shared by all of its connections. -#[derive(Clone, Default)] -pub struct SubscriptionPolicy { - /// Caps applied to every subscribe - pub limits: SubscriptionLimits, - /// Service-wide counter behind the global cap - pub accounting: Arc, - /// Manager whose topic index mirrors accepted subscriptions - pub manager: Option>, -} - -impl std::fmt::Debug for SubscriptionPolicy { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SubscriptionPolicy") - .field("limits", &self.limits) - .field("held", &self.accounting.total()) - .field("manager", &self.manager.is_some()) - .finish() - } -} - /// Context information for an active WebSocket connection. /// /// Subscription state can only change through [`subscribe`](Self::subscribe) diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs deleted file mode 100644 index e266140..0000000 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler.rs +++ /dev/null @@ -1,2060 +0,0 @@ -//! Message handlers for WebSocket communication - -use crate::{ConnectionContext, ServerError, ServerResult, connection::OutboundMessage}; -use async_trait::async_trait; -use axum::extract::ws::{CloseFrame, Message, WebSocket}; -use futures::stream::StreamExt; -use ras_auth_core::AuthProvider; -use ras_jsonrpc_bidirectional_types::{BidirectionalMessage, ConnectionManager}; -use ras_jsonrpc_types::{JsonRpcError, JsonRpcRequest, JsonRpcResponse, error_codes}; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::mpsc; -use tracing::{debug, error, info, warn}; - -/// Trait for handling JSON-RPC requests within a WebSocket context -#[async_trait] -pub trait MessageHandler: Send + Sync + 'static { - /// Handle an incoming JSON-RPC request - /// - /// # Arguments - /// * `request` - The JSON-RPC request to handle - /// * `context` - The connection context containing auth info and metadata - /// - /// # Returns - /// * `Ok(Some(response))` - Response to send back to client - /// * `Ok(None)` - No response needed (for notifications) - /// * `Err(error)` - Error occurred during handling - async fn handle_request( - &self, - request: JsonRpcRequest, - context: Arc, - ) -> ServerResult>; - - /// Decide whether this connection may subscribe to `topic`. - /// - /// Default-deny: services that broadcast over topics must override this - /// (or `handle_subscribe`) to allow the topics a connection is entitled - /// to. Errors propagate to the handler loop and close the connection. - async fn authorize_subscribe( - &self, - _topic: &str, - _context: &Arc, - ) -> ServerResult { - Ok(false) - } - - /// Handle subscription requests - async fn handle_subscribe( - &self, - topics: Vec, - context: Arc, - ) -> ServerResult<()> { - // Default implementation subscribes the connection to each topic the - // service authorizes via `authorize_subscribe`; denied topics are - // skipped without closing the connection. - for topic in topics { - if self.authorize_subscribe(&topic, &context).await? { - if let Err(e) = context.subscribe(topic.clone()).await { - warn!( - "Refused subscription to topic '{}' for connection {}: {}", - topic, context.id, e - ); - } - } else { - warn!( - "Denied subscription to topic '{}' for connection {}", - topic, context.id - ); - } - } - Ok(()) - } - - /// Handle unsubscription requests - async fn handle_unsubscribe( - &self, - topics: Vec, - context: Arc, - ) -> ServerResult<()> { - for topic in topics { - context.unsubscribe(&topic).await; - } - Ok(()) - } - - /// Handle connection established event - async fn on_connect(&self, context: Arc) -> ServerResult<()> { - info!("Connection established: {}", context.id); - Ok(()) - } - - /// Handle connection closed event - async fn on_disconnect( - &self, - context: Arc, - reason: Option, - ) -> ServerResult<()> { - info!("Connection closed: {} (reason: {:?})", context.id, reason); - Ok(()) - } - - /// Handle ping message - async fn on_ping(&self, _context: Arc) -> ServerResult<()> { - debug!("Received ping"); - Ok(()) - } - - /// Handle pong message - async fn on_pong(&self, _context: Arc) -> ServerResult<()> { - debug!("Received pong"); - Ok(()) - } -} - -/// WebSocket message shape used by the server handler loop. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum WebSocketIoMessage { - Text(String), - Binary(Vec), - Ping(Vec), - Pong(Vec), - Close(Option), -} - -impl From for WebSocketIoMessage { - fn from(message: Message) -> Self { - match message { - Message::Text(text) => Self::Text(text.to_string()), - Message::Binary(data) => Self::Binary(data.to_vec()), - Message::Ping(data) => Self::Ping(data.to_vec()), - Message::Pong(data) => Self::Pong(data.to_vec()), - Message::Close(frame) => Self::Close(frame.map(|frame| frame.reason.to_string())), - } - } -} - -/// Minimal socket interface used by the message loop. -#[async_trait] -pub trait WebSocketIo: Send { - async fn send(&mut self, message: WebSocketIoMessage) -> ServerResult<()>; - async fn recv(&mut self) -> Option>; -} - -pub(crate) struct AxumWebSocketIo { - socket: WebSocket, -} - -impl AxumWebSocketIo { - pub(crate) fn new(socket: WebSocket) -> Self { - Self { socket } - } -} - -#[async_trait] -impl WebSocketIo for AxumWebSocketIo { - async fn send(&mut self, message: WebSocketIoMessage) -> ServerResult<()> { - let message = match message { - WebSocketIoMessage::Text(text) => Message::Text(text.into()), - WebSocketIoMessage::Binary(data) => Message::Binary(data.into()), - WebSocketIoMessage::Ping(data) => Message::Ping(data.into()), - WebSocketIoMessage::Pong(data) => Message::Pong(data.into()), - WebSocketIoMessage::Close(reason) => Message::Close(reason.map(|reason| CloseFrame { - code: axum::extract::ws::close_code::NORMAL, - reason: reason.into(), - })), - }; - - self.socket - .send(message) - .await - .map_err(|e| ServerError::WebSocketError(e.to_string())) - } - - async fn recv(&mut self) -> Option> { - self.socket.next().await.map(|message| { - message - .map(WebSocketIoMessage::from) - .map_err(|e| ServerError::WebSocketError(e.to_string())) - }) - } -} - -/// Default interval between credential re-validations on long-lived connections. -pub const DEFAULT_AUTH_REVALIDATION_INTERVAL: Duration = Duration::from_secs(30); - -/// Periodic credential re-validation for a long-lived connection. -/// -/// The token is captured before the WebSocket upgrade and re-run through the -/// auth provider on every `interval` tick. Failure closes the connection; -/// success refreshes the cached user (so permission changes propagate). This -/// bounds the lifetime of revoked/expired credentials on an open socket to at -/// most one interval. -pub struct AuthRevalidation { - /// Provider used to re-run authentication - pub auth_provider: Arc, - /// Token captured at upgrade time - pub token: String, - /// How often to re-validate - pub interval: Duration, - /// What to do when re-validation succeeds but the permission set changed - pub on_permission_change: PermissionChangePolicy, -} - -/// Policy applied when a live connection's permissions change on -/// re-validation (W1). -/// -/// In both modes every held subscription is re-run through -/// [`MessageHandler::authorize_subscribe`] against the refreshed user and -/// topics that are no longer authorized are dropped, so a downgraded -/// connection stops receiving topic broadcasts within one interval. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PermissionChangePolicy { - /// Keep the socket open and silently drop subscriptions that are no - /// longer authorized. - #[default] - DropSubscriptions, - /// Close the socket so the client must reconnect and re-authenticate. - Close, -} - -/// Limits on client-initiated subscriptions (W3). -/// -/// Enforced by the handler loop before [`MessageHandler::handle_subscribe`] -/// runs, so services never see an over-limit request. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SubscriptionLimits { - /// Maximum topics in one `Subscribe`/`Unsubscribe` message - pub max_topics_per_message: usize, - /// Maximum concurrent subscriptions held by one connection - pub max_topics_per_connection: usize, - /// Maximum topic name length in bytes - pub max_topic_length: usize, - /// Maximum (connection, topic) pairs across the whole manager. `0` - /// disables the cap. Enforced only when the manager reports its count - /// (`ConnectionManager::total_subscription_count`); the default manager - /// does. - pub max_total_subscriptions: usize, -} - -impl Default for SubscriptionLimits { - fn default() -> Self { - Self { - max_topics_per_message: 64, - max_topics_per_connection: 256, - max_topic_length: 256, - max_total_subscriptions: 100_000, - } - } -} - -/// Service-wide count of held subscriptions, shared by every connection of a -/// service so the global cap is enforced by the server itself, independently -/// of which `ConnectionManager` or `MessageHandler` is plugged in. -#[derive(Debug, Default)] -pub struct SubscriptionAccounting { - total: std::sync::atomic::AtomicUsize, -} - -impl SubscriptionAccounting { - /// Current number of (connection, topic) pairs held across the service. - pub fn total(&self) -> usize { - self.total.load(std::sync::atomic::Ordering::Acquire) - } - - /// Atomically reserve one slot; `false` when `max` (non-zero) is reached. - pub(crate) fn reserve(&self, max: usize) -> bool { - use std::sync::atomic::Ordering; - let previous = self.total.fetch_add(1, Ordering::AcqRel); - if max > 0 && previous >= max { - self.total.fetch_sub(1, Ordering::AcqRel); - return false; - } - true - } - - pub(crate) fn release(&self, count: usize) { - self.total - .fetch_sub(count, std::sync::atomic::Ordering::AcqRel); - } -} - -/// Server-side keepalive for a connection (W4). -/// -/// The server sends a WebSocket ping every `ping_interval`; browsers and -/// tungstenite answer pings automatically, and any inbound frame (including -/// the pong) resets the idle clock. A connection that stays silent for -/// `idle_timeout` is closed, so half-open sockets are reclaimed. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct KeepaliveConfig { - /// Interval between server-initiated pings (`None` disables pings) - pub ping_interval: Option, - /// Close the socket after this long without any inbound frame - /// (`None` disables the idle timeout) - pub idle_timeout: Option, -} - -impl Default for KeepaliveConfig { - fn default() -> Self { - Self { - ping_interval: Some(Duration::from_secs(30)), - idle_timeout: Some(Duration::from_secs(90)), - } - } -} - -/// WebSocket connection handler that manages the message flow -pub struct WebSocketHandler { - /// The message handler for processing requests - handler: Arc, - /// Connection context - context: Arc, - /// Channel for receiving messages to send to client - message_rx: mpsc::Receiver, - max_message_size: usize, - /// Optional periodic credential re-validation - auth_revalidation: Option, - /// Connection manager kept in step with the cached user on re-validation - /// (None when running without a manager, e.g. unit tests). Subscription - /// mirroring goes through the context's `SubscriptionPolicy`. - connection_manager: Option>, - keepalive: KeepaliveConfig, -} - -impl WebSocketHandler { - /// Create a new WebSocket handler - pub fn new( - handler: Arc, - context: Arc, - message_rx: mpsc::Receiver, - max_message_size: usize, - ) -> Self { - Self { - handler, - context, - message_rx, - max_message_size, - auth_revalidation: None, - connection_manager: None, - keepalive: KeepaliveConfig::default(), - } - } - - /// Enable periodic credential re-validation for this connection. - pub fn with_auth_revalidation(mut self, revalidation: AuthRevalidation) -> Self { - self.auth_revalidation = Some(revalidation); - self - } - - /// Keep the manager's cached user in step on re-validation. - pub fn with_connection_manager(mut self, manager: Arc) -> Self { - self.connection_manager = Some(manager); - self - } - - /// Override the default keepalive settings. - pub fn with_keepalive(mut self, keepalive: KeepaliveConfig) -> Self { - self.keepalive = keepalive; - self - } - - /// Re-run authentication and re-authorize every held subscription. - /// - /// Returns `Ok(true)` to keep the connection, `Ok(false)` to close it. - async fn revalidate_credentials(&mut self) -> ServerResult { - let revalidation = self - .auth_revalidation - .as_ref() - .expect("revalidation timer implies config"); - let user = match revalidation - .auth_provider - .authenticate(revalidation.token.clone()) - .await - { - Ok(user) => user, - Err(e) => { - warn!( - "Closing connection {}: credential re-validation failed: {}", - self.context.id, e - ); - return Ok(false); - } - }; - - let previous = self.context.get_user().await; - let permissions_changed = previous - .as_ref() - .map(|prev| prev.permissions != user.permissions || prev.user_id != user.user_id) - .unwrap_or(true); - - // Refresh cached identity/permissions in both stores - self.context.set_user(user.clone()).await; - if let Some(manager) = &self.connection_manager { - let _ = manager.set_connection_user(self.context.id, user).await; - } - - if permissions_changed && revalidation.on_permission_change == PermissionChangePolicy::Close - { - warn!( - "Closing connection {}: permissions changed on re-validation", - self.context.id - ); - return Ok(false); - } - - // Re-authorize held subscriptions against the refreshed user (W1) - for topic in self.context.get_subscriptions().await { - if !self - .handler - .authorize_subscribe(&topic, &self.context) - .await? - { - warn!( - "Dropping subscription to '{}' on connection {}: no longer authorized", - topic, self.context.id - ); - self.context.unsubscribe(&topic).await; - } - } - Ok(true) - } - - /// Fast-path validation of a subscribe/unsubscribe request so the client - /// gets an error response. The authoritative checks live in - /// `ConnectionContext::subscribe`. - fn check_subscription_limits(&self, topics: &[String]) -> Result<(), &'static str> { - let limits = &self.context.subscription_policy().limits; - if topics.len() > limits.max_topics_per_message { - return Err("too many topics in one message"); - } - if topics - .iter() - .any(|topic| topic.len() > limits.max_topic_length || topic.is_empty()) - { - return Err("topic name length out of range"); - } - Ok(()) - } - - /// Run the WebSocket handler loop - pub async fn run(self, socket: WebSocket) -> ServerResult<()> { - let mut socket = AxumWebSocketIo::new(socket); - self.run_with_io(&mut socket).await - } - - /// Run the handler loop over an already-upgraded socket implementation. - pub async fn run_with_io( - mut self, - socket: &mut S, - ) -> ServerResult<()> { - info!( - "Starting WebSocket handler for connection: {}", - self.context.id - ); - - if let Err(e) = self.handler.on_connect(self.context.clone()).await { - error!("Error in on_connect handler: {}", e); - } - - let established_msg = BidirectionalMessage::ConnectionEstablished { - connection_id: self.context.id, - }; - if let Err(e) = socket - .send(WebSocketIoMessage::Text(serde_json::to_string( - &established_msg, - )?)) - .await - { - error!("Failed to send connection established message: {}", e); - } - - let mut revalidation_timer = self.auth_revalidation.as_ref().map(|revalidation| { - // tokio panics on a zero period; a zero interval is a config - // error, not a request to hammer the provider. Fall back to the - // default rather than disabling re-validation. - let interval = if revalidation.interval.is_zero() { - warn!( - "auth re-validation interval is zero; using default {:?}", - DEFAULT_AUTH_REVALIDATION_INTERVAL - ); - DEFAULT_AUTH_REVALIDATION_INTERVAL - } else { - revalidation.interval - }; - let mut timer = - tokio::time::interval_at(tokio::time::Instant::now() + interval, interval); - timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - timer - }); - - let mut ping_timer = self - .keepalive - .ping_interval - .filter(|interval| { - if interval.is_zero() { - warn!("keepalive ping interval is zero; pings disabled"); - } - !interval.is_zero() - }) - .map(|interval| { - let mut timer = - tokio::time::interval_at(tokio::time::Instant::now() + interval, interval); - timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - timer - }); - let idle_timeout = self.keepalive.idle_timeout.filter(|timeout| { - if timeout.is_zero() { - warn!("keepalive idle timeout is zero; idle timeout disabled"); - } - !timeout.is_zero() - }); - let idle_deadline = tokio::time::sleep(idle_timeout.unwrap_or(Duration::from_secs(0))); - tokio::pin!(idle_deadline); - - loop { - tokio::select! { - // Re-validate credentials so revoked/expired tokens are - // bounded to at most one interval on a long-lived connection - _ = async { revalidation_timer.as_mut().expect("guarded by is_some").tick().await }, - if revalidation_timer.is_some() => - { - match self.revalidate_credentials().await { - Ok(true) => {} - Ok(false) => { - let _ = socket - .send(WebSocketIoMessage::Close(Some( - "credentials no longer valid".to_string(), - ))) - .await; - break; - } - Err(e) => { - error!("Error re-authorizing subscriptions: {}", e); - break; - } - } - } - - // Server-initiated keepalive ping - _ = async { ping_timer.as_mut().expect("guarded by is_some").tick().await }, - if ping_timer.is_some() => - { - if let Err(e) = socket.send(WebSocketIoMessage::Ping(Vec::new())).await { - error!("Error sending keepalive ping: {}", e); - break; - } - } - - // Idle timeout: no inbound frame for the configured period - _ = &mut idle_deadline, if idle_timeout.is_some() => { - warn!( - "Closing connection {}: idle for {:?}", - self.context.id, - idle_timeout.expect("guarded") - ); - let _ = socket - .send(WebSocketIoMessage::Close(Some("idle timeout".to_string()))) - .await; - break; - } - - msg = socket.recv() => { - if let Some(timeout) = idle_timeout { - idle_deadline - .as_mut() - .reset(tokio::time::Instant::now() + timeout); - } - match msg { - Some(Ok(msg)) => { - if let Err(e) = self.handle_websocket_message(msg, socket).await { - error!("Error handling WebSocket message: {}", e); - break; - } - } - Some(Err(e)) => { - error!("WebSocket error: {}", e); - break; - } - None => { - debug!("WebSocket connection closed by client"); - break; - } - } - } - - msg = self.message_rx.recv() => { - match msg { - Some(OutboundMessage { message, topic }) => { - // Egress gate: a message routed on a topic while - // the subscription was still in the manager index - // is dropped here if the connection no longer - // holds it, closing the window between - // re-authorization and index removal. - if let Some(topic) = topic - && !self.context.is_subscribed_to(&topic).await - { - debug!( - "Dropping message on '{}' for connection {}: not subscribed", - topic, self.context.id - ); - continue; - } - if let Err(e) = self.send_message(socket, message).await { - error!("Error sending message: {}", e); - break; - } - } - None => { - debug!("Message channel closed"); - break; - } - } - } - } - } - - // Return this connection's subscription slots to the service pool - self.context.release_all_subscriptions().await; - - if let Err(e) = self.handler.on_disconnect(self.context.clone(), None).await { - error!("Error in on_disconnect handler: {}", e); - } - - let closed_msg = BidirectionalMessage::ConnectionClosed { - connection_id: self.context.id, - reason: None, - }; - let _ = socket - .send(WebSocketIoMessage::Text(serde_json::to_string( - &closed_msg, - )?)) - .await; - - info!( - "WebSocket handler finished for connection: {}", - self.context.id - ); - Ok(()) - } - - /// Handle incoming WebSocket messages - async fn handle_websocket_message( - &mut self, - msg: WebSocketIoMessage, - socket: &mut S, - ) -> ServerResult<()> { - match msg { - WebSocketIoMessage::Text(text) => { - if text.len() > self.max_message_size { - warn!("Received oversized text message: {} bytes", text.len()); - return Err(ServerError::InvalidRequest( - "Message exceeds maximum size".to_string(), - )); - } - debug!("Received text message ({} bytes)", text.len()); - self.handle_text_message(text, socket).await - } - WebSocketIoMessage::Binary(data) => { - if data.len() > self.max_message_size { - warn!("Received oversized binary message: {} bytes", data.len()); - return Err(ServerError::InvalidRequest( - "Message exceeds maximum size".to_string(), - )); - } - debug!("Received binary message ({} bytes)", data.len()); - match String::from_utf8(data) { - Ok(text) => self.handle_text_message(text, socket).await, - Err(_) => { - warn!("Received non-UTF-8 binary message, ignoring"); - Ok(()) - } - } - } - WebSocketIoMessage::Ping(data) => { - debug!("Received ping"); - socket.send(WebSocketIoMessage::Pong(data)).await?; - self.handler.on_ping(self.context.clone()).await - } - WebSocketIoMessage::Pong(_) => { - debug!("Received pong"); - self.handler.on_pong(self.context.clone()).await - } - WebSocketIoMessage::Close(reason) => { - debug!("Received close frame: {:?}", reason); - self.handler - .on_disconnect(self.context.clone(), reason.clone()) - .await?; - Err(ServerError::WebSocketError("Connection closed".to_string())) - } - } - } - - /// Handle text messages (JSON-RPC or bidirectional messages) - async fn handle_text_message( - &mut self, - text: String, - socket: &mut S, - ) -> ServerResult<()> { - if let Ok(msg) = serde_json::from_str::(&text) { - return self.handle_bidirectional_message(msg, socket).await; - } - - if let Ok(request) = serde_json::from_str::(&text) { - return self.handle_jsonrpc_request(request, socket).await; - } - - // Neither shape parsed. Per JSON-RPC 2.0, answer with a Parse Error - // (-32700, id null) and keep the connection open; only transport - // failures terminate the handler loop. - warn!( - "Could not parse message as JSON-RPC or bidirectional message on connection {}", - self.context.id - ); - let response = JsonRpcResponse::error(JsonRpcError::parse_error(), None); - self.send_message(socket, BidirectionalMessage::Response(response)) - .await - } - - /// Handle bidirectional messages - async fn handle_bidirectional_message( - &mut self, - msg: BidirectionalMessage, - _socket: &mut S, - ) -> ServerResult<()> { - match msg { - BidirectionalMessage::Request(request) => { - self.handle_jsonrpc_request(request, _socket).await - } - BidirectionalMessage::Subscribe { topics } => { - let before = self.context.get_subscriptions().await; - let new = topics.iter().filter(|t| !before.contains(t)).count(); - let policy = self.context.subscription_policy(); - let mut limit_error = self.check_subscription_limits(&topics).err().or_else(|| { - (before.len() + new > policy.limits.max_topics_per_connection) - .then_some("subscription limit for this connection reached") - }); - if limit_error.is_none() - && policy.limits.max_total_subscriptions > 0 - && policy.accounting.total() + new > policy.limits.max_total_subscriptions - { - limit_error = Some("global subscription limit reached"); - } - if let Some(reason) = limit_error { - warn!( - "Rejected subscribe on connection {}: {}", - self.context.id, reason - ); - let response = JsonRpcResponse::error( - JsonRpcError::invalid_params(reason.to_string()), - None, - ); - return self - .send_message(_socket, BidirectionalMessage::Response(response)) - .await; - } - self.handler - .handle_subscribe(topics, self.context.clone()) - .await - } - BidirectionalMessage::Unsubscribe { topics } => { - if let Err(reason) = self.check_subscription_limits(&topics) { - warn!( - "Rejected unsubscribe on connection {}: {}", - self.context.id, reason - ); - return Ok(()); - } - self.handler - .handle_unsubscribe(topics, self.context.clone()) - .await - } - BidirectionalMessage::Ping => self.handler.on_ping(self.context.clone()).await, - BidirectionalMessage::Pong => self.handler.on_pong(self.context.clone()).await, - // Other message types are typically server-to-client - _ => { - warn!("Received unexpected bidirectional message type from client"); - Ok(()) - } - } - } - - /// Handle JSON-RPC requests - async fn handle_jsonrpc_request( - &mut self, - request: JsonRpcRequest, - socket: &mut S, - ) -> ServerResult<()> { - debug!("Handling JSON-RPC request: {}", request.method); - let request_id = request.id.clone(); - - match self - .handler - .handle_request(request, self.context.clone()) - .await - { - Ok(Some(response)) => { - let response_msg = BidirectionalMessage::Response(response); - self.send_message(socket, response_msg).await - } - Ok(None) => { - // No response needed (notification) - Ok(()) - } - Err(e) => { - error!("Error handling request: {}", e); - let response = - JsonRpcResponse::error(jsonrpc_error_from_server_error(&e), request_id); - self.send_message(socket, BidirectionalMessage::Response(response)) - .await - } - } - } - - /// Send a message to the WebSocket client - async fn send_message( - &self, - socket: &mut S, - msg: BidirectionalMessage, - ) -> ServerResult<()> { - let json = serde_json::to_string(&msg)?; - socket.send(WebSocketIoMessage::Text(json)).await - } -} - -fn jsonrpc_error_from_server_error(error: &ServerError) -> JsonRpcError { - let code = match error { - ServerError::AuthenticationFailed(_) => error_codes::AUTHENTICATION_REQUIRED, - ServerError::PermissionDenied(_) => error_codes::INSUFFICIENT_PERMISSIONS, - ServerError::InvalidRequest(_) => error_codes::INVALID_REQUEST, - ServerError::HandlerNotFound(_) => error_codes::METHOD_NOT_FOUND, - ServerError::SerializationError(_) => error_codes::INVALID_PARAMS, - ServerError::UpgradeFailed(_) - | ServerError::ConnectionNotFound(_) - | ServerError::RoutingFailed(_) - | ServerError::WebSocketError(_) - | ServerError::ConnectionError(_) - | ServerError::Internal(_) => error_codes::INTERNAL_ERROR, - }; - - // Send only a generic per-class message; the full error was already logged - // server-side by the caller. Never interpolate handler/AuthError Display. - JsonRpcError::new(code, error.client_message().to_string(), None) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::connection::ChannelMessageSender; - use ras_jsonrpc_bidirectional_types::ConnectionId; - use std::collections::VecDeque; - use std::sync::Mutex; - - #[test] - fn jsonrpc_error_from_server_error_sends_generic_message_not_handler_detail() { - // Handler error carrying a secret -> client sees only a generic message, - // stable code preserved, no data field. - let err = ServerError::Internal("database password is hunter2".into()); - let jsonrpc = jsonrpc_error_from_server_error(&err); - assert_eq!(jsonrpc.code, error_codes::INTERNAL_ERROR); - assert_eq!(jsonrpc.message, "Internal error"); - assert!(!jsonrpc.message.contains("hunter2")); - assert!(jsonrpc.data.is_none()); - - // AuthError detail must not reach the client either. - let auth = ServerError::AuthenticationFailed(ras_auth_core::AuthError::Internal( - "dsn=postgres://user:pw@host/db".into(), - )); - let jsonrpc = jsonrpc_error_from_server_error(&auth); - assert_eq!(jsonrpc.code, error_codes::AUTHENTICATION_REQUIRED); - assert_eq!(jsonrpc.message, "Authentication failed"); - assert!(!jsonrpc.message.contains("dsn")); - - // Stable codes for the invalid-request / method-not-found classes. - assert_eq!( - jsonrpc_error_from_server_error(&ServerError::InvalidRequest( - "Invalid params: x".into() - )) - .code, - error_codes::INVALID_REQUEST - ); - assert_eq!( - jsonrpc_error_from_server_error(&ServerError::HandlerNotFound("m".into())).code, - error_codes::METHOD_NOT_FOUND - ); - } - - /// A minimal MessageHandler that only implements the required method — - /// every other method falls through to the default impl, which is what - /// these tests are verifying. - struct PassThrough; - - #[async_trait] - impl MessageHandler for PassThrough { - async fn handle_request( - &self, - _request: JsonRpcRequest, - _context: Arc, - ) -> ServerResult> { - Ok(None) - } - } - - struct RespondingHandler; - - #[async_trait] - impl MessageHandler for RespondingHandler { - async fn handle_request( - &self, - request: JsonRpcRequest, - _context: Arc, - ) -> ServerResult> { - Ok(Some(JsonRpcResponse::success( - serde_json::json!({ - "method": request.method, - "params": request.params, - }), - request.id, - ))) - } - } - - struct RecoveringHandler; - - #[async_trait] - impl MessageHandler for RecoveringHandler { - async fn handle_request( - &self, - request: JsonRpcRequest, - _context: Arc, - ) -> ServerResult> { - if request.method == "fail" { - return Err(ServerError::InvalidRequest("bad request".into())); - } - - Ok(Some(JsonRpcResponse::success( - serde_json::json!({ - "method": request.method, - }), - request.id, - ))) - } - } - - struct RecordingLifecycle { - disconnect_reasons: Mutex>>, - } - - impl RecordingLifecycle { - fn new() -> Self { - Self { - disconnect_reasons: Mutex::new(Vec::new()), - } - } - - fn disconnect_reasons(&self) -> Vec> { - self.disconnect_reasons - .lock() - .expect("disconnect reasons lock") - .clone() - } - } - - #[async_trait] - impl MessageHandler for RecordingLifecycle { - async fn handle_request( - &self, - _request: JsonRpcRequest, - _context: Arc, - ) -> ServerResult> { - Ok(None) - } - - async fn on_disconnect( - &self, - _context: Arc, - reason: Option, - ) -> ServerResult<()> { - self.disconnect_reasons - .lock() - .expect("disconnect reasons lock") - .push(reason); - Ok(()) - } - } - - struct InMemorySocket { - incoming: VecDeque, - outgoing: Vec, - close_when_empty: bool, - } - - impl InMemorySocket { - fn closing(incoming: impl IntoIterator) -> Self { - Self { - incoming: incoming.into_iter().collect(), - outgoing: Vec::new(), - close_when_empty: true, - } - } - - fn pending() -> Self { - Self { - incoming: VecDeque::new(), - outgoing: Vec::new(), - close_when_empty: false, - } - } - } - - #[async_trait] - impl WebSocketIo for InMemorySocket { - async fn send(&mut self, message: WebSocketIoMessage) -> ServerResult<()> { - self.outgoing.push(message); - Ok(()) - } - - async fn recv(&mut self) -> Option> { - if let Some(message) = self.incoming.pop_front() { - return Some(Ok(message)); - } - - if self.close_when_empty { - None - } else { - std::future::pending::>>().await - } - } - } - - fn ctx() -> Arc { - let id = ConnectionId::new(); - let (tx, _rx) = mpsc::channel(4); - let sender = ChannelMessageSender::new(id, tx); - Arc::new(ConnectionContext::new(id, sender)) - } - - fn ctx_with(policy: crate::connection::SubscriptionPolicy) -> Arc { - let id = ConnectionId::new(); - let (tx, _rx) = mpsc::channel(4); - let sender = ChannelMessageSender::new(id, tx); - Arc::new(ConnectionContext::new(id, sender).with_subscription_policy(policy)) - } - - fn limits_policy(limits: SubscriptionLimits) -> crate::connection::SubscriptionPolicy { - crate::connection::SubscriptionPolicy { - limits, - ..Default::default() - } - } - - #[tokio::test] - async fn default_handle_subscribe_denies_all_topics() { - let h = PassThrough; - let c = ctx(); - h.handle_subscribe(vec!["a".into(), "b".into()], c.clone()) - .await - .unwrap(); - assert!(!c.is_subscribed_to("a").await); - assert!(!c.is_subscribed_to("b").await); - } - - #[tokio::test] - async fn default_authorize_subscribe_denies() { - let h = PassThrough; - let c = ctx(); - assert!(!h.authorize_subscribe("any-topic", &c).await.unwrap()); - } - - struct AllowListHandler; - - #[async_trait] - impl MessageHandler for AllowListHandler { - async fn handle_request( - &self, - _request: JsonRpcRequest, - _context: Arc, - ) -> ServerResult> { - Ok(None) - } - - async fn authorize_subscribe( - &self, - topic: &str, - _context: &Arc, - ) -> ServerResult { - Ok(topic == "room:allowed") - } - } - - #[tokio::test] - async fn handle_subscribe_only_subscribes_authorized_topics() { - let h = AllowListHandler; - let c = ctx(); - h.handle_subscribe(vec!["room:allowed".into(), "room:denied".into()], c.clone()) - .await - .unwrap(); - assert!(c.is_subscribed_to("room:allowed").await); - assert!(!c.is_subscribed_to("room:denied").await); - } - - #[tokio::test] - async fn default_handle_unsubscribe_removes_from_context() { - let h = PassThrough; - let c = ctx(); - c.subscribe("a".into()).await.unwrap(); - c.subscribe("b".into()).await.unwrap(); - h.handle_unsubscribe(vec!["a".into()], c.clone()) - .await - .unwrap(); - assert!(!c.is_subscribed_to("a").await); - assert!(c.is_subscribed_to("b").await); - } - - #[tokio::test] - async fn default_lifecycle_methods_succeed() { - let h = PassThrough; - let c = ctx(); - h.on_connect(c.clone()).await.unwrap(); - h.on_ping(c.clone()).await.unwrap(); - h.on_pong(c.clone()).await.unwrap(); - h.on_disconnect(c.clone(), Some("bye".into())) - .await - .unwrap(); - // None reason path too. - h.on_disconnect(c, None).await.unwrap(); - } - - #[tokio::test] - async fn handler_loop_processes_jsonrpc_request_without_socket() { - let request = JsonRpcRequest::new( - "echo".into(), - Some(serde_json::json!({"value": 42})), - Some(serde_json::json!(7)), - ); - let incoming = serde_json::to_string(&BidirectionalMessage::Request(request)).unwrap(); - let mut socket = InMemorySocket::closing([WebSocketIoMessage::Text(incoming)]); - let (_tx, rx) = mpsc::channel(4); - - WebSocketHandler::new(Arc::new(RespondingHandler), ctx(), rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - let messages = bidirectional_outgoing(&socket); - assert!(matches!( - messages[0], - BidirectionalMessage::ConnectionEstablished { .. } - )); - - let response = match &messages[1] { - BidirectionalMessage::Response(response) => response, - other => panic!("expected response, got {other:?}"), - }; - assert_eq!(response.id, Some(serde_json::json!(7))); - assert_eq!(response.result.as_ref().unwrap()["method"], "echo"); - assert_eq!(response.result.as_ref().unwrap()["params"]["value"], 42); - - assert!(matches!( - messages[2], - BidirectionalMessage::ConnectionClosed { .. } - )); - } - - #[tokio::test] - async fn handler_loop_sends_jsonrpc_error_and_continues_without_socket() { - let fail = JsonRpcRequest::new( - "fail".into(), - Some(serde_json::json!({})), - Some(serde_json::json!(1)), - ); - let ok = JsonRpcRequest::new( - "ok".into(), - Some(serde_json::json!({})), - Some(serde_json::json!(2)), - ); - let mut socket = InMemorySocket::closing([ - WebSocketIoMessage::Text( - serde_json::to_string(&BidirectionalMessage::Request(fail)).unwrap(), - ), - WebSocketIoMessage::Text( - serde_json::to_string(&BidirectionalMessage::Request(ok)).unwrap(), - ), - ]); - let (_tx, rx) = mpsc::channel(4); - - WebSocketHandler::new(Arc::new(RecoveringHandler), ctx(), rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - let messages = bidirectional_outgoing(&socket); - assert!(matches!( - messages[0], - BidirectionalMessage::ConnectionEstablished { .. } - )); - - let error_response = match &messages[1] { - BidirectionalMessage::Response(response) => response, - other => panic!("expected error response, got {other:?}"), - }; - assert_eq!(error_response.id, Some(serde_json::json!(1))); - let error = error_response.error.as_ref().expect("JSON-RPC error"); - assert_eq!(error.code, ras_jsonrpc_types::error_codes::INVALID_REQUEST); - // Message is the generic per-class string; the handler's detail - // ("bad request") stays server-side. - assert_eq!(error.message, "Invalid request"); - - let success_response = match &messages[2] { - BidirectionalMessage::Response(response) => response, - other => panic!("expected success response, got {other:?}"), - }; - assert_eq!(success_response.id, Some(serde_json::json!(2))); - assert_eq!(success_response.result.as_ref().unwrap()["method"], "ok"); - - assert!(matches!( - messages[3], - BidirectionalMessage::ConnectionClosed { .. } - )); - } - - #[tokio::test] - async fn handler_loop_processes_control_messages_without_socket() { - let context = ctx(); - let subscribe = serde_json::to_string(&BidirectionalMessage::Subscribe { - topics: vec!["room:1".into()], - }) - .unwrap(); - let unsubscribe = serde_json::to_string(&BidirectionalMessage::Unsubscribe { - topics: vec!["room:1".into()], - }) - .unwrap(); - let mut socket = InMemorySocket::closing([ - WebSocketIoMessage::Text(subscribe), - WebSocketIoMessage::Text(unsubscribe), - WebSocketIoMessage::Ping(vec![1, 2, 3]), - ]); - let (_tx, rx) = mpsc::channel(4); - - WebSocketHandler::new(Arc::new(PassThrough), context.clone(), rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - assert!(!context.is_subscribed_to("room:1").await); - assert!( - socket - .outgoing - .contains(&WebSocketIoMessage::Pong(vec![1, 2, 3])) - ); - } - - #[tokio::test] - async fn handler_loop_sends_manager_messages_without_socket() { - let notification = BidirectionalMessage::ServerNotification( - ras_jsonrpc_bidirectional_types::ServerNotification { - method: "server.note".into(), - params: serde_json::json!({"ok": true}), - metadata: None, - }, - ); - let (tx, rx) = mpsc::channel(4); - tx.send(OutboundMessage::from(notification)).await.unwrap(); - drop(tx); - - let mut socket = InMemorySocket::pending(); - WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - let messages = bidirectional_outgoing(&socket); - assert!(matches!( - messages[0], - BidirectionalMessage::ConnectionEstablished { .. } - )); - - match &messages[1] { - BidirectionalMessage::ServerNotification(notification) => { - assert_eq!(notification.method, "server.note"); - assert_eq!(notification.params["ok"], true); - } - other => panic!("expected server notification, got {other:?}"), - } - - assert!(matches!( - messages[2], - BidirectionalMessage::ConnectionClosed { .. } - )); - } - - #[tokio::test] - async fn handler_loop_answers_malformed_text_with_parse_error_and_continues() { - let request = JsonRpcRequest::new( - "echo".into(), - Some(serde_json::json!({})), - Some(serde_json::json!(9)), - ); - let mut socket = InMemorySocket::closing([ - WebSocketIoMessage::Text("not json-rpc".to_string()), - WebSocketIoMessage::Text( - serde_json::to_string(&BidirectionalMessage::Request(request)).unwrap(), - ), - ]); - let (_tx, rx) = mpsc::channel(4); - - WebSocketHandler::new(Arc::new(RespondingHandler), ctx(), rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - let messages = bidirectional_outgoing(&socket); - assert!(matches!( - messages[0], - BidirectionalMessage::ConnectionEstablished { .. } - )); - - // The garbage frame is answered with -32700 (id null)... - let parse_error = match &messages[1] { - BidirectionalMessage::Response(response) => response, - other => panic!("expected parse error response, got {other:?}"), - }; - assert_eq!(parse_error.id, None); - let error = parse_error.error.as_ref().expect("parse error"); - assert_eq!(error.code, ras_jsonrpc_types::error_codes::PARSE_ERROR); - - // ...and the connection keeps serving subsequent requests. - let response = match &messages[2] { - BidirectionalMessage::Response(response) => response, - other => panic!("expected response, got {other:?}"), - }; - assert_eq!(response.id, Some(serde_json::json!(9))); - - assert!(matches!( - messages[3], - BidirectionalMessage::ConnectionClosed { .. } - )); - } - - #[tokio::test] - async fn handler_loop_closes_oversized_text_without_response() { - let mut socket = InMemorySocket::closing([WebSocketIoMessage::Text("too large".into())]); - let (_tx, rx) = mpsc::channel(4); - - WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 4) - .run_with_io(&mut socket) - .await - .unwrap(); - - let messages = bidirectional_outgoing(&socket); - assert_eq!(messages.len(), 2); - assert!(matches!( - messages[0], - BidirectionalMessage::ConnectionEstablished { .. } - )); - assert!(matches!( - messages[1], - BidirectionalMessage::ConnectionClosed { .. } - )); - } - - #[tokio::test] - async fn handler_loop_ignores_non_utf8_binary_without_response() { - let mut socket = InMemorySocket::closing([WebSocketIoMessage::Binary(vec![0xff, 0xfe])]); - let (_tx, rx) = mpsc::channel(4); - - WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - let messages = bidirectional_outgoing(&socket); - assert_eq!(messages.len(), 2); - assert!(matches!( - messages[0], - BidirectionalMessage::ConnectionEstablished { .. } - )); - assert!(matches!( - messages[1], - BidirectionalMessage::ConnectionClosed { .. } - )); - } - - #[tokio::test] - async fn handler_loop_records_close_reason_without_socket() { - let handler = Arc::new(RecordingLifecycle::new()); - let mut socket = - InMemorySocket::closing([WebSocketIoMessage::Close(Some("client bye".to_string()))]); - let (_tx, rx) = mpsc::channel(4); - - WebSocketHandler::new(handler.clone(), ctx(), rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - assert!( - handler - .disconnect_reasons() - .contains(&Some("client bye".to_string())) - ); - } - - fn auth_user(id: &str) -> ras_auth_core::AuthenticatedUser { - ras_auth_core::AuthenticatedUser { - user_id: id.to_string(), - permissions: std::collections::HashSet::new(), - metadata: None, - } - } - - /// Auth provider that replays a fixed sequence of results, then fails. - struct SequenceAuthProvider( - Mutex>>, - ); - - impl SequenceAuthProvider { - fn new( - results: impl IntoIterator< - Item = Result, - >, - ) -> Self { - Self(Mutex::new(results.into_iter().collect())) - } - } - - impl AuthProvider for SequenceAuthProvider { - fn authenticate(&self, _token: String) -> ras_auth_core::AuthFuture<'_> { - let result = self - .0 - .lock() - .expect("results lock") - .pop_front() - .unwrap_or(Err(ras_auth_core::AuthError::InvalidToken)); - Box::pin(async move { result }) - } - } - - #[tokio::test(start_paused = true)] - async fn revalidation_failure_closes_connection() { - let context = ctx(); - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::pending(); - - WebSocketHandler::new(Arc::new(PassThrough), context, rx, 1024) - .with_auth_revalidation(AuthRevalidation { - auth_provider: Arc::new(SequenceAuthProvider::new([])), - token: "revoked-token".into(), - interval: Duration::from_secs(30), - on_permission_change: PermissionChangePolicy::default(), - }) - .run_with_io(&mut socket) - .await - .unwrap(); - - assert!(socket.outgoing.iter().any(|message| matches!( - message, - WebSocketIoMessage::Close(Some(reason)) if reason == "credentials no longer valid" - ))); - } - - #[tokio::test(start_paused = true)] - async fn revalidation_success_refreshes_cached_user() { - let context = ctx(); - context.set_user(auth_user("stale")).await; - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::pending(); - - WebSocketHandler::new(Arc::new(PassThrough), context.clone(), rx, 1024) - .with_auth_revalidation(AuthRevalidation { - auth_provider: Arc::new(SequenceAuthProvider::new([Ok(auth_user("fresh"))])), - token: "valid-token".into(), - interval: Duration::from_secs(30), - on_permission_change: PermissionChangePolicy::default(), - }) - .run_with_io(&mut socket) - .await - .unwrap(); - - // First tick refreshed the cached user; the second (sequence - // exhausted) failed and closed the connection. - assert_eq!(context.get_user().await.expect("user").user_id, "fresh"); - assert!( - socket - .outgoing - .iter() - .any(|message| matches!(message, WebSocketIoMessage::Close(_))) - ); - } - - fn auth_user_with(id: &str, perms: &[&str]) -> ras_auth_core::AuthenticatedUser { - let mut user = auth_user(id); - user.permissions = perms.iter().map(|p| p.to_string()).collect(); - user - } - - /// Authorizes any topic for connections holding `room:read`. - struct PermissionGated; - - #[async_trait] - impl MessageHandler for PermissionGated { - async fn handle_request( - &self, - _request: JsonRpcRequest, - _context: Arc, - ) -> ServerResult> { - Ok(None) - } - - async fn authorize_subscribe( - &self, - _topic: &str, - context: &Arc, - ) -> ServerResult { - Ok(context.has_permission("room:read").await) - } - } - - fn subscribe_msg(topics: Vec) -> WebSocketIoMessage { - WebSocketIoMessage::Text( - serde_json::to_string(&BidirectionalMessage::Subscribe { topics }).unwrap(), - ) - } - - async fn manager_with(context: &ConnectionContext) -> Arc { - let manager: Arc = Arc::new(crate::DefaultConnectionManager::new()); - manager - .add_connection(ras_jsonrpc_bidirectional_types::ConnectionInfo::new( - context.id, - )) - .await - .unwrap(); - manager - } - - #[tokio::test(start_paused = true)] - async fn w1_revalidation_drops_subscriptions_no_longer_authorized() { - let context = ctx(); - context.set_user(auth_user_with("u", &["room:read"])).await; - let manager = manager_with(&context).await; - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::pending(); - socket - .incoming - .push_back(subscribe_msg(vec!["room:1".into()])); - - // Tick 1 returns the same user with the permission revoked; tick 2 fails. - let provider = SequenceAuthProvider::new([Ok(auth_user_with("u", &[]))]); - WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) - .with_connection_manager(manager.clone()) - .with_auth_revalidation(AuthRevalidation { - auth_provider: Arc::new(provider), - token: "t".into(), - interval: Duration::from_secs(30), - on_permission_change: PermissionChangePolicy::DropSubscriptions, - }) - .run_with_io(&mut socket) - .await - .unwrap(); - - assert!(!context.is_subscribed_to("room:1").await); - assert!( - manager - .get_subscriptions(context.id) - .await - .unwrap() - .is_empty() - ); - assert!( - manager - .get_subscribed_connections("room:1") - .await - .unwrap() - .is_empty() - ); - } - - #[tokio::test] - async fn w1_subscribe_mirrors_into_manager_index() { - let manager: Arc = Arc::new(crate::DefaultConnectionManager::new()); - let context = ctx_with(crate::connection::SubscriptionPolicy { - manager: Some(manager.clone()), - ..Default::default() - }); - manager - .add_connection(ras_jsonrpc_bidirectional_types::ConnectionInfo::new( - context.id, - )) - .await - .unwrap(); - - context.subscribe("room:1".into()).await.unwrap(); - assert!(context.is_subscribed_to("room:1").await); - assert_eq!( - manager.get_subscriptions(context.id).await.unwrap(), - vec!["room:1".to_string()] - ); - - assert!(context.unsubscribe("room:1").await); - assert!( - manager - .get_subscriptions(context.id) - .await - .unwrap() - .is_empty() - ); - assert_eq!(context.subscription_policy().accounting.total(), 0); - } - - #[tokio::test(start_paused = true)] - async fn w1_close_policy_closes_socket_when_permissions_change() { - let context = ctx(); - context.set_user(auth_user_with("u", &["room:read"])).await; - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::pending(); - - WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) - .with_auth_revalidation(AuthRevalidation { - auth_provider: Arc::new(SequenceAuthProvider::new([Ok(auth_user_with("u", &[]))])), - token: "t".into(), - interval: Duration::from_secs(30), - on_permission_change: PermissionChangePolicy::Close, - }) - .run_with_io(&mut socket) - .await - .unwrap(); - - // Closed on the first tick (permission change), not the second (failure). - assert_eq!( - socket - .outgoing - .iter() - .filter(|m| matches!(m, WebSocketIoMessage::Close(_))) - .count(), - 1 - ); - assert!(context.get_user().await.unwrap().permissions.is_empty()); - } - - #[tokio::test] - async fn w3_subscribe_over_per_message_limit_is_rejected() { - let context = ctx_with(limits_policy(SubscriptionLimits { - max_topics_per_message: 2, - ..SubscriptionLimits::default() - })); - context.set_user(auth_user_with("u", &["room:read"])).await; - let (_tx, rx) = mpsc::channel(4); - let topics: Vec = (0..3).map(|i| format!("room:{i}")).collect(); - let mut socket = InMemorySocket::closing([subscribe_msg(topics)]); - - WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - assert!(context.get_subscriptions().await.is_empty()); - assert!(bidirectional_outgoing(&socket).iter().any(|m| matches!( - m, - BidirectionalMessage::Response(r) if r.error.is_some() - ))); - } - - #[tokio::test] - async fn w3_subscribe_over_per_connection_limit_is_rejected() { - let context = ctx_with(limits_policy(SubscriptionLimits { - max_topics_per_connection: 1, - ..SubscriptionLimits::default() - })); - context.set_user(auth_user_with("u", &["room:read"])).await; - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::closing([ - subscribe_msg(vec!["room:1".into()]), - subscribe_msg(vec!["room:2".into()]), - ]); - - WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - // First subscribe accepted silently; second answered with an error. - let errors = bidirectional_outgoing(&socket) - .iter() - .filter(|m| matches!(m, BidirectionalMessage::Response(r) if r.error.is_some())) - .count(); - assert_eq!(errors, 1); - // Teardown released the held slot. - assert_eq!(context.subscription_policy().accounting.total(), 0); - assert!(context.get_subscriptions().await.is_empty()); - - // Direct path: the context itself refuses the second topic. - let direct = ctx_with(limits_policy(SubscriptionLimits { - max_topics_per_connection: 1, - ..SubscriptionLimits::default() - })); - direct.subscribe("room:1".into()).await.unwrap(); - assert!(matches!( - direct.subscribe("room:2".into()).await, - Err(ras_jsonrpc_bidirectional_types::BidirectionalError::SubscriptionLimitReached(_)) - )); - } - - #[tokio::test] - async fn w3_overlong_topic_is_rejected() { - let context = ctx(); - context.set_user(auth_user_with("u", &["room:read"])).await; - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::closing([subscribe_msg(vec!["x".repeat(300)])]); - - WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - assert!(context.get_subscriptions().await.is_empty()); - } - - #[tokio::test(start_paused = true)] - async fn w4_idle_connection_is_pinged_then_closed() { - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::pending(); - - WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) - .with_keepalive(KeepaliveConfig { - ping_interval: Some(Duration::from_secs(5)), - idle_timeout: Some(Duration::from_secs(12)), - }) - .run_with_io(&mut socket) - .await - .unwrap(); - - let pings = socket - .outgoing - .iter() - .filter(|m| matches!(m, WebSocketIoMessage::Ping(_))) - .count(); - assert_eq!(pings, 2, "pings at 5s and 10s before the 12s idle close"); - assert!(socket.outgoing.iter().any(|m| matches!( - m, - WebSocketIoMessage::Close(Some(reason)) if reason == "idle timeout" - ))); - } - - /// Like `PermissionGated`, but when it denies a topic during - /// re-authorization it first pushes a broadcast for that topic into the - /// connection's queue, simulating a `broadcast_to_topic` that snapshotted - /// the manager index in the window before the subscription was removed. - struct RacingAuthorizer; - - #[async_trait] - impl MessageHandler for RacingAuthorizer { - async fn handle_request( - &self, - _request: JsonRpcRequest, - _context: Arc, - ) -> ServerResult> { - Ok(None) - } - - async fn authorize_subscribe( - &self, - topic: &str, - context: &Arc, - ) -> ServerResult { - if context.has_permission("room:read").await { - return Ok(true); - } - let stale = BidirectionalMessage::Broadcast( - ras_jsonrpc_bidirectional_types::BroadcastMessage { - topic: topic.to_string(), - method: "secret".into(), - params: serde_json::json!({}), - metadata: None, - }, - ); - context.sender.send_on_topic(topic, stale).await.unwrap(); - Ok(false) - } - } - - #[tokio::test(start_paused = true)] - async fn w1_broadcast_queued_during_revocation_window_is_not_delivered() { - let context = ctx(); - context.set_user(auth_user_with("u", &["room:read"])).await; - let manager = manager_with(&context).await; - let (tx, rx) = mpsc::channel(4); - // The handler's context must share this channel so the authorizer - // can enqueue through `context.sender`. - let context = Arc::new(ConnectionContext::new( - context.id, - ChannelMessageSender::new(context.id, tx), - )); - context.set_user(auth_user_with("u", &["room:read"])).await; - let mut socket = InMemorySocket::pending(); - socket - .incoming - .push_back(subscribe_msg(vec!["room:1".into()])); - - WebSocketHandler::new(Arc::new(RacingAuthorizer), context.clone(), rx, 1024) - .with_connection_manager(manager) - .with_auth_revalidation(AuthRevalidation { - auth_provider: Arc::new(SequenceAuthProvider::new([Ok(auth_user_with("u", &[]))])), - token: "t".into(), - interval: Duration::from_secs(30), - on_permission_change: PermissionChangePolicy::DropSubscriptions, - }) - .run_with_io(&mut socket) - .await - .unwrap(); - - assert!(!context.is_subscribed_to("room:1").await); - let leaked = bidirectional_outgoing(&socket) - .iter() - .any(|m| matches!(m, BidirectionalMessage::Broadcast(b) if b.method == "secret")); - assert!( - !leaked, - "broadcast queued during the revocation window must be dropped" - ); - } - - #[tokio::test] - async fn w1_egress_gate_only_filters_topic_routed_messages() { - let context = ctx(); - let (tx, rx) = mpsc::channel(4); - let ping = BidirectionalMessage::Ping; - tx.send(OutboundMessage::from(ping.clone())).await.unwrap(); - tx.send(OutboundMessage { - message: ping, - topic: Some("room:never".into()), - }) - .await - .unwrap(); - drop(tx); - - let mut socket = InMemorySocket::pending(); - WebSocketHandler::new(Arc::new(PassThrough), context, rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - let pings = bidirectional_outgoing(&socket) - .iter() - .filter(|m| matches!(m, BidirectionalMessage::Ping)) - .count(); - assert_eq!( - pings, 1, - "untagged delivered, topic-tagged unsubscribed dropped" - ); - } - - #[tokio::test] - async fn w3_global_subscription_cap_is_enforced_across_connections() { - // Service-level accounting shared by both contexts. The first - // connection stays open (pending socket) so its slots remain held - // while the second connection tries to subscribe. - let limits = SubscriptionLimits { - max_total_subscriptions: 2, - ..SubscriptionLimits::default() - }; - let accounting = Arc::new(SubscriptionAccounting::default()); - let policy = crate::connection::SubscriptionPolicy { - limits, - accounting: accounting.clone(), - manager: None, - }; - - let first = ctx_with(policy.clone()); - first.set_user(auth_user_with("u", &["room:read"])).await; - let (_tx1, rx1) = mpsc::channel(4); - let mut socket1 = InMemorySocket::pending(); - socket1 - .incoming - .push_back(subscribe_msg(vec!["a".into(), "b".into()])); - let first_run = { - let first = first.clone(); - tokio::spawn(async move { - WebSocketHandler::new(Arc::new(PermissionGated), first, rx1, 1024) - .with_keepalive(KeepaliveConfig { - ping_interval: None, - idle_timeout: None, - }) - .run_with_io(&mut socket1) - .await - }) - }; - tokio::time::timeout(Duration::from_secs(10), async { - while accounting.total() != 2 { - tokio::task::yield_now().await; - } - }) - .await - .expect("first connection should reserve its 2 slots"); - assert_eq!(first.get_subscriptions().await.len(), 2); - - let second = ctx_with(policy); - second.set_user(auth_user_with("u", &["room:read"])).await; - let (_tx2, rx2) = mpsc::channel(4); - let mut socket2 = InMemorySocket::closing([subscribe_msg(vec!["c".into()])]); - WebSocketHandler::new(Arc::new(PermissionGated), second.clone(), rx2, 1024) - .run_with_io(&mut socket2) - .await - .unwrap(); - - assert!(second.get_subscriptions().await.is_empty()); - assert_eq!(accounting.total(), 2, "second connection reserved nothing"); - first_run.abort(); - } - - #[tokio::test(start_paused = true)] - async fn w4_zero_durations_do_not_panic() { - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::pending(); - - // Zero ping and idle: both disabled; zero revalidation: default used. - // The sequence provider fails on its first tick, which closes the loop. - WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) - .with_keepalive(KeepaliveConfig { - ping_interval: Some(Duration::ZERO), - idle_timeout: Some(Duration::ZERO), - }) - .with_auth_revalidation(AuthRevalidation { - auth_provider: Arc::new(SequenceAuthProvider::new([])), - token: "t".into(), - interval: Duration::ZERO, - on_permission_change: PermissionChangePolicy::default(), - }) - .run_with_io(&mut socket) - .await - .unwrap(); - - assert!( - !socket - .outgoing - .iter() - .any(|m| matches!(m, WebSocketIoMessage::Ping(_))) - ); - assert!(socket.outgoing.iter().any(|m| matches!( - m, - WebSocketIoMessage::Close(Some(reason)) if reason == "credentials no longer valid" - ))); - } - - static GREEDY_ACCEPTED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); - - /// A custom handler that subscribes to far more topics than any limit - /// allows, from `on_connect` as well as `handle_subscribe`, straight on - /// the context. - struct GreedyHandler; - - impl GreedyHandler { - async fn grab(context: &ConnectionContext, prefix: &str) { - for i in 0..10 { - if context.subscribe(format!("{prefix}:{i}")).await.is_ok() { - GREEDY_ACCEPTED.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - } - } - } - } - - #[async_trait] - impl MessageHandler for GreedyHandler { - async fn handle_request( - &self, - _request: JsonRpcRequest, - _context: Arc, - ) -> ServerResult> { - Ok(None) - } - - async fn on_connect(&self, context: Arc) -> ServerResult<()> { - Self::grab(&context, "connect").await; - Ok(()) - } - - async fn handle_subscribe( - &self, - _topics: Vec, - context: Arc, - ) -> ServerResult<()> { - Self::grab(&context, "greedy").await; - Ok(()) - } - } - - #[tokio::test] - async fn w3_custom_handler_cannot_exceed_limits_from_any_callback() { - let limits = SubscriptionLimits { - max_topics_per_connection: 3, - ..SubscriptionLimits::default() - }; - let manager: Arc = Arc::new( - crate::DefaultConnectionManager::with_subscription_limits(limits), - ); - let accounting = Arc::new(SubscriptionAccounting::default()); - let context = ctx_with(crate::connection::SubscriptionPolicy { - limits, - accounting: accounting.clone(), - manager: Some(manager.clone()), - }); - manager - .add_connection(ras_jsonrpc_bidirectional_types::ConnectionInfo::new( - context.id, - )) - .await - .unwrap(); - let (_tx, rx) = mpsc::channel(4); - let mut socket = InMemorySocket::closing([subscribe_msg(vec!["x".into()])]); - - WebSocketHandler::new(Arc::new(GreedyHandler), context.clone(), rx, 1024) - .with_connection_manager(manager.clone()) - .run_with_io(&mut socket) - .await - .unwrap(); - - // Greedy on_connect (10), handle_request-free, then greedy - // handle_subscribe (10 more): the context admitted three in total, - // the manager saw exactly those, and disconnect released exactly - // those, so the counter is back to zero, not underflowed. - assert_eq!( - context.get_subscriptions().await.len(), - 0, - "released on disconnect" - ); - assert_eq!( - manager.get_subscriptions(context.id).await.unwrap().len(), - 0 - ); - assert_eq!(accounting.total(), 0, "no underflow"); - assert_eq!(manager.total_subscription_count().await.unwrap(), 0); - assert_eq!( - GREEDY_ACCEPTED.load(std::sync::atomic::Ordering::SeqCst), - 3, - "exactly the cap accepted across on_connect and handle_subscribe" - ); - } - - #[tokio::test] - async fn handler_without_revalidation_does_not_authenticate() { - // No auth provider involved at all: the loop must terminate on - // socket close without ticking a revalidation timer. - let mut socket = InMemorySocket::closing([]); - let (_tx, rx) = mpsc::channel(4); - - WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) - .run_with_io(&mut socket) - .await - .unwrap(); - - let messages = bidirectional_outgoing(&socket); - assert_eq!(messages.len(), 2); - } - - fn bidirectional_outgoing(socket: &InMemorySocket) -> Vec { - socket - .outgoing - .iter() - .filter_map(|message| match message { - WebSocketIoMessage::Text(text) => serde_json::from_str(text).ok(), - _ => None, - }) - .collect() - } -} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/mod.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/mod.rs new file mode 100644 index 0000000..8cec338 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/mod.rs @@ -0,0 +1,786 @@ +//! Message handlers for WebSocket communication + +use crate::{ConnectionContext, ServerError, ServerResult, connection::OutboundMessage}; +use async_trait::async_trait; +use axum::extract::ws::{CloseFrame, Message, WebSocket}; +use futures::stream::StreamExt; +use ras_auth_core::AuthProvider; +use ras_jsonrpc_bidirectional_types::{BidirectionalMessage, ConnectionManager}; +use ras_jsonrpc_types::{JsonRpcError, JsonRpcRequest, JsonRpcResponse, error_codes}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc; +use tracing::{debug, error, info, warn}; + +/// Trait for handling JSON-RPC requests within a WebSocket context +#[async_trait] +pub trait MessageHandler: Send + Sync + 'static { + /// Handle an incoming JSON-RPC request + /// + /// # Arguments + /// * `request` - The JSON-RPC request to handle + /// * `context` - The connection context containing auth info and metadata + /// + /// # Returns + /// * `Ok(Some(response))` - Response to send back to client + /// * `Ok(None)` - No response needed (for notifications) + /// * `Err(error)` - Error occurred during handling + async fn handle_request( + &self, + request: JsonRpcRequest, + context: Arc, + ) -> ServerResult>; + + /// Decide whether this connection may subscribe to `topic`. + /// + /// Default-deny: services that broadcast over topics must override this + /// (or `handle_subscribe`) to allow the topics a connection is entitled + /// to. Errors propagate to the handler loop and close the connection. + async fn authorize_subscribe( + &self, + _topic: &str, + _context: &Arc, + ) -> ServerResult { + Ok(false) + } + + /// Handle subscription requests + async fn handle_subscribe( + &self, + topics: Vec, + context: Arc, + ) -> ServerResult<()> { + // Default implementation subscribes the connection to each topic the + // service authorizes via `authorize_subscribe`; denied topics are + // skipped without closing the connection. + for topic in topics { + if self.authorize_subscribe(&topic, &context).await? { + if let Err(e) = context.subscribe(topic.clone()).await { + warn!( + "Refused subscription to topic '{}' for connection {}: {}", + topic, context.id, e + ); + } + } else { + warn!( + "Denied subscription to topic '{}' for connection {}", + topic, context.id + ); + } + } + Ok(()) + } + + /// Handle unsubscription requests + async fn handle_unsubscribe( + &self, + topics: Vec, + context: Arc, + ) -> ServerResult<()> { + for topic in topics { + context.unsubscribe(&topic).await; + } + Ok(()) + } + + /// Handle connection established event + async fn on_connect(&self, context: Arc) -> ServerResult<()> { + info!("Connection established: {}", context.id); + Ok(()) + } + + /// Handle connection closed event + async fn on_disconnect( + &self, + context: Arc, + reason: Option, + ) -> ServerResult<()> { + info!("Connection closed: {} (reason: {:?})", context.id, reason); + Ok(()) + } + + /// Handle ping message + async fn on_ping(&self, _context: Arc) -> ServerResult<()> { + debug!("Received ping"); + Ok(()) + } + + /// Handle pong message + async fn on_pong(&self, _context: Arc) -> ServerResult<()> { + debug!("Received pong"); + Ok(()) + } +} + +/// WebSocket message shape used by the server handler loop. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WebSocketIoMessage { + Text(String), + Binary(Vec), + Ping(Vec), + Pong(Vec), + Close(Option), +} + +impl From for WebSocketIoMessage { + fn from(message: Message) -> Self { + match message { + Message::Text(text) => Self::Text(text.to_string()), + Message::Binary(data) => Self::Binary(data.to_vec()), + Message::Ping(data) => Self::Ping(data.to_vec()), + Message::Pong(data) => Self::Pong(data.to_vec()), + Message::Close(frame) => Self::Close(frame.map(|frame| frame.reason.to_string())), + } + } +} + +/// Minimal socket interface used by the message loop. +#[async_trait] +pub trait WebSocketIo: Send { + async fn send(&mut self, message: WebSocketIoMessage) -> ServerResult<()>; + async fn recv(&mut self) -> Option>; +} + +pub(crate) struct AxumWebSocketIo { + socket: WebSocket, +} + +impl AxumWebSocketIo { + pub(crate) fn new(socket: WebSocket) -> Self { + Self { socket } + } +} + +#[async_trait] +impl WebSocketIo for AxumWebSocketIo { + async fn send(&mut self, message: WebSocketIoMessage) -> ServerResult<()> { + let message = match message { + WebSocketIoMessage::Text(text) => Message::Text(text.into()), + WebSocketIoMessage::Binary(data) => Message::Binary(data.into()), + WebSocketIoMessage::Ping(data) => Message::Ping(data.into()), + WebSocketIoMessage::Pong(data) => Message::Pong(data.into()), + WebSocketIoMessage::Close(reason) => Message::Close(reason.map(|reason| CloseFrame { + code: axum::extract::ws::close_code::NORMAL, + reason: reason.into(), + })), + }; + + self.socket + .send(message) + .await + .map_err(|e| ServerError::WebSocketError(e.to_string())) + } + + async fn recv(&mut self) -> Option> { + self.socket.next().await.map(|message| { + message + .map(WebSocketIoMessage::from) + .map_err(|e| ServerError::WebSocketError(e.to_string())) + }) + } +} + +/// Default interval between credential re-validations on long-lived connections. +pub const DEFAULT_AUTH_REVALIDATION_INTERVAL: Duration = Duration::from_secs(30); + +/// Periodic credential re-validation for a long-lived connection. +/// +/// The token is captured before the WebSocket upgrade and re-run through the +/// auth provider on every `interval` tick. Failure closes the connection; +/// success refreshes the cached user (so permission changes propagate). This +/// bounds the lifetime of revoked/expired credentials on an open socket to at +/// most one interval. +pub struct AuthRevalidation { + /// Provider used to re-run authentication + pub auth_provider: Arc, + /// Token captured at upgrade time + pub token: String, + /// How often to re-validate + pub interval: Duration, + /// What to do when re-validation succeeds but the permission set changed + pub on_permission_change: PermissionChangePolicy, +} + +/// Policy applied when a live connection's permissions change on +/// re-validation (W1). +/// +/// In both modes every held subscription is re-run through +/// [`MessageHandler::authorize_subscribe`] against the refreshed user and +/// topics that are no longer authorized are dropped, so a downgraded +/// connection stops receiving topic broadcasts within one interval. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PermissionChangePolicy { + /// Keep the socket open and silently drop subscriptions that are no + /// longer authorized. + #[default] + DropSubscriptions, + /// Close the socket so the client must reconnect and re-authenticate. + Close, +} + +pub use crate::subscriptions::{SubscriptionAccounting, SubscriptionLimits}; + +/// Server-side keepalive for a connection (W4). +/// +/// The server sends a WebSocket ping every `ping_interval`; browsers and +/// tungstenite answer pings automatically, and any inbound frame (including +/// the pong) resets the idle clock. A connection that stays silent for +/// `idle_timeout` is closed, so half-open sockets are reclaimed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeepaliveConfig { + /// Interval between server-initiated pings (`None` disables pings) + pub ping_interval: Option, + /// Close the socket after this long without any inbound frame + /// (`None` disables the idle timeout) + pub idle_timeout: Option, +} + +impl Default for KeepaliveConfig { + fn default() -> Self { + Self { + ping_interval: Some(Duration::from_secs(30)), + idle_timeout: Some(Duration::from_secs(90)), + } + } +} + +/// WebSocket connection handler that manages the message flow +pub struct WebSocketHandler { + /// The message handler for processing requests + handler: Arc, + /// Connection context + context: Arc, + /// Channel for receiving messages to send to client + message_rx: mpsc::Receiver, + max_message_size: usize, + /// Optional periodic credential re-validation + auth_revalidation: Option, + /// Connection manager kept in step with the cached user on re-validation + /// (None when running without a manager, e.g. unit tests). Subscription + /// mirroring goes through the context's `SubscriptionPolicy`. + connection_manager: Option>, + keepalive: KeepaliveConfig, +} + +impl WebSocketHandler { + /// Create a new WebSocket handler + pub fn new( + handler: Arc, + context: Arc, + message_rx: mpsc::Receiver, + max_message_size: usize, + ) -> Self { + Self { + handler, + context, + message_rx, + max_message_size, + auth_revalidation: None, + connection_manager: None, + keepalive: KeepaliveConfig::default(), + } + } + + /// Enable periodic credential re-validation for this connection. + pub fn with_auth_revalidation(mut self, revalidation: AuthRevalidation) -> Self { + self.auth_revalidation = Some(revalidation); + self + } + + /// Keep the manager's cached user in step on re-validation. + pub fn with_connection_manager(mut self, manager: Arc) -> Self { + self.connection_manager = Some(manager); + self + } + + /// Override the default keepalive settings. + pub fn with_keepalive(mut self, keepalive: KeepaliveConfig) -> Self { + self.keepalive = keepalive; + self + } + + /// Re-run authentication and re-authorize every held subscription. + /// + /// Returns `Ok(true)` to keep the connection, `Ok(false)` to close it. + async fn revalidate_credentials(&mut self) -> ServerResult { + let revalidation = self + .auth_revalidation + .as_ref() + .expect("revalidation timer implies config"); + let user = match revalidation + .auth_provider + .authenticate(revalidation.token.clone()) + .await + { + Ok(user) => user, + Err(e) => { + warn!( + "Closing connection {}: credential re-validation failed: {}", + self.context.id, e + ); + return Ok(false); + } + }; + + let previous = self.context.get_user().await; + let permissions_changed = previous + .as_ref() + .map(|prev| prev.permissions != user.permissions || prev.user_id != user.user_id) + .unwrap_or(true); + + // Refresh cached identity/permissions in both stores + self.context.set_user(user.clone()).await; + if let Some(manager) = &self.connection_manager { + let _ = manager.set_connection_user(self.context.id, user).await; + } + + if permissions_changed && revalidation.on_permission_change == PermissionChangePolicy::Close + { + warn!( + "Closing connection {}: permissions changed on re-validation", + self.context.id + ); + return Ok(false); + } + + // Re-authorize held subscriptions against the refreshed user (W1) + for topic in self.context.get_subscriptions().await { + if !self + .handler + .authorize_subscribe(&topic, &self.context) + .await? + { + warn!( + "Dropping subscription to '{}' on connection {}: no longer authorized", + topic, self.context.id + ); + self.context.unsubscribe(&topic).await; + } + } + Ok(true) + } + + /// Fast-path validation of a subscribe/unsubscribe request so the client + /// gets an error response. The authoritative checks live in + /// `ConnectionContext::subscribe`. + fn check_subscription_limits(&self, topics: &[String]) -> Result<(), &'static str> { + let limits = &self.context.subscription_policy().limits; + if topics.len() > limits.max_topics_per_message { + return Err("too many topics in one message"); + } + if topics + .iter() + .any(|topic| topic.len() > limits.max_topic_length || topic.is_empty()) + { + return Err("topic name length out of range"); + } + Ok(()) + } + + /// Run the WebSocket handler loop + pub async fn run(self, socket: WebSocket) -> ServerResult<()> { + let mut socket = AxumWebSocketIo::new(socket); + self.run_with_io(&mut socket).await + } + + /// Run the handler loop over an already-upgraded socket implementation. + pub async fn run_with_io( + mut self, + socket: &mut S, + ) -> ServerResult<()> { + info!( + "Starting WebSocket handler for connection: {}", + self.context.id + ); + + if let Err(e) = self.handler.on_connect(self.context.clone()).await { + error!("Error in on_connect handler: {}", e); + } + + let established_msg = BidirectionalMessage::ConnectionEstablished { + connection_id: self.context.id, + }; + if let Err(e) = socket + .send(WebSocketIoMessage::Text(serde_json::to_string( + &established_msg, + )?)) + .await + { + error!("Failed to send connection established message: {}", e); + } + + let mut revalidation_timer = self.auth_revalidation.as_ref().map(|revalidation| { + // tokio panics on a zero period; a zero interval is a config + // error, not a request to hammer the provider. Fall back to the + // default rather than disabling re-validation. + let interval = if revalidation.interval.is_zero() { + warn!( + "auth re-validation interval is zero; using default {:?}", + DEFAULT_AUTH_REVALIDATION_INTERVAL + ); + DEFAULT_AUTH_REVALIDATION_INTERVAL + } else { + revalidation.interval + }; + let mut timer = + tokio::time::interval_at(tokio::time::Instant::now() + interval, interval); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + timer + }); + + let mut ping_timer = self + .keepalive + .ping_interval + .filter(|interval| { + if interval.is_zero() { + warn!("keepalive ping interval is zero; pings disabled"); + } + !interval.is_zero() + }) + .map(|interval| { + let mut timer = + tokio::time::interval_at(tokio::time::Instant::now() + interval, interval); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + timer + }); + let idle_timeout = self.keepalive.idle_timeout.filter(|timeout| { + if timeout.is_zero() { + warn!("keepalive idle timeout is zero; idle timeout disabled"); + } + !timeout.is_zero() + }); + let idle_deadline = tokio::time::sleep(idle_timeout.unwrap_or(Duration::from_secs(0))); + tokio::pin!(idle_deadline); + + loop { + tokio::select! { + // Re-validate credentials so revoked/expired tokens are + // bounded to at most one interval on a long-lived connection + _ = async { revalidation_timer.as_mut().expect("guarded by is_some").tick().await }, + if revalidation_timer.is_some() => + { + match self.revalidate_credentials().await { + Ok(true) => {} + Ok(false) => { + let _ = socket + .send(WebSocketIoMessage::Close(Some( + "credentials no longer valid".to_string(), + ))) + .await; + break; + } + Err(e) => { + error!("Error re-authorizing subscriptions: {}", e); + break; + } + } + } + + // Server-initiated keepalive ping + _ = async { ping_timer.as_mut().expect("guarded by is_some").tick().await }, + if ping_timer.is_some() => + { + if let Err(e) = socket.send(WebSocketIoMessage::Ping(Vec::new())).await { + error!("Error sending keepalive ping: {}", e); + break; + } + } + + // Idle timeout: no inbound frame for the configured period + _ = &mut idle_deadline, if idle_timeout.is_some() => { + warn!( + "Closing connection {}: idle for {:?}", + self.context.id, + idle_timeout.expect("guarded") + ); + let _ = socket + .send(WebSocketIoMessage::Close(Some("idle timeout".to_string()))) + .await; + break; + } + + msg = socket.recv() => { + if let Some(timeout) = idle_timeout { + idle_deadline + .as_mut() + .reset(tokio::time::Instant::now() + timeout); + } + match msg { + Some(Ok(msg)) => { + if let Err(e) = self.handle_websocket_message(msg, socket).await { + error!("Error handling WebSocket message: {}", e); + break; + } + } + Some(Err(e)) => { + error!("WebSocket error: {}", e); + break; + } + None => { + debug!("WebSocket connection closed by client"); + break; + } + } + } + + msg = self.message_rx.recv() => { + match msg { + Some(OutboundMessage { message, topic }) => { + // Egress gate: a message routed on a topic while + // the subscription was still in the manager index + // is dropped here if the connection no longer + // holds it, closing the window between + // re-authorization and index removal. + if let Some(topic) = topic + && !self.context.is_subscribed_to(&topic).await + { + debug!( + "Dropping message on '{}' for connection {}: not subscribed", + topic, self.context.id + ); + continue; + } + if let Err(e) = self.send_message(socket, message).await { + error!("Error sending message: {}", e); + break; + } + } + None => { + debug!("Message channel closed"); + break; + } + } + } + } + } + + // Return this connection's subscription slots to the service pool + self.context.release_all_subscriptions().await; + + if let Err(e) = self.handler.on_disconnect(self.context.clone(), None).await { + error!("Error in on_disconnect handler: {}", e); + } + + let closed_msg = BidirectionalMessage::ConnectionClosed { + connection_id: self.context.id, + reason: None, + }; + let _ = socket + .send(WebSocketIoMessage::Text(serde_json::to_string( + &closed_msg, + )?)) + .await; + + info!( + "WebSocket handler finished for connection: {}", + self.context.id + ); + Ok(()) + } + + /// Handle incoming WebSocket messages + async fn handle_websocket_message( + &mut self, + msg: WebSocketIoMessage, + socket: &mut S, + ) -> ServerResult<()> { + match msg { + WebSocketIoMessage::Text(text) => { + if text.len() > self.max_message_size { + warn!("Received oversized text message: {} bytes", text.len()); + return Err(ServerError::InvalidRequest( + "Message exceeds maximum size".to_string(), + )); + } + debug!("Received text message ({} bytes)", text.len()); + self.handle_text_message(text, socket).await + } + WebSocketIoMessage::Binary(data) => { + if data.len() > self.max_message_size { + warn!("Received oversized binary message: {} bytes", data.len()); + return Err(ServerError::InvalidRequest( + "Message exceeds maximum size".to_string(), + )); + } + debug!("Received binary message ({} bytes)", data.len()); + match String::from_utf8(data) { + Ok(text) => self.handle_text_message(text, socket).await, + Err(_) => { + warn!("Received non-UTF-8 binary message, ignoring"); + Ok(()) + } + } + } + WebSocketIoMessage::Ping(data) => { + debug!("Received ping"); + socket.send(WebSocketIoMessage::Pong(data)).await?; + self.handler.on_ping(self.context.clone()).await + } + WebSocketIoMessage::Pong(_) => { + debug!("Received pong"); + self.handler.on_pong(self.context.clone()).await + } + WebSocketIoMessage::Close(reason) => { + debug!("Received close frame: {:?}", reason); + self.handler + .on_disconnect(self.context.clone(), reason.clone()) + .await?; + Err(ServerError::WebSocketError("Connection closed".to_string())) + } + } + } + + /// Handle text messages (JSON-RPC or bidirectional messages) + async fn handle_text_message( + &mut self, + text: String, + socket: &mut S, + ) -> ServerResult<()> { + if let Ok(msg) = serde_json::from_str::(&text) { + return self.handle_bidirectional_message(msg, socket).await; + } + + if let Ok(request) = serde_json::from_str::(&text) { + return self.handle_jsonrpc_request(request, socket).await; + } + + // Neither shape parsed. Per JSON-RPC 2.0, answer with a Parse Error + // (-32700, id null) and keep the connection open; only transport + // failures terminate the handler loop. + warn!( + "Could not parse message as JSON-RPC or bidirectional message on connection {}", + self.context.id + ); + let response = JsonRpcResponse::error(JsonRpcError::parse_error(), None); + self.send_message(socket, BidirectionalMessage::Response(response)) + .await + } + + /// Handle bidirectional messages + async fn handle_bidirectional_message( + &mut self, + msg: BidirectionalMessage, + _socket: &mut S, + ) -> ServerResult<()> { + match msg { + BidirectionalMessage::Request(request) => { + self.handle_jsonrpc_request(request, _socket).await + } + BidirectionalMessage::Subscribe { topics } => { + let before = self.context.get_subscriptions().await; + let new = topics.iter().filter(|t| !before.contains(t)).count(); + let policy = self.context.subscription_policy(); + let mut limit_error = self.check_subscription_limits(&topics).err().or_else(|| { + (before.len() + new > policy.limits.max_topics_per_connection) + .then_some("subscription limit for this connection reached") + }); + if limit_error.is_none() + && policy.limits.max_total_subscriptions > 0 + && policy.accounting.total() + new > policy.limits.max_total_subscriptions + { + limit_error = Some("global subscription limit reached"); + } + if let Some(reason) = limit_error { + warn!( + "Rejected subscribe on connection {}: {}", + self.context.id, reason + ); + let response = JsonRpcResponse::error( + JsonRpcError::invalid_params(reason.to_string()), + None, + ); + return self + .send_message(_socket, BidirectionalMessage::Response(response)) + .await; + } + self.handler + .handle_subscribe(topics, self.context.clone()) + .await + } + BidirectionalMessage::Unsubscribe { topics } => { + if let Err(reason) = self.check_subscription_limits(&topics) { + warn!( + "Rejected unsubscribe on connection {}: {}", + self.context.id, reason + ); + return Ok(()); + } + self.handler + .handle_unsubscribe(topics, self.context.clone()) + .await + } + BidirectionalMessage::Ping => self.handler.on_ping(self.context.clone()).await, + BidirectionalMessage::Pong => self.handler.on_pong(self.context.clone()).await, + // Other message types are typically server-to-client + _ => { + warn!("Received unexpected bidirectional message type from client"); + Ok(()) + } + } + } + + /// Handle JSON-RPC requests + async fn handle_jsonrpc_request( + &mut self, + request: JsonRpcRequest, + socket: &mut S, + ) -> ServerResult<()> { + debug!("Handling JSON-RPC request: {}", request.method); + let request_id = request.id.clone(); + + match self + .handler + .handle_request(request, self.context.clone()) + .await + { + Ok(Some(response)) => { + let response_msg = BidirectionalMessage::Response(response); + self.send_message(socket, response_msg).await + } + Ok(None) => { + // No response needed (notification) + Ok(()) + } + Err(e) => { + error!("Error handling request: {}", e); + let response = + JsonRpcResponse::error(jsonrpc_error_from_server_error(&e), request_id); + self.send_message(socket, BidirectionalMessage::Response(response)) + .await + } + } + } + + /// Send a message to the WebSocket client + async fn send_message( + &self, + socket: &mut S, + msg: BidirectionalMessage, + ) -> ServerResult<()> { + let json = serde_json::to_string(&msg)?; + socket.send(WebSocketIoMessage::Text(json)).await + } +} + +fn jsonrpc_error_from_server_error(error: &ServerError) -> JsonRpcError { + let code = match error { + ServerError::AuthenticationFailed(_) => error_codes::AUTHENTICATION_REQUIRED, + ServerError::PermissionDenied(_) => error_codes::INSUFFICIENT_PERMISSIONS, + ServerError::InvalidRequest(_) => error_codes::INVALID_REQUEST, + ServerError::HandlerNotFound(_) => error_codes::METHOD_NOT_FOUND, + ServerError::SerializationError(_) => error_codes::INVALID_PARAMS, + ServerError::UpgradeFailed(_) + | ServerError::ConnectionNotFound(_) + | ServerError::RoutingFailed(_) + | ServerError::WebSocketError(_) + | ServerError::ConnectionError(_) + | ServerError::Internal(_) => error_codes::INTERNAL_ERROR, + }; + + // Send only a generic per-class message; the full error was already logged + // server-side by the caller. Never interpolate handler/AuthError Display. + JsonRpcError::new(code, error.client_message().to_string(), None) +} + +#[cfg(test)] +mod tests; diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/keepalive.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/keepalive.rs new file mode 100644 index 0000000..3542dfe --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/keepalive.rs @@ -0,0 +1,61 @@ +use super::*; + +#[tokio::test(start_paused = true)] +async fn w4_idle_connection_is_pinged_then_closed() { + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::pending(); + + WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) + .with_keepalive(KeepaliveConfig { + ping_interval: Some(Duration::from_secs(5)), + idle_timeout: Some(Duration::from_secs(12)), + }) + .run_with_io(&mut socket) + .await + .unwrap(); + + let pings = socket + .outgoing + .iter() + .filter(|m| matches!(m, WebSocketIoMessage::Ping(_))) + .count(); + assert_eq!(pings, 2, "pings at 5s and 10s before the 12s idle close"); + assert!(socket.outgoing.iter().any(|m| matches!( + m, + WebSocketIoMessage::Close(Some(reason)) if reason == "idle timeout" + ))); +} + +#[tokio::test(start_paused = true)] +async fn w4_zero_durations_do_not_panic() { + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::pending(); + + // Zero ping and idle: both disabled; zero revalidation: default used. + // The sequence provider fails on its first tick, which closes the loop. + WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) + .with_keepalive(KeepaliveConfig { + ping_interval: Some(Duration::ZERO), + idle_timeout: Some(Duration::ZERO), + }) + .with_auth_revalidation(AuthRevalidation { + auth_provider: Arc::new(SequenceAuthProvider::new([])), + token: "t".into(), + interval: Duration::ZERO, + on_permission_change: PermissionChangePolicy::default(), + }) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!( + !socket + .outgoing + .iter() + .any(|m| matches!(m, WebSocketIoMessage::Ping(_))) + ); + assert!(socket.outgoing.iter().any(|m| matches!( + m, + WebSocketIoMessage::Close(Some(reason)) if reason == "credentials no longer valid" + ))); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/lifecycle.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/lifecycle.rs new file mode 100644 index 0000000..14a2676 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/lifecycle.rs @@ -0,0 +1,140 @@ +use super::*; + +#[tokio::test] +async fn default_lifecycle_methods_succeed() { + let h = PassThrough; + let c = ctx(); + h.on_connect(c.clone()).await.unwrap(); + h.on_ping(c.clone()).await.unwrap(); + h.on_pong(c.clone()).await.unwrap(); + h.on_disconnect(c.clone(), Some("bye".into())) + .await + .unwrap(); + // None reason path too. + h.on_disconnect(c, None).await.unwrap(); +} + +#[tokio::test] +async fn handler_loop_processes_jsonrpc_request_without_socket() { + let request = JsonRpcRequest::new( + "echo".into(), + Some(serde_json::json!({"value": 42})), + Some(serde_json::json!(7)), + ); + let incoming = serde_json::to_string(&BidirectionalMessage::Request(request)).unwrap(); + let mut socket = InMemorySocket::closing([WebSocketIoMessage::Text(incoming)]); + let (_tx, rx) = mpsc::channel(4); + + WebSocketHandler::new(Arc::new(RespondingHandler), ctx(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + let messages = bidirectional_outgoing(&socket); + assert!(matches!( + messages[0], + BidirectionalMessage::ConnectionEstablished { .. } + )); + + let response = match &messages[1] { + BidirectionalMessage::Response(response) => response, + other => panic!("expected response, got {other:?}"), + }; + assert_eq!(response.id, Some(serde_json::json!(7))); + assert_eq!(response.result.as_ref().unwrap()["method"], "echo"); + assert_eq!(response.result.as_ref().unwrap()["params"]["value"], 42); + + assert!(matches!( + messages[2], + BidirectionalMessage::ConnectionClosed { .. } + )); +} + +#[tokio::test] +async fn handler_loop_processes_control_messages_without_socket() { + let context = ctx(); + let subscribe = serde_json::to_string(&BidirectionalMessage::Subscribe { + topics: vec!["room:1".into()], + }) + .unwrap(); + let unsubscribe = serde_json::to_string(&BidirectionalMessage::Unsubscribe { + topics: vec!["room:1".into()], + }) + .unwrap(); + let mut socket = InMemorySocket::closing([ + WebSocketIoMessage::Text(subscribe), + WebSocketIoMessage::Text(unsubscribe), + WebSocketIoMessage::Ping(vec![1, 2, 3]), + ]); + let (_tx, rx) = mpsc::channel(4); + + WebSocketHandler::new(Arc::new(PassThrough), context.clone(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!(!context.is_subscribed_to("room:1").await); + assert!( + socket + .outgoing + .contains(&WebSocketIoMessage::Pong(vec![1, 2, 3])) + ); +} + +#[tokio::test] +async fn handler_loop_sends_manager_messages_without_socket() { + let notification = BidirectionalMessage::ServerNotification( + ras_jsonrpc_bidirectional_types::ServerNotification { + method: "server.note".into(), + params: serde_json::json!({"ok": true}), + metadata: None, + }, + ); + let (tx, rx) = mpsc::channel(4); + tx.send(OutboundMessage::from(notification)).await.unwrap(); + drop(tx); + + let mut socket = InMemorySocket::pending(); + WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + let messages = bidirectional_outgoing(&socket); + assert!(matches!( + messages[0], + BidirectionalMessage::ConnectionEstablished { .. } + )); + + match &messages[1] { + BidirectionalMessage::ServerNotification(notification) => { + assert_eq!(notification.method, "server.note"); + assert_eq!(notification.params["ok"], true); + } + other => panic!("expected server notification, got {other:?}"), + } + + assert!(matches!( + messages[2], + BidirectionalMessage::ConnectionClosed { .. } + )); +} + +#[tokio::test] +async fn handler_loop_records_close_reason_without_socket() { + let handler = Arc::new(RecordingLifecycle::new()); + let mut socket = + InMemorySocket::closing([WebSocketIoMessage::Close(Some("client bye".to_string()))]); + let (_tx, rx) = mpsc::channel(4); + + WebSocketHandler::new(handler.clone(), ctx(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!( + handler + .disconnect_reasons() + .contains(&Some("client bye".to_string())) + ); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/mod.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/mod.rs new file mode 100644 index 0000000..b1978c8 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/mod.rs @@ -0,0 +1,364 @@ +use super::*; +use crate::connection::ChannelMessageSender; +use ras_jsonrpc_bidirectional_types::ConnectionId; +use std::collections::VecDeque; +use std::sync::Mutex; + +/// A minimal MessageHandler that only implements the required method — +/// every other method falls through to the default impl, which is what +/// these tests are verifying. +struct PassThrough; + +#[async_trait] +impl MessageHandler for PassThrough { + async fn handle_request( + &self, + _request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + Ok(None) + } +} + +struct RespondingHandler; + +#[async_trait] +impl MessageHandler for RespondingHandler { + async fn handle_request( + &self, + request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + Ok(Some(JsonRpcResponse::success( + serde_json::json!({ + "method": request.method, + "params": request.params, + }), + request.id, + ))) + } +} + +struct RecoveringHandler; + +#[async_trait] +impl MessageHandler for RecoveringHandler { + async fn handle_request( + &self, + request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + if request.method == "fail" { + return Err(ServerError::InvalidRequest("bad request".into())); + } + + Ok(Some(JsonRpcResponse::success( + serde_json::json!({ + "method": request.method, + }), + request.id, + ))) + } +} + +struct RecordingLifecycle { + disconnect_reasons: Mutex>>, +} + +impl RecordingLifecycle { + fn new() -> Self { + Self { + disconnect_reasons: Mutex::new(Vec::new()), + } + } + + fn disconnect_reasons(&self) -> Vec> { + self.disconnect_reasons + .lock() + .expect("disconnect reasons lock") + .clone() + } +} + +#[async_trait] +impl MessageHandler for RecordingLifecycle { + async fn handle_request( + &self, + _request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + Ok(None) + } + + async fn on_disconnect( + &self, + _context: Arc, + reason: Option, + ) -> ServerResult<()> { + self.disconnect_reasons + .lock() + .expect("disconnect reasons lock") + .push(reason); + Ok(()) + } +} + +struct InMemorySocket { + incoming: VecDeque, + outgoing: Vec, + close_when_empty: bool, +} + +impl InMemorySocket { + fn closing(incoming: impl IntoIterator) -> Self { + Self { + incoming: incoming.into_iter().collect(), + outgoing: Vec::new(), + close_when_empty: true, + } + } + + fn pending() -> Self { + Self { + incoming: VecDeque::new(), + outgoing: Vec::new(), + close_when_empty: false, + } + } +} + +#[async_trait] +impl WebSocketIo for InMemorySocket { + async fn send(&mut self, message: WebSocketIoMessage) -> ServerResult<()> { + self.outgoing.push(message); + Ok(()) + } + + async fn recv(&mut self) -> Option> { + if let Some(message) = self.incoming.pop_front() { + return Some(Ok(message)); + } + + if self.close_when_empty { + None + } else { + std::future::pending::>>().await + } + } +} + +fn ctx() -> Arc { + let id = ConnectionId::new(); + let (tx, _rx) = mpsc::channel(4); + let sender = ChannelMessageSender::new(id, tx); + Arc::new(ConnectionContext::new(id, sender)) +} + +fn ctx_with(policy: crate::connection::SubscriptionPolicy) -> Arc { + let id = ConnectionId::new(); + let (tx, _rx) = mpsc::channel(4); + let sender = ChannelMessageSender::new(id, tx); + Arc::new(ConnectionContext::new(id, sender).with_subscription_policy(policy)) +} + +fn limits_policy(limits: SubscriptionLimits) -> crate::connection::SubscriptionPolicy { + crate::connection::SubscriptionPolicy { + limits, + ..Default::default() + } +} + +struct AllowListHandler; + +#[async_trait] +impl MessageHandler for AllowListHandler { + async fn handle_request( + &self, + _request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + Ok(None) + } + + async fn authorize_subscribe( + &self, + topic: &str, + _context: &Arc, + ) -> ServerResult { + Ok(topic == "room:allowed") + } +} + +fn auth_user(id: &str) -> ras_auth_core::AuthenticatedUser { + ras_auth_core::AuthenticatedUser { + user_id: id.to_string(), + permissions: std::collections::HashSet::new(), + metadata: None, + } +} + +/// Auth provider that replays a fixed sequence of results, then fails. +struct SequenceAuthProvider( + Mutex>>, +); + +impl SequenceAuthProvider { + fn new( + results: impl IntoIterator< + Item = Result, + >, + ) -> Self { + Self(Mutex::new(results.into_iter().collect())) + } +} + +impl AuthProvider for SequenceAuthProvider { + fn authenticate(&self, _token: String) -> ras_auth_core::AuthFuture<'_> { + let result = self + .0 + .lock() + .expect("results lock") + .pop_front() + .unwrap_or(Err(ras_auth_core::AuthError::InvalidToken)); + Box::pin(async move { result }) + } +} + +fn auth_user_with(id: &str, perms: &[&str]) -> ras_auth_core::AuthenticatedUser { + let mut user = auth_user(id); + user.permissions = perms.iter().map(|p| p.to_string()).collect(); + user +} + +/// Authorizes any topic for connections holding `room:read`. +struct PermissionGated; + +#[async_trait] +impl MessageHandler for PermissionGated { + async fn handle_request( + &self, + _request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + Ok(None) + } + + async fn authorize_subscribe( + &self, + _topic: &str, + context: &Arc, + ) -> ServerResult { + Ok(context.has_permission("room:read").await) + } +} + +fn subscribe_msg(topics: Vec) -> WebSocketIoMessage { + WebSocketIoMessage::Text( + serde_json::to_string(&BidirectionalMessage::Subscribe { topics }).unwrap(), + ) +} + +async fn manager_with(context: &ConnectionContext) -> Arc { + let manager: Arc = Arc::new(crate::DefaultConnectionManager::new()); + manager + .add_connection(ras_jsonrpc_bidirectional_types::ConnectionInfo::new( + context.id, + )) + .await + .unwrap(); + manager +} + +/// Like `PermissionGated`, but when it denies a topic during +/// re-authorization it first pushes a broadcast for that topic into the +/// connection's queue, simulating a `broadcast_to_topic` that snapshotted +/// the manager index in the window before the subscription was removed. +struct RacingAuthorizer; + +#[async_trait] +impl MessageHandler for RacingAuthorizer { + async fn handle_request( + &self, + _request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + Ok(None) + } + + async fn authorize_subscribe( + &self, + topic: &str, + context: &Arc, + ) -> ServerResult { + if context.has_permission("room:read").await { + return Ok(true); + } + let stale = + BidirectionalMessage::Broadcast(ras_jsonrpc_bidirectional_types::BroadcastMessage { + topic: topic.to_string(), + method: "secret".into(), + params: serde_json::json!({}), + metadata: None, + }); + context.sender.send_on_topic(topic, stale).await.unwrap(); + Ok(false) + } +} + +static GREEDY_ACCEPTED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// A custom handler that subscribes to far more topics than any limit +/// allows, from `on_connect` as well as `handle_subscribe`, straight on +/// the context. +struct GreedyHandler; + +impl GreedyHandler { + async fn grab(context: &ConnectionContext, prefix: &str) { + for i in 0..10 { + if context.subscribe(format!("{prefix}:{i}")).await.is_ok() { + GREEDY_ACCEPTED.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + } + } +} + +#[async_trait] +impl MessageHandler for GreedyHandler { + async fn handle_request( + &self, + _request: JsonRpcRequest, + _context: Arc, + ) -> ServerResult> { + Ok(None) + } + + async fn on_connect(&self, context: Arc) -> ServerResult<()> { + Self::grab(&context, "connect").await; + Ok(()) + } + + async fn handle_subscribe( + &self, + _topics: Vec, + context: Arc, + ) -> ServerResult<()> { + Self::grab(&context, "greedy").await; + Ok(()) + } +} + +fn bidirectional_outgoing(socket: &InMemorySocket) -> Vec { + socket + .outgoing + .iter() + .filter_map(|message| match message { + WebSocketIoMessage::Text(text) => serde_json::from_str(text).ok(), + _ => None, + }) + .collect() +} +mod keepalive; +mod lifecycle; +mod protocol; +mod revalidation; +mod subscriptions; diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/protocol.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/protocol.rs new file mode 100644 index 0000000..3400a2e --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/protocol.rs @@ -0,0 +1,182 @@ +use super::*; + +#[test] +fn jsonrpc_error_from_server_error_sends_generic_message_not_handler_detail() { + // Handler error carrying a secret -> client sees only a generic message, + // stable code preserved, no data field. + let err = ServerError::Internal("database password is hunter2".into()); + let jsonrpc = jsonrpc_error_from_server_error(&err); + assert_eq!(jsonrpc.code, error_codes::INTERNAL_ERROR); + assert_eq!(jsonrpc.message, "Internal error"); + assert!(!jsonrpc.message.contains("hunter2")); + assert!(jsonrpc.data.is_none()); + + // AuthError detail must not reach the client either. + let auth = ServerError::AuthenticationFailed(ras_auth_core::AuthError::Internal( + "dsn=postgres://user:pw@host/db".into(), + )); + let jsonrpc = jsonrpc_error_from_server_error(&auth); + assert_eq!(jsonrpc.code, error_codes::AUTHENTICATION_REQUIRED); + assert_eq!(jsonrpc.message, "Authentication failed"); + assert!(!jsonrpc.message.contains("dsn")); + + // Stable codes for the invalid-request / method-not-found classes. + assert_eq!( + jsonrpc_error_from_server_error(&ServerError::InvalidRequest("Invalid params: x".into())) + .code, + error_codes::INVALID_REQUEST + ); + assert_eq!( + jsonrpc_error_from_server_error(&ServerError::HandlerNotFound("m".into())).code, + error_codes::METHOD_NOT_FOUND + ); +} + +#[tokio::test] +async fn handler_loop_sends_jsonrpc_error_and_continues_without_socket() { + let fail = JsonRpcRequest::new( + "fail".into(), + Some(serde_json::json!({})), + Some(serde_json::json!(1)), + ); + let ok = JsonRpcRequest::new( + "ok".into(), + Some(serde_json::json!({})), + Some(serde_json::json!(2)), + ); + let mut socket = InMemorySocket::closing([ + WebSocketIoMessage::Text( + serde_json::to_string(&BidirectionalMessage::Request(fail)).unwrap(), + ), + WebSocketIoMessage::Text( + serde_json::to_string(&BidirectionalMessage::Request(ok)).unwrap(), + ), + ]); + let (_tx, rx) = mpsc::channel(4); + + WebSocketHandler::new(Arc::new(RecoveringHandler), ctx(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + let messages = bidirectional_outgoing(&socket); + assert!(matches!( + messages[0], + BidirectionalMessage::ConnectionEstablished { .. } + )); + + let error_response = match &messages[1] { + BidirectionalMessage::Response(response) => response, + other => panic!("expected error response, got {other:?}"), + }; + assert_eq!(error_response.id, Some(serde_json::json!(1))); + let error = error_response.error.as_ref().expect("JSON-RPC error"); + assert_eq!(error.code, ras_jsonrpc_types::error_codes::INVALID_REQUEST); + // Message is the generic per-class string; the handler's detail + // ("bad request") stays server-side. + assert_eq!(error.message, "Invalid request"); + + let success_response = match &messages[2] { + BidirectionalMessage::Response(response) => response, + other => panic!("expected success response, got {other:?}"), + }; + assert_eq!(success_response.id, Some(serde_json::json!(2))); + assert_eq!(success_response.result.as_ref().unwrap()["method"], "ok"); + + assert!(matches!( + messages[3], + BidirectionalMessage::ConnectionClosed { .. } + )); +} + +#[tokio::test] +async fn handler_loop_answers_malformed_text_with_parse_error_and_continues() { + let request = JsonRpcRequest::new( + "echo".into(), + Some(serde_json::json!({})), + Some(serde_json::json!(9)), + ); + let mut socket = InMemorySocket::closing([ + WebSocketIoMessage::Text("not json-rpc".to_string()), + WebSocketIoMessage::Text( + serde_json::to_string(&BidirectionalMessage::Request(request)).unwrap(), + ), + ]); + let (_tx, rx) = mpsc::channel(4); + + WebSocketHandler::new(Arc::new(RespondingHandler), ctx(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + let messages = bidirectional_outgoing(&socket); + assert!(matches!( + messages[0], + BidirectionalMessage::ConnectionEstablished { .. } + )); + + // The garbage frame is answered with -32700 (id null)... + let parse_error = match &messages[1] { + BidirectionalMessage::Response(response) => response, + other => panic!("expected parse error response, got {other:?}"), + }; + assert_eq!(parse_error.id, None); + let error = parse_error.error.as_ref().expect("parse error"); + assert_eq!(error.code, ras_jsonrpc_types::error_codes::PARSE_ERROR); + + // ...and the connection keeps serving subsequent requests. + let response = match &messages[2] { + BidirectionalMessage::Response(response) => response, + other => panic!("expected response, got {other:?}"), + }; + assert_eq!(response.id, Some(serde_json::json!(9))); + + assert!(matches!( + messages[3], + BidirectionalMessage::ConnectionClosed { .. } + )); +} + +#[tokio::test] +async fn handler_loop_closes_oversized_text_without_response() { + let mut socket = InMemorySocket::closing([WebSocketIoMessage::Text("too large".into())]); + let (_tx, rx) = mpsc::channel(4); + + WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 4) + .run_with_io(&mut socket) + .await + .unwrap(); + + let messages = bidirectional_outgoing(&socket); + assert_eq!(messages.len(), 2); + assert!(matches!( + messages[0], + BidirectionalMessage::ConnectionEstablished { .. } + )); + assert!(matches!( + messages[1], + BidirectionalMessage::ConnectionClosed { .. } + )); +} + +#[tokio::test] +async fn handler_loop_ignores_non_utf8_binary_without_response() { + let mut socket = InMemorySocket::closing([WebSocketIoMessage::Binary(vec![0xff, 0xfe])]); + let (_tx, rx) = mpsc::channel(4); + + WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + let messages = bidirectional_outgoing(&socket); + assert_eq!(messages.len(), 2); + assert!(matches!( + messages[0], + BidirectionalMessage::ConnectionEstablished { .. } + )); + assert!(matches!( + messages[1], + BidirectionalMessage::ConnectionClosed { .. } + )); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/revalidation.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/revalidation.rs new file mode 100644 index 0000000..b1ec68f --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/revalidation.rs @@ -0,0 +1,243 @@ +use super::*; + +#[tokio::test(start_paused = true)] +async fn revalidation_failure_closes_connection() { + let context = ctx(); + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::pending(); + + WebSocketHandler::new(Arc::new(PassThrough), context, rx, 1024) + .with_auth_revalidation(AuthRevalidation { + auth_provider: Arc::new(SequenceAuthProvider::new([])), + token: "revoked-token".into(), + interval: Duration::from_secs(30), + on_permission_change: PermissionChangePolicy::default(), + }) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!(socket.outgoing.iter().any(|message| matches!( + message, + WebSocketIoMessage::Close(Some(reason)) if reason == "credentials no longer valid" + ))); +} + +#[tokio::test(start_paused = true)] +async fn revalidation_success_refreshes_cached_user() { + let context = ctx(); + context.set_user(auth_user("stale")).await; + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::pending(); + + WebSocketHandler::new(Arc::new(PassThrough), context.clone(), rx, 1024) + .with_auth_revalidation(AuthRevalidation { + auth_provider: Arc::new(SequenceAuthProvider::new([Ok(auth_user("fresh"))])), + token: "valid-token".into(), + interval: Duration::from_secs(30), + on_permission_change: PermissionChangePolicy::default(), + }) + .run_with_io(&mut socket) + .await + .unwrap(); + + // First tick refreshed the cached user; the second (sequence + // exhausted) failed and closed the connection. + assert_eq!(context.get_user().await.expect("user").user_id, "fresh"); + assert!( + socket + .outgoing + .iter() + .any(|message| matches!(message, WebSocketIoMessage::Close(_))) + ); +} + +#[tokio::test(start_paused = true)] +async fn w1_revalidation_drops_subscriptions_no_longer_authorized() { + let context = ctx(); + context.set_user(auth_user_with("u", &["room:read"])).await; + let manager = manager_with(&context).await; + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::pending(); + socket + .incoming + .push_back(subscribe_msg(vec!["room:1".into()])); + + // Tick 1 returns the same user with the permission revoked; tick 2 fails. + let provider = SequenceAuthProvider::new([Ok(auth_user_with("u", &[]))]); + WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) + .with_connection_manager(manager.clone()) + .with_auth_revalidation(AuthRevalidation { + auth_provider: Arc::new(provider), + token: "t".into(), + interval: Duration::from_secs(30), + on_permission_change: PermissionChangePolicy::DropSubscriptions, + }) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!(!context.is_subscribed_to("room:1").await); + assert!( + manager + .get_subscriptions(context.id) + .await + .unwrap() + .is_empty() + ); + assert!( + manager + .get_subscribed_connections("room:1") + .await + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn w1_subscribe_mirrors_into_manager_index() { + let manager: Arc = Arc::new(crate::DefaultConnectionManager::new()); + let context = ctx_with(crate::connection::SubscriptionPolicy { + manager: Some(manager.clone()), + ..Default::default() + }); + manager + .add_connection(ras_jsonrpc_bidirectional_types::ConnectionInfo::new( + context.id, + )) + .await + .unwrap(); + + context.subscribe("room:1".into()).await.unwrap(); + assert!(context.is_subscribed_to("room:1").await); + assert_eq!( + manager.get_subscriptions(context.id).await.unwrap(), + vec!["room:1".to_string()] + ); + + assert!(context.unsubscribe("room:1").await); + assert!( + manager + .get_subscriptions(context.id) + .await + .unwrap() + .is_empty() + ); + assert_eq!(context.subscription_policy().accounting.total(), 0); +} + +#[tokio::test(start_paused = true)] +async fn w1_close_policy_closes_socket_when_permissions_change() { + let context = ctx(); + context.set_user(auth_user_with("u", &["room:read"])).await; + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::pending(); + + WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) + .with_auth_revalidation(AuthRevalidation { + auth_provider: Arc::new(SequenceAuthProvider::new([Ok(auth_user_with("u", &[]))])), + token: "t".into(), + interval: Duration::from_secs(30), + on_permission_change: PermissionChangePolicy::Close, + }) + .run_with_io(&mut socket) + .await + .unwrap(); + + // Closed on the first tick (permission change), not the second (failure). + assert_eq!( + socket + .outgoing + .iter() + .filter(|m| matches!(m, WebSocketIoMessage::Close(_))) + .count(), + 1 + ); + assert!(context.get_user().await.unwrap().permissions.is_empty()); +} + +#[tokio::test(start_paused = true)] +async fn w1_broadcast_queued_during_revocation_window_is_not_delivered() { + let context = ctx(); + context.set_user(auth_user_with("u", &["room:read"])).await; + let manager = manager_with(&context).await; + let (tx, rx) = mpsc::channel(4); + // The handler's context must share this channel so the authorizer + // can enqueue through `context.sender`. + let context = Arc::new(ConnectionContext::new( + context.id, + ChannelMessageSender::new(context.id, tx), + )); + context.set_user(auth_user_with("u", &["room:read"])).await; + let mut socket = InMemorySocket::pending(); + socket + .incoming + .push_back(subscribe_msg(vec!["room:1".into()])); + + WebSocketHandler::new(Arc::new(RacingAuthorizer), context.clone(), rx, 1024) + .with_connection_manager(manager) + .with_auth_revalidation(AuthRevalidation { + auth_provider: Arc::new(SequenceAuthProvider::new([Ok(auth_user_with("u", &[]))])), + token: "t".into(), + interval: Duration::from_secs(30), + on_permission_change: PermissionChangePolicy::DropSubscriptions, + }) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!(!context.is_subscribed_to("room:1").await); + let leaked = bidirectional_outgoing(&socket) + .iter() + .any(|m| matches!(m, BidirectionalMessage::Broadcast(b) if b.method == "secret")); + assert!( + !leaked, + "broadcast queued during the revocation window must be dropped" + ); +} + +#[tokio::test] +async fn w1_egress_gate_only_filters_topic_routed_messages() { + let context = ctx(); + let (tx, rx) = mpsc::channel(4); + let ping = BidirectionalMessage::Ping; + tx.send(OutboundMessage::from(ping.clone())).await.unwrap(); + tx.send(OutboundMessage { + message: ping, + topic: Some("room:never".into()), + }) + .await + .unwrap(); + drop(tx); + + let mut socket = InMemorySocket::pending(); + WebSocketHandler::new(Arc::new(PassThrough), context, rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + let pings = bidirectional_outgoing(&socket) + .iter() + .filter(|m| matches!(m, BidirectionalMessage::Ping)) + .count(); + assert_eq!( + pings, 1, + "untagged delivered, topic-tagged unsubscribed dropped" + ); +} + +#[tokio::test] +async fn handler_without_revalidation_does_not_authenticate() { + // No auth provider involved at all: the loop must terminate on + // socket close without ticking a revalidation timer. + let mut socket = InMemorySocket::closing([]); + let (_tx, rx) = mpsc::channel(4); + + WebSocketHandler::new(Arc::new(PassThrough), ctx(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + let messages = bidirectional_outgoing(&socket); + assert_eq!(messages.len(), 2); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/subscriptions.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/subscriptions.rs new file mode 100644 index 0000000..8408304 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/subscriptions.rs @@ -0,0 +1,231 @@ +use super::*; + +#[tokio::test] +async fn default_handle_subscribe_denies_all_topics() { + let h = PassThrough; + let c = ctx(); + h.handle_subscribe(vec!["a".into(), "b".into()], c.clone()) + .await + .unwrap(); + assert!(!c.is_subscribed_to("a").await); + assert!(!c.is_subscribed_to("b").await); +} + +#[tokio::test] +async fn default_authorize_subscribe_denies() { + let h = PassThrough; + let c = ctx(); + assert!(!h.authorize_subscribe("any-topic", &c).await.unwrap()); +} + +#[tokio::test] +async fn handle_subscribe_only_subscribes_authorized_topics() { + let h = AllowListHandler; + let c = ctx(); + h.handle_subscribe(vec!["room:allowed".into(), "room:denied".into()], c.clone()) + .await + .unwrap(); + assert!(c.is_subscribed_to("room:allowed").await); + assert!(!c.is_subscribed_to("room:denied").await); +} + +#[tokio::test] +async fn default_handle_unsubscribe_removes_from_context() { + let h = PassThrough; + let c = ctx(); + c.subscribe("a".into()).await.unwrap(); + c.subscribe("b".into()).await.unwrap(); + h.handle_unsubscribe(vec!["a".into()], c.clone()) + .await + .unwrap(); + assert!(!c.is_subscribed_to("a").await); + assert!(c.is_subscribed_to("b").await); +} + +#[tokio::test] +async fn w3_subscribe_over_per_message_limit_is_rejected() { + let context = ctx_with(limits_policy(SubscriptionLimits { + max_topics_per_message: 2, + ..SubscriptionLimits::default() + })); + context.set_user(auth_user_with("u", &["room:read"])).await; + let (_tx, rx) = mpsc::channel(4); + let topics: Vec = (0..3).map(|i| format!("room:{i}")).collect(); + let mut socket = InMemorySocket::closing([subscribe_msg(topics)]); + + WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!(context.get_subscriptions().await.is_empty()); + assert!(bidirectional_outgoing(&socket).iter().any(|m| matches!( + m, + BidirectionalMessage::Response(r) if r.error.is_some() + ))); +} + +#[tokio::test] +async fn w3_subscribe_over_per_connection_limit_is_rejected() { + let context = ctx_with(limits_policy(SubscriptionLimits { + max_topics_per_connection: 1, + ..SubscriptionLimits::default() + })); + context.set_user(auth_user_with("u", &["room:read"])).await; + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::closing([ + subscribe_msg(vec!["room:1".into()]), + subscribe_msg(vec!["room:2".into()]), + ]); + + WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + // First subscribe accepted silently; second answered with an error. + let errors = bidirectional_outgoing(&socket) + .iter() + .filter(|m| matches!(m, BidirectionalMessage::Response(r) if r.error.is_some())) + .count(); + assert_eq!(errors, 1); + // Teardown released the held slot. + assert_eq!(context.subscription_policy().accounting.total(), 0); + assert!(context.get_subscriptions().await.is_empty()); + + // Direct path: the context itself refuses the second topic. + let direct = ctx_with(limits_policy(SubscriptionLimits { + max_topics_per_connection: 1, + ..SubscriptionLimits::default() + })); + direct.subscribe("room:1".into()).await.unwrap(); + assert!(matches!( + direct.subscribe("room:2".into()).await, + Err(ras_jsonrpc_bidirectional_types::BidirectionalError::SubscriptionLimitReached(_)) + )); +} + +#[tokio::test] +async fn w3_overlong_topic_is_rejected() { + let context = ctx(); + context.set_user(auth_user_with("u", &["room:read"])).await; + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::closing([subscribe_msg(vec!["x".repeat(300)])]); + + WebSocketHandler::new(Arc::new(PermissionGated), context.clone(), rx, 1024) + .run_with_io(&mut socket) + .await + .unwrap(); + + assert!(context.get_subscriptions().await.is_empty()); +} + +#[tokio::test] +async fn w3_global_subscription_cap_is_enforced_across_connections() { + // Service-level accounting shared by both contexts. The first + // connection stays open (pending socket) so its slots remain held + // while the second connection tries to subscribe. + let limits = SubscriptionLimits { + max_total_subscriptions: 2, + ..SubscriptionLimits::default() + }; + let accounting = Arc::new(SubscriptionAccounting::default()); + let policy = crate::connection::SubscriptionPolicy { + limits, + accounting: accounting.clone(), + manager: None, + }; + + let first = ctx_with(policy.clone()); + first.set_user(auth_user_with("u", &["room:read"])).await; + let (_tx1, rx1) = mpsc::channel(4); + let mut socket1 = InMemorySocket::pending(); + socket1 + .incoming + .push_back(subscribe_msg(vec!["a".into(), "b".into()])); + let first_run = { + let first = first.clone(); + tokio::spawn(async move { + WebSocketHandler::new(Arc::new(PermissionGated), first, rx1, 1024) + .with_keepalive(KeepaliveConfig { + ping_interval: None, + idle_timeout: None, + }) + .run_with_io(&mut socket1) + .await + }) + }; + tokio::time::timeout(Duration::from_secs(10), async { + while accounting.total() != 2 { + tokio::task::yield_now().await; + } + }) + .await + .expect("first connection should reserve its 2 slots"); + assert_eq!(first.get_subscriptions().await.len(), 2); + + let second = ctx_with(policy); + second.set_user(auth_user_with("u", &["room:read"])).await; + let (_tx2, rx2) = mpsc::channel(4); + let mut socket2 = InMemorySocket::closing([subscribe_msg(vec!["c".into()])]); + WebSocketHandler::new(Arc::new(PermissionGated), second.clone(), rx2, 1024) + .run_with_io(&mut socket2) + .await + .unwrap(); + + assert!(second.get_subscriptions().await.is_empty()); + assert_eq!(accounting.total(), 2, "second connection reserved nothing"); + first_run.abort(); +} + +#[tokio::test] +async fn w3_custom_handler_cannot_exceed_limits_from_any_callback() { + let limits = SubscriptionLimits { + max_topics_per_connection: 3, + ..SubscriptionLimits::default() + }; + let manager: Arc = Arc::new( + crate::DefaultConnectionManager::with_subscription_limits(limits), + ); + let accounting = Arc::new(SubscriptionAccounting::default()); + let context = ctx_with(crate::connection::SubscriptionPolicy { + limits, + accounting: accounting.clone(), + manager: Some(manager.clone()), + }); + manager + .add_connection(ras_jsonrpc_bidirectional_types::ConnectionInfo::new( + context.id, + )) + .await + .unwrap(); + let (_tx, rx) = mpsc::channel(4); + let mut socket = InMemorySocket::closing([subscribe_msg(vec!["x".into()])]); + + WebSocketHandler::new(Arc::new(GreedyHandler), context.clone(), rx, 1024) + .with_connection_manager(manager.clone()) + .run_with_io(&mut socket) + .await + .unwrap(); + + // Greedy on_connect (10), handle_request-free, then greedy + // handle_subscribe (10 more): the context admitted three in total, + // the manager saw exactly those, and disconnect released exactly + // those, so the counter is back to zero, not underflowed. + assert_eq!( + context.get_subscriptions().await.len(), + 0, + "released on disconnect" + ); + assert_eq!( + manager.get_subscriptions(context.id).await.unwrap().len(), + 0 + ); + assert_eq!(accounting.total(), 0, "no underflow"); + assert_eq!(manager.total_subscription_count().await.unwrap(), 0); + assert_eq!( + GREEDY_ACCEPTED.load(std::sync::atomic::Ordering::SeqCst), + 3, + "exactly the cap accepted across on_connect and handle_subscribe" + ); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/lib.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/lib.rs index f8aa494..262084e 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/lib.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/lib.rs @@ -9,6 +9,7 @@ pub mod handler; pub mod manager; pub mod router; pub mod service; +mod subscriptions; pub mod upgrade; pub use connection::{ diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/subscriptions.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/subscriptions.rs new file mode 100644 index 0000000..123905f --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/subscriptions.rs @@ -0,0 +1,87 @@ +//! Subscription limits, service-wide accounting, and policy configuration. + +use ras_jsonrpc_bidirectional_types::ConnectionManager; +use std::sync::Arc; + +/// Limits on client-initiated subscriptions (W3). +/// +/// Enforced by the handler loop before [`MessageHandler::handle_subscribe`](crate::handler::MessageHandler::handle_subscribe) +/// runs, so services never see an over-limit request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SubscriptionLimits { + /// Maximum topics in one `Subscribe`/`Unsubscribe` message + pub max_topics_per_message: usize, + /// Maximum concurrent subscriptions held by one connection + pub max_topics_per_connection: usize, + /// Maximum topic name length in bytes + pub max_topic_length: usize, + /// Maximum (connection, topic) pairs across the whole manager. `0` + /// disables the cap. Enforced only when the manager reports its count + /// (`ConnectionManager::total_subscription_count`); the default manager + /// does. + pub max_total_subscriptions: usize, +} + +impl Default for SubscriptionLimits { + fn default() -> Self { + Self { + max_topics_per_message: 64, + max_topics_per_connection: 256, + max_topic_length: 256, + max_total_subscriptions: 100_000, + } + } +} + +/// Service-wide count of held subscriptions, shared by every connection of a +/// service so the global cap is enforced by the server itself, independently +/// of which `ConnectionManager` or `MessageHandler` is plugged in. +#[derive(Debug, Default)] +pub struct SubscriptionAccounting { + total: std::sync::atomic::AtomicUsize, +} + +impl SubscriptionAccounting { + /// Current number of (connection, topic) pairs held across the service. + pub fn total(&self) -> usize { + self.total.load(std::sync::atomic::Ordering::Acquire) + } + + /// Atomically reserve one slot; `false` when `max` (non-zero) is reached. + pub(crate) fn reserve(&self, max: usize) -> bool { + use std::sync::atomic::Ordering; + let previous = self.total.fetch_add(1, Ordering::AcqRel); + if max > 0 && previous >= max { + self.total.fetch_sub(1, Ordering::AcqRel); + return false; + } + true + } + + pub(crate) fn release(&self, count: usize) { + self.total + .fetch_sub(count, std::sync::atomic::Ordering::AcqRel); + } +} + +/// Everything a subscription mutation has to be checked against and mirrored +/// into. Owned by the service and shared by all of its connections. +#[derive(Clone, Default)] +pub struct SubscriptionPolicy { + /// Caps applied to every subscribe + pub limits: SubscriptionLimits, + /// Service-wide counter behind the global cap + pub accounting: Arc, + /// Manager whose topic index mirrors accepted subscriptions + pub manager: Option>, +} + +impl std::fmt::Debug for SubscriptionPolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SubscriptionPolicy") + .field("limits", &self.limits) + .field("held", &self.accounting.total()) + .field("manager", &self.manager.is_some()) + .finish() + } +} diff --git a/documentation/reviews/refactor-progress.md b/documentation/reviews/refactor-progress.md index 76c54f2..d77c7cf 100644 --- a/documentation/reviews/refactor-progress.md +++ b/documentation/reviews/refactor-progress.md @@ -19,3 +19,4 @@ REST baseline: 61/61 passed. | 3 | JSON-RPC model and parser | 59 tests; doctests (1 pre-existing ignored example); Clippy; all macro feature modes and no-default/server/client-WASM `basic-jsonrpc-api` builds. | | 4 | JSON-RPC builder, HTTP envelope/auth policy, method/version dispatch | 59 tests; doctests; Clippy; all macro feature modes; native-server and WASM-client consumer builds. | | 5 | Shared explorer assets crate | Original template SHA-256 preserved; 120 macro tests; docs/Clippy/features; 11/11 browser tests (baseline also 11/11); asset and macro packages created offline, unpacked macro builds pass using local dependency patches. | +| 6 | WebSocket subscription policy/accounting and handler test organization | 112 server/macro tests including all 29 moved handler tests; docs and Clippy; chat server consumer build. Checked mutation and egress checks unchanged. | From dbcec1511e3ff2d6088634831ccd4b3a88ab2312 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 11:17:47 +0200 Subject: [PATCH 07/35] refactor(websocket): isolate callbacks IO and lifecycle configuration --- .../src/handler/config.rs | 67 +++++ .../src/handler/contract.rs | 107 ++++++++ .../src/handler/io.rs | 74 ++++++ .../src/handler/mod.rs | 244 +----------------- .../src/handler/tests/mod.rs | 3 + documentation/reviews/refactor-progress.md | 1 + 6 files changed, 262 insertions(+), 234 deletions(-) create mode 100644 crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/config.rs create mode 100644 crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/contract.rs create mode 100644 crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/io.rs diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/config.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/config.rs new file mode 100644 index 0000000..2ab82e8 --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/config.rs @@ -0,0 +1,67 @@ +//! Revalidation and keepalive policy for a connection. + +use ras_auth_core::AuthProvider; +use std::sync::Arc; +use std::time::Duration; + +/// Default interval between credential re-validations on long-lived connections. +pub const DEFAULT_AUTH_REVALIDATION_INTERVAL: Duration = Duration::from_secs(30); + +/// Periodic credential re-validation for a long-lived connection. +/// +/// The token is captured before the WebSocket upgrade and re-run through the +/// auth provider on every `interval` tick. Failure closes the connection; +/// success refreshes the cached user (so permission changes propagate). This +/// bounds the lifetime of revoked/expired credentials on an open socket to at +/// most one interval. +pub struct AuthRevalidation { + /// Provider used to re-run authentication + pub auth_provider: Arc, + /// Token captured at upgrade time + pub token: String, + /// How often to re-validate + pub interval: Duration, + /// What to do when re-validation succeeds but the permission set changed + pub on_permission_change: PermissionChangePolicy, +} + +/// Policy applied when a live connection's permissions change on +/// re-validation (W1). +/// +/// In both modes every held subscription is re-run through +/// [`MessageHandler::authorize_subscribe`](super::MessageHandler::authorize_subscribe) against the refreshed user and +/// topics that are no longer authorized are dropped, so a downgraded +/// connection stops receiving topic broadcasts within one interval. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PermissionChangePolicy { + /// Keep the socket open and silently drop subscriptions that are no + /// longer authorized. + #[default] + DropSubscriptions, + /// Close the socket so the client must reconnect and re-authenticate. + Close, +} + +/// Server-side keepalive for a connection (W4). +/// +/// The server sends a WebSocket ping every `ping_interval`; browsers and +/// tungstenite answer pings automatically, and any inbound frame (including +/// the pong) resets the idle clock. A connection that stays silent for +/// `idle_timeout` is closed, so half-open sockets are reclaimed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KeepaliveConfig { + /// Interval between server-initiated pings (`None` disables pings) + pub ping_interval: Option, + /// Close the socket after this long without any inbound frame + /// (`None` disables the idle timeout) + pub idle_timeout: Option, +} + +impl Default for KeepaliveConfig { + fn default() -> Self { + Self { + ping_interval: Some(Duration::from_secs(30)), + idle_timeout: Some(Duration::from_secs(90)), + } + } +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/contract.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/contract.rs new file mode 100644 index 0000000..14d0f1c --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/contract.rs @@ -0,0 +1,107 @@ +//! Service callbacks and subscription authorization contract. + +use crate::{ConnectionContext, ServerResult}; +use async_trait::async_trait; +use ras_jsonrpc_types::{JsonRpcRequest, JsonRpcResponse}; +use std::sync::Arc; +use tracing::{debug, info, warn}; + +/// Trait for handling JSON-RPC requests within a WebSocket context +#[async_trait] +pub trait MessageHandler: Send + Sync + 'static { + /// Handle an incoming JSON-RPC request + /// + /// # Arguments + /// * `request` - The JSON-RPC request to handle + /// * `context` - The connection context containing auth info and metadata + /// + /// # Returns + /// * `Ok(Some(response))` - Response to send back to client + /// * `Ok(None)` - No response needed (for notifications) + /// * `Err(error)` - Error occurred during handling + async fn handle_request( + &self, + request: JsonRpcRequest, + context: Arc, + ) -> ServerResult>; + + /// Decide whether this connection may subscribe to `topic`. + /// + /// Default-deny: services that broadcast over topics must override this + /// (or `handle_subscribe`) to allow the topics a connection is entitled + /// to. Errors propagate to the handler loop and close the connection. + async fn authorize_subscribe( + &self, + _topic: &str, + _context: &Arc, + ) -> ServerResult { + Ok(false) + } + + /// Handle subscription requests + async fn handle_subscribe( + &self, + topics: Vec, + context: Arc, + ) -> ServerResult<()> { + // Default implementation subscribes the connection to each topic the + // service authorizes via `authorize_subscribe`; denied topics are + // skipped without closing the connection. + for topic in topics { + if self.authorize_subscribe(&topic, &context).await? { + if let Err(e) = context.subscribe(topic.clone()).await { + warn!( + "Refused subscription to topic '{}' for connection {}: {}", + topic, context.id, e + ); + } + } else { + warn!( + "Denied subscription to topic '{}' for connection {}", + topic, context.id + ); + } + } + Ok(()) + } + + /// Handle unsubscription requests + async fn handle_unsubscribe( + &self, + topics: Vec, + context: Arc, + ) -> ServerResult<()> { + for topic in topics { + context.unsubscribe(&topic).await; + } + Ok(()) + } + + /// Handle connection established event + async fn on_connect(&self, context: Arc) -> ServerResult<()> { + info!("Connection established: {}", context.id); + Ok(()) + } + + /// Handle connection closed event + async fn on_disconnect( + &self, + context: Arc, + reason: Option, + ) -> ServerResult<()> { + info!("Connection closed: {} (reason: {:?})", context.id, reason); + Ok(()) + } + + /// Handle ping message + async fn on_ping(&self, _context: Arc) -> ServerResult<()> { + debug!("Received ping"); + Ok(()) + } + + /// Handle pong message + async fn on_pong(&self, _context: Arc) -> ServerResult<()> { + debug!("Received pong"); + Ok(()) + } +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/io.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/io.rs new file mode 100644 index 0000000..86cb6ee --- /dev/null +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/io.rs @@ -0,0 +1,74 @@ +//! Socket IO boundary and Axum adapter. + +use crate::{ServerError, ServerResult}; +use async_trait::async_trait; +use axum::extract::ws::{CloseFrame, Message, WebSocket}; +use futures::stream::StreamExt; + +/// WebSocket message shape used by the server handler loop. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WebSocketIoMessage { + Text(String), + Binary(Vec), + Ping(Vec), + Pong(Vec), + Close(Option), +} + +impl From for WebSocketIoMessage { + fn from(message: Message) -> Self { + match message { + Message::Text(text) => Self::Text(text.to_string()), + Message::Binary(data) => Self::Binary(data.to_vec()), + Message::Ping(data) => Self::Ping(data.to_vec()), + Message::Pong(data) => Self::Pong(data.to_vec()), + Message::Close(frame) => Self::Close(frame.map(|frame| frame.reason.to_string())), + } + } +} + +/// Minimal socket interface used by the message loop. +#[async_trait] +pub trait WebSocketIo: Send { + async fn send(&mut self, message: WebSocketIoMessage) -> ServerResult<()>; + async fn recv(&mut self) -> Option>; +} + +pub(crate) struct AxumWebSocketIo { + socket: WebSocket, +} + +impl AxumWebSocketIo { + pub(crate) fn new(socket: WebSocket) -> Self { + Self { socket } + } +} + +#[async_trait] +impl WebSocketIo for AxumWebSocketIo { + async fn send(&mut self, message: WebSocketIoMessage) -> ServerResult<()> { + let message = match message { + WebSocketIoMessage::Text(text) => Message::Text(text.into()), + WebSocketIoMessage::Binary(data) => Message::Binary(data.into()), + WebSocketIoMessage::Ping(data) => Message::Ping(data.into()), + WebSocketIoMessage::Pong(data) => Message::Pong(data.into()), + WebSocketIoMessage::Close(reason) => Message::Close(reason.map(|reason| CloseFrame { + code: axum::extract::ws::close_code::NORMAL, + reason: reason.into(), + })), + }; + + self.socket + .send(message) + .await + .map_err(|e| ServerError::WebSocketError(e.to_string())) + } + + async fn recv(&mut self) -> Option> { + self.socket.next().await.map(|message| { + message + .map(WebSocketIoMessage::from) + .map_err(|e| ServerError::WebSocketError(e.to_string())) + }) + } +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/mod.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/mod.rs index 8cec338..b644697 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/mod.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/mod.rs @@ -1,10 +1,7 @@ //! Message handlers for WebSocket communication use crate::{ConnectionContext, ServerError, ServerResult, connection::OutboundMessage}; -use async_trait::async_trait; -use axum::extract::ws::{CloseFrame, Message, WebSocket}; -use futures::stream::StreamExt; -use ras_auth_core::AuthProvider; +use axum::extract::ws::WebSocket; use ras_jsonrpc_bidirectional_types::{BidirectionalMessage, ConnectionManager}; use ras_jsonrpc_types::{JsonRpcError, JsonRpcRequest, JsonRpcResponse, error_codes}; use std::sync::Arc; @@ -12,237 +9,16 @@ use std::time::Duration; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; -/// Trait for handling JSON-RPC requests within a WebSocket context -#[async_trait] -pub trait MessageHandler: Send + Sync + 'static { - /// Handle an incoming JSON-RPC request - /// - /// # Arguments - /// * `request` - The JSON-RPC request to handle - /// * `context` - The connection context containing auth info and metadata - /// - /// # Returns - /// * `Ok(Some(response))` - Response to send back to client - /// * `Ok(None)` - No response needed (for notifications) - /// * `Err(error)` - Error occurred during handling - async fn handle_request( - &self, - request: JsonRpcRequest, - context: Arc, - ) -> ServerResult>; - - /// Decide whether this connection may subscribe to `topic`. - /// - /// Default-deny: services that broadcast over topics must override this - /// (or `handle_subscribe`) to allow the topics a connection is entitled - /// to. Errors propagate to the handler loop and close the connection. - async fn authorize_subscribe( - &self, - _topic: &str, - _context: &Arc, - ) -> ServerResult { - Ok(false) - } - - /// Handle subscription requests - async fn handle_subscribe( - &self, - topics: Vec, - context: Arc, - ) -> ServerResult<()> { - // Default implementation subscribes the connection to each topic the - // service authorizes via `authorize_subscribe`; denied topics are - // skipped without closing the connection. - for topic in topics { - if self.authorize_subscribe(&topic, &context).await? { - if let Err(e) = context.subscribe(topic.clone()).await { - warn!( - "Refused subscription to topic '{}' for connection {}: {}", - topic, context.id, e - ); - } - } else { - warn!( - "Denied subscription to topic '{}' for connection {}", - topic, context.id - ); - } - } - Ok(()) - } - - /// Handle unsubscription requests - async fn handle_unsubscribe( - &self, - topics: Vec, - context: Arc, - ) -> ServerResult<()> { - for topic in topics { - context.unsubscribe(&topic).await; - } - Ok(()) - } - - /// Handle connection established event - async fn on_connect(&self, context: Arc) -> ServerResult<()> { - info!("Connection established: {}", context.id); - Ok(()) - } - - /// Handle connection closed event - async fn on_disconnect( - &self, - context: Arc, - reason: Option, - ) -> ServerResult<()> { - info!("Connection closed: {} (reason: {:?})", context.id, reason); - Ok(()) - } - - /// Handle ping message - async fn on_ping(&self, _context: Arc) -> ServerResult<()> { - debug!("Received ping"); - Ok(()) - } - - /// Handle pong message - async fn on_pong(&self, _context: Arc) -> ServerResult<()> { - debug!("Received pong"); - Ok(()) - } -} - -/// WebSocket message shape used by the server handler loop. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum WebSocketIoMessage { - Text(String), - Binary(Vec), - Ping(Vec), - Pong(Vec), - Close(Option), -} - -impl From for WebSocketIoMessage { - fn from(message: Message) -> Self { - match message { - Message::Text(text) => Self::Text(text.to_string()), - Message::Binary(data) => Self::Binary(data.to_vec()), - Message::Ping(data) => Self::Ping(data.to_vec()), - Message::Pong(data) => Self::Pong(data.to_vec()), - Message::Close(frame) => Self::Close(frame.map(|frame| frame.reason.to_string())), - } - } -} - -/// Minimal socket interface used by the message loop. -#[async_trait] -pub trait WebSocketIo: Send { - async fn send(&mut self, message: WebSocketIoMessage) -> ServerResult<()>; - async fn recv(&mut self) -> Option>; -} - -pub(crate) struct AxumWebSocketIo { - socket: WebSocket, -} - -impl AxumWebSocketIo { - pub(crate) fn new(socket: WebSocket) -> Self { - Self { socket } - } -} - -#[async_trait] -impl WebSocketIo for AxumWebSocketIo { - async fn send(&mut self, message: WebSocketIoMessage) -> ServerResult<()> { - let message = match message { - WebSocketIoMessage::Text(text) => Message::Text(text.into()), - WebSocketIoMessage::Binary(data) => Message::Binary(data.into()), - WebSocketIoMessage::Ping(data) => Message::Ping(data.into()), - WebSocketIoMessage::Pong(data) => Message::Pong(data.into()), - WebSocketIoMessage::Close(reason) => Message::Close(reason.map(|reason| CloseFrame { - code: axum::extract::ws::close_code::NORMAL, - reason: reason.into(), - })), - }; - - self.socket - .send(message) - .await - .map_err(|e| ServerError::WebSocketError(e.to_string())) - } - - async fn recv(&mut self) -> Option> { - self.socket.next().await.map(|message| { - message - .map(WebSocketIoMessage::from) - .map_err(|e| ServerError::WebSocketError(e.to_string())) - }) - } -} - -/// Default interval between credential re-validations on long-lived connections. -pub const DEFAULT_AUTH_REVALIDATION_INTERVAL: Duration = Duration::from_secs(30); - -/// Periodic credential re-validation for a long-lived connection. -/// -/// The token is captured before the WebSocket upgrade and re-run through the -/// auth provider on every `interval` tick. Failure closes the connection; -/// success refreshes the cached user (so permission changes propagate). This -/// bounds the lifetime of revoked/expired credentials on an open socket to at -/// most one interval. -pub struct AuthRevalidation { - /// Provider used to re-run authentication - pub auth_provider: Arc, - /// Token captured at upgrade time - pub token: String, - /// How often to re-validate - pub interval: Duration, - /// What to do when re-validation succeeds but the permission set changed - pub on_permission_change: PermissionChangePolicy, -} - -/// Policy applied when a live connection's permissions change on -/// re-validation (W1). -/// -/// In both modes every held subscription is re-run through -/// [`MessageHandler::authorize_subscribe`] against the refreshed user and -/// topics that are no longer authorized are dropped, so a downgraded -/// connection stops receiving topic broadcasts within one interval. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PermissionChangePolicy { - /// Keep the socket open and silently drop subscriptions that are no - /// longer authorized. - #[default] - DropSubscriptions, - /// Close the socket so the client must reconnect and re-authenticate. - Close, -} - +mod config; +mod contract; +mod io; pub use crate::subscriptions::{SubscriptionAccounting, SubscriptionLimits}; - -/// Server-side keepalive for a connection (W4). -/// -/// The server sends a WebSocket ping every `ping_interval`; browsers and -/// tungstenite answer pings automatically, and any inbound frame (including -/// the pong) resets the idle clock. A connection that stays silent for -/// `idle_timeout` is closed, so half-open sockets are reclaimed. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct KeepaliveConfig { - /// Interval between server-initiated pings (`None` disables pings) - pub ping_interval: Option, - /// Close the socket after this long without any inbound frame - /// (`None` disables the idle timeout) - pub idle_timeout: Option, -} - -impl Default for KeepaliveConfig { - fn default() -> Self { - Self { - ping_interval: Some(Duration::from_secs(30)), - idle_timeout: Some(Duration::from_secs(90)), - } - } -} +pub use config::{ + AuthRevalidation, DEFAULT_AUTH_REVALIDATION_INTERVAL, KeepaliveConfig, PermissionChangePolicy, +}; +pub use contract::MessageHandler; +pub(crate) use io::AxumWebSocketIo; +pub use io::{WebSocketIo, WebSocketIoMessage}; /// WebSocket connection handler that manages the message flow pub struct WebSocketHandler { diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/mod.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/mod.rs index b1978c8..773e3ae 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/mod.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-server/src/handler/tests/mod.rs @@ -1,6 +1,9 @@ use super::*; use crate::connection::ChannelMessageSender; +use async_trait::async_trait; +use ras_auth_core::AuthProvider; use ras_jsonrpc_bidirectional_types::ConnectionId; +use ras_jsonrpc_types::JsonRpcResponse; use std::collections::VecDeque; use std::sync::Mutex; diff --git a/documentation/reviews/refactor-progress.md b/documentation/reviews/refactor-progress.md index d77c7cf..58cff19 100644 --- a/documentation/reviews/refactor-progress.md +++ b/documentation/reviews/refactor-progress.md @@ -20,3 +20,4 @@ REST baseline: 61/61 passed. | 4 | JSON-RPC builder, HTTP envelope/auth policy, method/version dispatch | 59 tests; doctests; Clippy; all macro feature modes; native-server and WASM-client consumer builds. | | 5 | Shared explorer assets crate | Original template SHA-256 preserved; 120 macro tests; docs/Clippy/features; 11/11 browser tests (baseline also 11/11); asset and macro packages created offline, unpacked macro builds pass using local dependency patches. | | 6 | WebSocket subscription policy/accounting and handler test organization | 112 server/macro tests including all 29 moved handler tests; docs and Clippy; chat server consumer build. Checked mutation and egress checks unchanged. | +| 7 | WebSocket handler contract, socket IO, and lifecycle configuration | 112 server/macro tests; docs and Clippy. Public handler paths re-export moved types; connection loop remains together. | From 1e3948145ce81663750265b871d69516797ecbe3 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 11:22:13 +0200 Subject: [PATCH 08/35] refactor(explorer): separate embedded source responsibilities --- .../src/assets/body.html | 108 ++ .../src/assets/bootstrap.js | 17 + .../src/assets/events.js | 117 ++ .../src/assets/explorer.css | 434 +++++ .../src/assets/forms.js | 132 ++ .../src/assets/head.html | 7 + .../src/assets/markdown.js | 122 ++ .../src/assets/navigation.js | 116 ++ .../src/assets/requests.js | 117 ++ .../src/assets/schema-model.js | 48 + .../src/assets/schema-render.js | 82 + .../src/assets/specs.js | 61 + .../src/assets/state.js | 57 + .../src/assets/storage.js | 32 + .../src/assets/tail.html | 3 + .../specs/ras-api-explorer-assets/src/lib.rs | 19 +- .../ras-api-explorer-assets/src/template.html | 1451 ----------------- documentation/reviews/refactor-progress.md | 1 + 18 files changed, 1472 insertions(+), 1452 deletions(-) create mode 100644 crates/specs/ras-api-explorer-assets/src/assets/body.html create mode 100644 crates/specs/ras-api-explorer-assets/src/assets/bootstrap.js create mode 100644 crates/specs/ras-api-explorer-assets/src/assets/events.js create mode 100644 crates/specs/ras-api-explorer-assets/src/assets/explorer.css create mode 100644 crates/specs/ras-api-explorer-assets/src/assets/forms.js create mode 100644 crates/specs/ras-api-explorer-assets/src/assets/head.html create mode 100644 crates/specs/ras-api-explorer-assets/src/assets/markdown.js create mode 100644 crates/specs/ras-api-explorer-assets/src/assets/navigation.js create mode 100644 crates/specs/ras-api-explorer-assets/src/assets/requests.js create mode 100644 crates/specs/ras-api-explorer-assets/src/assets/schema-model.js create mode 100644 crates/specs/ras-api-explorer-assets/src/assets/schema-render.js create mode 100644 crates/specs/ras-api-explorer-assets/src/assets/specs.js create mode 100644 crates/specs/ras-api-explorer-assets/src/assets/state.js create mode 100644 crates/specs/ras-api-explorer-assets/src/assets/storage.js create mode 100644 crates/specs/ras-api-explorer-assets/src/assets/tail.html delete mode 100644 crates/specs/ras-api-explorer-assets/src/template.html diff --git a/crates/specs/ras-api-explorer-assets/src/assets/body.html b/crates/specs/ras-api-explorer-assets/src/assets/body.html new file mode 100644 index 0000000..ebf273d --- /dev/null +++ b/crates/specs/ras-api-explorer-assets/src/assets/body.html @@ -0,0 +1,108 @@ + + + +
+ + +
+
+
+

Select an operation

+
Choose an operation to prepare a request.
+
+
+ + +
+
+
+
+
+ Request + +
+
+
No operation selected.
+
+
+
+
+ Saved requests + +
+
+
+
+
+ + +
+
+ + + + + + diff --git a/crates/specs/ras-api-explorer-assets/src/lib.rs b/crates/specs/ras-api-explorer-assets/src/lib.rs index f2b9fee..aba0496 100644 --- a/crates/specs/ras-api-explorer-assets/src/lib.rs +++ b/crates/specs/ras-api-explorer-assets/src/lib.rs @@ -2,4 +2,21 @@ /// Self-contained explorer HTML. Replace `{EXPLORER_CONFIG_JSON}` with JSON /// whose `<` characters are escaped to keep it inside the configuration script. -pub const TEMPLATE: &str = include_str!("template.html"); +/// Source order preserves one script scope; event binding runs after all helpers. +pub const TEMPLATE: &str = concat!( + include_str!("assets/head.html"), + include_str!("assets/explorer.css"), + include_str!("assets/body.html"), + include_str!("assets/bootstrap.js"), + include_str!("assets/storage.js"), + include_str!("assets/schema-model.js"), + include_str!("assets/markdown.js"), + include_str!("assets/schema-render.js"), + include_str!("assets/specs.js"), + include_str!("assets/state.js"), + include_str!("assets/navigation.js"), + include_str!("assets/forms.js"), + include_str!("assets/requests.js"), + include_str!("assets/events.js"), + include_str!("assets/tail.html"), +); diff --git a/crates/specs/ras-api-explorer-assets/src/template.html b/crates/specs/ras-api-explorer-assets/src/template.html deleted file mode 100644 index c944bd7..0000000 --- a/crates/specs/ras-api-explorer-assets/src/template.html +++ /dev/null @@ -1,1451 +0,0 @@ - - - - - - API Explorer - - - -
- - -
-
-
-

Select an operation

-
Choose an operation to prepare a request.
-
-
- - -
-
-
-
-
- Request - -
-
-
No operation selected.
-
-
-
-
- Saved requests - -
-
-
-
-
- - -
-
- - - - - - diff --git a/documentation/reviews/refactor-progress.md b/documentation/reviews/refactor-progress.md index 58cff19..230d554 100644 --- a/documentation/reviews/refactor-progress.md +++ b/documentation/reviews/refactor-progress.md @@ -21,3 +21,4 @@ REST baseline: 61/61 passed. | 5 | Shared explorer assets crate | Original template SHA-256 preserved; 120 macro tests; docs/Clippy/features; 11/11 browser tests (baseline also 11/11); asset and macro packages created offline, unpacked macro builds pass using local dependency patches. | | 6 | WebSocket subscription policy/accounting and handler test organization | 112 server/macro tests including all 29 moved handler tests; docs and Clippy; chat server consumer build. Checked mutation and egress checks unchanged. | | 7 | WebSocket handler contract, socket IO, and lifecycle configuration | 112 server/macro tests; docs and Clippy. Public handler paths re-export moved types; connection loop remains together. | +| 8 | Explorer markup, styles, rendering, state, and request assets | Assembled HTML remains byte-identical (60,172 bytes); 120 tests; docs/Clippy/macro features; 11 browser tests; packaged and unpacked macro builds. | From 7886e5284bd6db70bbc41b5a1465b567783c2a2a Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 11:24:39 +0200 Subject: [PATCH 09/35] refactor(file): separate server generation responsibilities --- crates/rest/ras-file-macro/src/server.rs | 1083 ----------------- crates/rest/ras-file-macro/src/server/auth.rs | 65 + .../ras-file-macro/src/server/download.rs | 72 ++ crates/rest/ras-file-macro/src/server/mod.rs | 213 ++++ .../rest/ras-file-macro/src/server/routes.rs | 107 ++ .../rest/ras-file-macro/src/server/types.rs | 219 ++++ .../rest/ras-file-macro/src/server/upload.rs | 446 +++++++ documentation/reviews/refactor-progress.md | 1 + 8 files changed, 1123 insertions(+), 1083 deletions(-) delete mode 100644 crates/rest/ras-file-macro/src/server.rs create mode 100644 crates/rest/ras-file-macro/src/server/auth.rs create mode 100644 crates/rest/ras-file-macro/src/server/download.rs create mode 100644 crates/rest/ras-file-macro/src/server/mod.rs create mode 100644 crates/rest/ras-file-macro/src/server/routes.rs create mode 100644 crates/rest/ras-file-macro/src/server/types.rs create mode 100644 crates/rest/ras-file-macro/src/server/upload.rs diff --git a/crates/rest/ras-file-macro/src/server.rs b/crates/rest/ras-file-macro/src/server.rs deleted file mode 100644 index eb42de9..0000000 --- a/crates/rest/ras-file-macro/src/server.rs +++ /dev/null @@ -1,1083 +0,0 @@ -use proc_macro2::{Ident, TokenStream}; -use quote::{format_ident, quote}; - -use crate::parser::{ - AuthRequirement, Endpoint, FileServiceDefinition, FilenamePolicy, MaxBytes, Operation, - PathParam, UploadConfig, UploadPart, UploadPartKind, -}; - -pub fn generate_server(definition: &FileServiceDefinition) -> TokenStream { - let service_name = &definition.service_name; - let base_path = &definition.base_path; - - let trait_name = format_ident!("{}Trait", service_name); - let builder_name = format_ident!("{}Builder", service_name); - let error_name = format_ident!("{}FileError", service_name); - - let support_types = generate_support_types(definition); - let trait_methods = generate_trait_methods(definition, &trait_name); - let handler_functions = generate_handlers(definition, &trait_name); - let router_construction = generate_router_construction(&definition.endpoints, base_path); - - quote! { - pub type #error_name = ::ras_file_core::FileError; - - #support_types - - #[async_trait::async_trait] - pub trait #trait_name: Send + Sync + 'static { - #trait_methods - } - - pub struct #builder_name { - service: S, - auth_provider: Option, - auth_transport: ::ras_auth_core::AuthTransportConfig, - usage_tracker: Option>, - duration_tracker: Option>, - } - - impl #builder_name - where - S: #trait_name + Send + Sync + 'static, - A: ::ras_auth_core::AuthProvider + Clone + Send + Sync + 'static, - { - pub fn new(service: S) -> Self { - Self { - service, - auth_provider: None, - auth_transport: ::ras_auth_core::AuthTransportConfig::default(), - usage_tracker: None, - duration_tracker: None, - } - } - - pub fn auth_provider(mut self, provider: A) -> Self { - self.auth_provider = Some(provider); - self - } - - pub fn auth_cookie(mut self, cookie: ::ras_auth_core::AuthCookieConfig) -> Self { - self.auth_transport.cookie = Some(cookie); - if self.auth_transport.csrf.is_none() { - self.auth_transport.csrf = Some(::ras_auth_core::CsrfConfig::default()); - } - self - } - - pub fn auth_transport(mut self, transport: ::ras_auth_core::AuthTransportConfig) -> Self { - self.auth_transport = transport; - self - } - - pub fn csrf_protection(mut self, csrf: ::ras_auth_core::CsrfConfig) -> Self { - self.auth_transport.csrf = Some(csrf); - self - } - - pub fn with_usage_tracker(mut self, tracker: F) -> Self - where - F: Fn(&::axum::http::HeaderMap, &str, &str) + Send + Sync + 'static, - { - self.usage_tracker = Some(Box::new(tracker)); - self - } - - pub fn with_duration_tracker(mut self, tracker: F) -> Self - where - F: Fn(&str, &str, std::time::Duration) + Send + Sync + 'static, - { - self.duration_tracker = Some(Box::new(tracker)); - self - } - - pub fn build(self) -> ::axum::Router { - use ::axum::routing::{get, post}; - - self.auth_transport - .validate() - .expect("invalid auth transport configuration"); - - let service = ::std::sync::Arc::new(self.service); - let auth_provider = self.auth_provider.map(::std::sync::Arc::new); - let auth_transport = self.auth_transport; - let usage_tracker = self.usage_tracker.map(::std::sync::Arc::new); - let duration_tracker = self.duration_tracker.map(::std::sync::Arc::new); - - #router_construction - } - } - - fn __ras_file_error_response(error: ::ras_file_core::FileError) -> ::axum::response::Response { - use ::axum::response::IntoResponse; - let status = error.status(); - let message = error.client_message(); - ( - status, - ::axum::Json(::serde_json::json!({ "error": message })), - ).into_response() - } - - /// Map a multipart parse error to a `FileError`. The axum detail - /// (which can echo field names and parser state) is logged at `warn` - /// server-side; the client receives a fixed generic message. - fn __ras_file_multipart_error(error: ::axum::extract::multipart::MultipartError) -> ::ras_file_core::FileError { - if error.status() == ::axum::http::StatusCode::PAYLOAD_TOO_LARGE { - ::ras_file_core::FileError::PayloadTooLarge - } else { - ::ras_file_core::tracing::warn!( - status = error.status().as_u16(), - detail = %::ras_file_core::sanitize_log_detail(&error.body_text()), - "rejected request: invalid multipart body" - ); - ::ras_file_core::FileError::bad_request("invalid multipart body") - } - } - - fn __ras_file_download_response(response: ::ras_file_core::DownloadResponse) -> ::axum::response::Response { - use ::axum::response::IntoResponse; - let mut builder = ::axum::response::Response::builder().status(response.status); - let headers = builder.headers_mut().expect("response builder is valid before body"); - for (name, value) in response.headers.iter() { - headers.insert(name.clone(), value.clone()); - } - - let body = match response.body { - ::ras_file_core::DownloadBody::Empty => ::axum::body::Body::empty(), - ::ras_file_core::DownloadBody::Bytes(bytes) => ::axum::body::Body::from(bytes), - ::ras_file_core::DownloadBody::Stream(stream) => ::axum::body::Body::from_stream(stream), - }; - - builder - .body(body) - .unwrap_or_else(|_| { - ( - ::axum::http::StatusCode::INTERNAL_SERVER_ERROR, - "failed to build file response", - ).into_response() - }) - } - - async fn __ras_read_field_bytes( - mut field: ::axum::extract::multipart::Field<'_>, - max_bytes: u64, - remaining_total: Option, - ) -> ::ras_file_core::FileResult<::ras_file_core::bytes::Bytes> { - let mut bytes = Vec::new(); - - while let Some(chunk) = field.chunk().await.map_err(__ras_file_multipart_error)? { - let next_len = bytes - .len() - .checked_add(chunk.len()) - .ok_or(::ras_file_core::FileError::PayloadTooLarge)?; - - if next_len as u64 > max_bytes { - return Err(::ras_file_core::FileError::PayloadTooLarge); - } - - if let Some(remaining_total) = remaining_total { - if next_len as u64 > remaining_total { - return Err(::ras_file_core::FileError::PayloadTooLarge); - } - } - - bytes.extend_from_slice(&chunk); - } - - Ok(::ras_file_core::bytes::Bytes::from(bytes)) - } - - #handler_functions - } -} - -fn generate_support_types(definition: &FileServiceDefinition) -> TokenStream { - let support = definition.endpoints.iter().flat_map(|endpoint| { - let path_struct = path_struct_name(&definition.service_name, endpoint); - let path_fields = endpoint.path_params.iter().map(|param| { - let name = ¶m.name; - let ty = ¶m.ty; - quote! { pub #name: #ty } - }); - - let mut tokens = vec![quote! { - #[derive(Debug, Clone)] - pub struct #path_struct { - #(#path_fields),* - } - }]; - - if let Operation::Upload { config, .. } = &endpoint.operation { - let part_enum = part_enum_name(&definition.service_name, endpoint); - let has_file_part = config - .parts - .iter() - .any(|part| part.kind == UploadPartKind::File); - let variants = config.parts.iter().map(|part| { - let variant = part_variant_name(part); - match part.kind { - UploadPartKind::File => quote! { #variant(::ras_file_core::IncomingFile<'a>) }, - UploadPartKind::Json => { - let ty = part.ty.as_ref().expect("json part type"); - quote! { #variant(#ty) } - } - UploadPartKind::Text => quote! { #variant(String) }, - } - }); - let lifetime_variant = if has_file_part { - quote! {} - } else { - quote! { #[doc(hidden)] __Lifetime(std::marker::PhantomData<&'a ()>), } - }; - - let consumed_arms = config.parts.iter().map(|part| { - let variant = part_variant_name(part); - match part.kind { - UploadPartKind::File => quote! { Self::#variant(file) => file.is_finished() }, - UploadPartKind::Json | UploadPartKind::Text => { - quote! { Self::#variant(_) => true } - } - } - }); - let lifetime_consumed_arm = if has_file_part { - quote! {} - } else { - quote! { Self::__Lifetime(_) => true, } - }; - - let bytes_arms = config.parts.iter().map(|part| { - let variant = part_variant_name(part); - match part.kind { - UploadPartKind::File => quote! { Self::#variant(file) => file.bytes_read() }, - UploadPartKind::Json | UploadPartKind::Text => { - quote! { Self::#variant(_) => 0 } - } - } - }); - let lifetime_bytes_arm = if has_file_part { - quote! {} - } else { - quote! { Self::__Lifetime(_) => 0, } - }; - - tokens.push(quote! { - pub enum #part_enum<'a> { - #lifetime_variant - #(#variants),* - } - - impl #part_enum<'_> { - pub fn is_consumed(&self) -> bool { - match self { - #lifetime_consumed_arm - #(#consumed_arms),* - } - } - - pub fn bytes_read(&self) -> u64 { - match self { - #lifetime_bytes_arm - #(#bytes_arms),* - } - } - } - }); - } - - tokens - }); - - quote! { #(#support)* } -} - -fn generate_trait_methods(definition: &FileServiceDefinition, _trait_name: &Ident) -> TokenStream { - let methods = definition.endpoints.iter().map(|endpoint| { - let path_struct = path_struct_name(&definition.service_name, endpoint); - let handler_name = &endpoint.name; - - match &endpoint.operation { - Operation::Upload { response_type, .. } => { - let state_type = upload_state_type_name(endpoint); - let begin = format_ident!("{}_begin", handler_name); - let part = format_ident!("{}_part", handler_name); - let finish = format_ident!("{}_finish", handler_name); - let abort = format_ident!("{}_abort", handler_name); - let part_enum = part_enum_name(&definition.service_name, endpoint); - - quote! { - type #state_type: Send; - - async fn #begin( - &self, - ctx: &::ras_file_core::FileRequestContext<'_>, - path: &#path_struct, - ) -> ::ras_file_core::FileResult; - - async fn #part( - &self, - ctx: &::ras_file_core::FileRequestContext<'_>, - path: &#path_struct, - state: &mut Self::#state_type, - part: &mut #part_enum<'_>, - ) -> ::ras_file_core::FileResult<()>; - - async fn #finish( - &self, - ctx: &::ras_file_core::FileRequestContext<'_>, - path: &#path_struct, - state: Self::#state_type, - summary: ::ras_file_core::UploadSummary, - ) -> ::ras_file_core::FileResult<::ras_file_core::JsonResponse<#response_type>>; - - async fn #abort( - &self, - _ctx: &::ras_file_core::FileRequestContext<'_>, - _path: &#path_struct, - _state: Self::#state_type, - _error: &::ras_file_core::FileError, - ) { - } - } - } - Operation::Download { .. } => { - quote! { - async fn #handler_name( - &self, - ctx: &::ras_file_core::FileRequestContext<'_>, - path: #path_struct, - ) -> ::ras_file_core::FileResult<::ras_file_core::DownloadResponse>; - } - } - } - }); - - quote! { #(#methods)* } -} - -fn generate_handlers(definition: &FileServiceDefinition, trait_name: &Ident) -> TokenStream { - definition - .endpoints - .iter() - .map(|endpoint| match &endpoint.operation { - Operation::Upload { config, .. } => { - generate_upload_handler(definition, endpoint, config, trait_name) - } - Operation::Download { .. } => { - generate_download_handler(definition, endpoint, trait_name) - } - }) - .collect() -} - -fn generate_upload_handler( - definition: &FileServiceDefinition, - endpoint: &Endpoint, - config: &UploadConfig, - trait_name: &Ident, -) -> TokenStream { - let handler_fn = format_ident!("{}_handler", endpoint.name); - let begin = format_ident!("{}_begin", endpoint.name); - let part_method = format_ident!("{}_part", endpoint.name); - let finish = format_ident!("{}_finish", endpoint.name); - let abort = format_ident!("{}_abort", endpoint.name); - let path = endpoint.path.value(); - let path_struct = path_struct_name(&definition.service_name, endpoint); - let part_enum = part_enum_name(&definition.service_name, endpoint); - let auth = generate_auth_check(&endpoint.auth); - let permission_check = generate_permission_check(&endpoint.auth); - let path_extraction = generate_path_extraction(&endpoint.path_params, &path_struct); - let content_length_limit = match &config.max_total_bytes { - MaxBytes::Limited(limit) => quote! { - if let Some(content_length) = parts.headers - .get(::axum::http::header::CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()) - { - if content_length > #limit { - return __ras_file_error_response(::ras_file_core::FileError::PayloadTooLarge); - } - } - }, - MaxBytes::Unlimited => quote! {}, - }; - let max_total_limit = match &config.max_total_bytes { - MaxBytes::Limited(limit) => quote! { Some(#limit as u64) }, - MaxBytes::Unlimited => quote! { None }, - }; - let part_dispatch = generate_part_dispatch(config, &part_enum, &part_method, &abort); - let required_checks = generate_required_checks(config, &abort); - let part_count_vars = config.parts.iter().map(|part| { - let count_ident = part_count_ident(part); - quote! { let mut #count_ident: usize = 0; } - }); - - quote! { - async fn #handler_fn( - state: ::axum::extract::State<( - ::std::sync::Arc, - Option<::std::sync::Arc>, - Option<::std::sync::Arc>>, - Option<::std::sync::Arc>>, - ::ras_auth_core::AuthTransportConfig, - )>, - req: ::axum::http::Request<::axum::body::Body>, - ) -> ::axum::response::Response - where - S: #trait_name + Send + Sync + 'static, - A: ::ras_auth_core::AuthProvider + Send + Sync + 'static, - { - use ::axum::extract::FromRequest; - use ::axum::response::IntoResponse; - - let start = std::time::Instant::now(); - let method = "POST"; - let request_path = req.uri().path().to_string(); - let (mut parts, body) = req.into_parts(); - - if let Some(tracker) = &state.2 { - let tracker_headers = - ::ras_auth_core::redact_sensitive_headers_for_auth_transport(&parts.headers, &state.4); - tracker(&tracker_headers, method, &request_path); - } - - #auth - #permission_check - #path_extraction - - #content_length_limit - - let content_type = parts.headers - .get(::axum::http::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or(""); - if !content_type.starts_with("multipart/form-data") { - return __ras_file_error_response(::ras_file_core::FileError::unsupported_media_type( - "expected multipart/form-data", - )); - } - - let request_headers = parts.headers.clone(); - let ctx = ::ras_file_core::FileRequestContext::new( - method, - &request_path, - #path, - &request_headers, - user.as_ref(), - ); - - let req = ::axum::http::Request::from_parts(parts, body); - let mut multipart = match <::axum::extract::Multipart as FromRequest<_>>::from_request(req, &state).await { - Ok(multipart) => multipart, - Err(rejection) => { - // Never echo the axum rejection body (it can include the - // offending header value); log it and send a fixed message. - ::ras_file_core::tracing::warn!( - status = rejection.status().as_u16(), - detail = %::ras_file_core::sanitize_log_detail(&rejection.body_text()), - "rejected request: invalid multipart request" - ); - return __ras_file_error_response( - ::ras_file_core::FileError::bad_request("invalid multipart request"), - ); - } - }; - - let service = &state.0.0; - let mut upload_state = Some(match service.#begin(&ctx, &path_value).await { - Ok(upload_state) => upload_state, - Err(error) => return __ras_file_error_response(error), - }); - - let mut summary = ::ras_file_core::UploadSummary::default(); - let mut total_bytes: u64 = 0; - let max_total_bytes: Option = #max_total_limit; - #(#part_count_vars)* - - while let Some(mut field) = match multipart.next_field().await { - Ok(field) => field, - Err(error) => { - let error = __ras_file_multipart_error(error); - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - } { - let field_name = field.name().unwrap_or("").to_string(); - #part_dispatch - } - - #required_checks - - let upload_state = upload_state.take().expect("upload state is present before finish"); - let response = match service.#finish(&ctx, &path_value, upload_state, summary).await { - Ok(response) => response, - Err(error) => return __ras_file_error_response(error), - }; - - if let Some(tracker) = &state.3 { - tracker(method, &request_path, start.elapsed()); - } - - let (status, headers, body) = response.into_parts(); - let mut response = (status, ::axum::Json(body)).into_response(); - response.headers_mut().extend(headers); - response - } - } -} - -fn generate_part_dispatch( - config: &UploadConfig, - part_enum: &Ident, - part_method: &Ident, - abort: &Ident, -) -> TokenStream { - let arms = config - .parts - .iter() - .map(|part| generate_part_arm(part, part_enum, part_method, abort)); - let unknown = if config.reject_unknown_fields { - quote! { - { - let error = ::ras_file_core::FileError::bad_request(format!("unknown multipart field `{}`", field_name)); - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - } - } else { - quote! { - { - let mut ignored_bytes: u64 = 0; - loop { - let maybe_chunk = match field.chunk().await { - Ok(chunk) => chunk, - Err(error) => { - let error = __ras_file_multipart_error(error); - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - }; - - let Some(chunk) = maybe_chunk else { - break; - }; - - ignored_bytes = ignored_bytes.saturating_add(chunk.len() as u64); - if let Some(max_total) = max_total_bytes { - if total_bytes.saturating_add(ignored_bytes) > max_total { - let error = ::ras_file_core::FileError::PayloadTooLarge; - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - } - } - total_bytes = total_bytes.saturating_add(ignored_bytes); - } - } - }; - - quote! { - match field_name.as_str() { - #(#arms,)* - _ => #unknown, - } - } -} - -fn generate_part_arm( - part: &UploadPart, - part_enum: &Ident, - part_method: &Ident, - abort: &Ident, -) -> TokenStream { - let field_name = part.name.to_string(); - let count_ident = part_count_ident(part); - let max_count = part.max_count; - let max_bytes = part.max_bytes; - let variant = part_variant_name(part); - - let content_type_check = if part.content_types.is_empty() { - quote! {} - } else { - let allowed = part.content_types.iter(); - quote! { - let content_type = field.content_type().unwrap_or("").to_string(); - if ![#(#allowed),*].contains(&content_type.as_str()) { - let error = ::ras_file_core::FileError::unsupported_media_type( - format!("unsupported content type `{}` for field `{}`", content_type, #field_name), - ); - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - } - }; - - let count_check = quote! { - if #count_ident >= #max_count { - let error = ::ras_file_core::FileError::bad_request(format!("too many `{}` parts", #field_name)); - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - #count_ident += 1; - }; - - match part.kind { - UploadPartKind::File => { - let filename_check = match part.filename { - FilenamePolicy::Optional => quote! {}, - FilenamePolicy::Required => quote! { - if field.file_name().is_none() { - let error = ::ras_file_core::FileError::bad_request(format!("field `{}` requires a filename", #field_name)); - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - }, - FilenamePolicy::Forbidden => quote! { - if field.file_name().is_some() { - let error = ::ras_file_core::FileError::bad_request(format!("field `{}` must not include a filename", #field_name)); - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - }, - }; - - quote! { - #field_name => { - #count_check - #content_type_check - #filename_check - - let remaining_total = max_total_bytes - .map(|max| max.saturating_sub(total_bytes)) - .unwrap_or(u64::MAX); - let part_limit = std::cmp::min(#max_bytes as u64, remaining_total); - // Reduce the client-supplied name to a single safe path - // component before the handler ever sees it. - let file_name = field.file_name().map(::ras_file_core::sanitize_filename); - let content_type = field.content_type().map(ToString::to_string); - let headers = field.headers().clone(); - let stream = ::ras_file_core::futures_util::StreamExt::map(field, |chunk| { - chunk.map_err(__ras_file_multipart_error) - }); - let file = ::ras_file_core::IncomingFile::new( - #field_name, - file_name, - content_type, - headers, - part_limit, - Box::pin(stream), - ); - let mut part = #part_enum::#variant(file); - - let part_result = { - let upload_state = upload_state.as_mut().expect("upload state is present while handling parts"); - service.#part_method(&ctx, &path_value, upload_state, &mut part).await - }; - if let Err(error) = part_result { - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - - if !part.is_consumed() { - let error = ::ras_file_core::FileError::handler_contract(format!("handler did not consume file field `{}`", #field_name)); - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - - let bytes_read = part.bytes_read(); - if let Some(max_total) = max_total_bytes { - if total_bytes.saturating_add(bytes_read) > max_total { - let error = ::ras_file_core::FileError::PayloadTooLarge; - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - } - total_bytes = total_bytes.saturating_add(bytes_read); - summary.record(#field_name, bytes_read); - } - } - } - UploadPartKind::Json => { - let ty = part.ty.as_ref().expect("json part type"); - quote! { - #field_name => { - #count_check - #content_type_check - let bytes = match __ras_read_field_bytes(field, #max_bytes as u64, max_total_bytes.map(|max| max.saturating_sub(total_bytes))).await { - Ok(bytes) => bytes, - Err(error) => { - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - }; - let value: #ty = match ::serde_json::from_slice(&bytes) { - Ok(value) => value, - Err(error) => { - let error = ::ras_file_core::FileError::bad_request(format!("invalid JSON in field `{}`: {}", #field_name, error)); - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - }; - let mut part = #part_enum::#variant(value); - let part_result = { - let upload_state = upload_state.as_mut().expect("upload state is present while handling parts"); - service.#part_method(&ctx, &path_value, upload_state, &mut part).await - }; - if let Err(error) = part_result { - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - total_bytes = total_bytes.saturating_add(bytes.len() as u64); - summary.record(#field_name, bytes.len() as u64); - } - } - } - UploadPartKind::Text => { - quote! { - #field_name => { - #count_check - #content_type_check - let bytes = match __ras_read_field_bytes(field, #max_bytes as u64, max_total_bytes.map(|max| max.saturating_sub(total_bytes))).await { - Ok(bytes) => bytes, - Err(error) => { - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - }; - let value = match String::from_utf8(bytes.to_vec()) { - Ok(value) => value, - Err(error) => { - let error = ::ras_file_core::FileError::bad_request(format!("invalid UTF-8 in field `{}`: {}", #field_name, error)); - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - }; - let mut part = #part_enum::#variant(value); - let part_result = { - let upload_state = upload_state.as_mut().expect("upload state is present while handling parts"); - service.#part_method(&ctx, &path_value, upload_state, &mut part).await - }; - if let Err(error) = part_result { - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - total_bytes = total_bytes.saturating_add(bytes.len() as u64); - summary.record(#field_name, bytes.len() as u64); - } - } - } - } -} - -fn generate_required_checks(config: &UploadConfig, abort: &Ident) -> TokenStream { - let checks = config.parts.iter().filter(|part| part.required).map(|part| { - let field_name = part.name.to_string(); - let count_ident = part_count_ident(part); - quote! { - if #count_ident == 0 { - let error = ::ras_file_core::FileError::bad_request(format!("missing required multipart field `{}`", #field_name)); - let upload_state = upload_state.take().expect("upload state is present before abort"); - service.#abort(&ctx, &path_value, upload_state, &error).await; - return __ras_file_error_response(error); - } - } - }); - - quote! { #(#checks)* } -} - -fn generate_download_handler( - definition: &FileServiceDefinition, - endpoint: &Endpoint, - trait_name: &Ident, -) -> TokenStream { - let handler_fn = format_ident!("{}_handler", endpoint.name); - let handler_name = &endpoint.name; - let path = endpoint.path.value(); - let path_struct = path_struct_name(&definition.service_name, endpoint); - let auth = generate_auth_check(&endpoint.auth); - let permission_check = generate_permission_check(&endpoint.auth); - let path_extraction = generate_path_extraction(&endpoint.path_params, &path_struct); - - quote! { - async fn #handler_fn( - state: ::axum::extract::State<( - ::std::sync::Arc, - Option<::std::sync::Arc>, - Option<::std::sync::Arc>>, - Option<::std::sync::Arc>>, - ::ras_auth_core::AuthTransportConfig, - )>, - req: ::axum::http::Request<::axum::body::Body>, - ) -> ::axum::response::Response - where - S: #trait_name + Send + Sync + 'static, - A: ::ras_auth_core::AuthProvider + Send + Sync + 'static, - { - let start = std::time::Instant::now(); - let method = "GET"; - let request_path = req.uri().path().to_string(); - let (mut parts, _body) = req.into_parts(); - - if let Some(tracker) = &state.2 { - let tracker_headers = - ::ras_auth_core::redact_sensitive_headers_for_auth_transport(&parts.headers, &state.4); - tracker(&tracker_headers, method, &request_path); - } - - #auth - #permission_check - #path_extraction - - let ctx = ::ras_file_core::FileRequestContext::new( - method, - &request_path, - #path, - &parts.headers, - user.as_ref(), - ); - - let service = &state.0.0; - let response = match service.#handler_name(&ctx, path_value).await { - Ok(response) => response, - Err(error) => return __ras_file_error_response(error), - }; - - if let Some(tracker) = &state.3 { - tracker(method, &request_path, start.elapsed()); - } - - __ras_file_download_response(response) - } - } -} - -fn generate_path_extraction(path_params: &[PathParam], path_struct: &Ident) -> TokenStream { - if path_params.is_empty() { - return quote! { let path_value = #path_struct {}; }; - } - - let fields = path_params.iter().enumerate().map(|(idx, param)| { - let name = ¶m.name; - if path_params.len() == 1 { - quote! { #name: path_params } - } else { - let idx = syn::Index::from(idx); - quote! { #name: path_params.#idx } - } - }); - - let extraction = if path_params.len() == 1 { - let ty = &path_params[0].ty; - quote! { - let ::axum::extract::Path(path_params) = - match <::axum::extract::Path<#ty> as ::axum::extract::FromRequestParts<_>>::from_request_parts(&mut parts, &state).await { - Ok(path) => path, - Err(error) => { - // The axum rejection echoes the offending path value; - // log it server-side and send a fixed message. - ::ras_file_core::tracing::warn!( - status = error.status().as_u16(), - detail = %::ras_file_core::sanitize_log_detail(&error.body_text()), - "rejected request: invalid path parameters" - ); - return __ras_file_error_response(::ras_file_core::FileError::bad_request("invalid path parameters")); - } - }; - } - } else { - let tys = path_params.iter().map(|param| ¶m.ty); - quote! { - let ::axum::extract::Path(path_params) = - match <::axum::extract::Path<(#(#tys),*)> as ::axum::extract::FromRequestParts<_>>::from_request_parts(&mut parts, &state).await { - Ok(path) => path, - Err(error) => { - // The axum rejection echoes the offending path value; - // log it server-side and send a fixed message. - ::ras_file_core::tracing::warn!( - status = error.status().as_u16(), - detail = %::ras_file_core::sanitize_log_detail(&error.body_text()), - "rejected request: invalid path parameters" - ); - return __ras_file_error_response(::ras_file_core::FileError::bad_request("invalid path parameters")); - } - }; - } - }; - - quote! { - #extraction - let path_value = #path_struct { - #(#fields),* - }; - } -} - -fn generate_auth_check(auth: &AuthRequirement) -> TokenStream { - match auth { - AuthRequirement::Unauthorized => quote! { - let user: Option<::ras_auth_core::AuthenticatedUser> = None; - }, - AuthRequirement::OptionalAuth => quote! { - // Best-effort authentication for an OPTIONAL_AUTH file route — never - // rejected. Resolves to None for a missing/invalid credential (or a - // cookie that fails CSRF on an unsafe method), Some(user) otherwise. - // The caller is surfaced through FileRequestContext::new below. - let user: Option<::ras_auth_core::AuthenticatedUser> = - ::ras_auth_core::resolve_caller(method, &parts.headers, &state.4, state.1.as_deref()) - .await - .into_authenticated(); - }, - AuthRequirement::WithPermissions(_) => quote! { - let auth_provider = match state.1.as_ref() { - Some(provider) => provider, - None => return __ras_file_error_response(::ras_file_core::FileError::Internal), - }; - - let auth_credential = match ::ras_auth_core::extract_auth_credential(&parts.headers, &state.4) { - Ok(credential) => credential, - Err(_) => return __ras_file_error_response(::ras_file_core::FileError::Unauthorized), - }; - - if ::ras_auth_core::validate_csrf_for_credential(method, &parts.headers, &auth_credential, &state.4).is_err() { - return __ras_file_error_response(::ras_file_core::FileError::Forbidden); - } - - let user = match auth_provider.authenticate(auth_credential.token().to_string()).await { - Ok(user) => Some(user), - Err(_) => return __ras_file_error_response(::ras_file_core::FileError::Unauthorized), - }; - }, - } -} - -fn generate_permission_check(auth: &AuthRequirement) -> TokenStream { - match auth { - // Public routes (Unauthorized / OptionalAuth) have no permission gate. - AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => quote! {}, - AuthRequirement::WithPermissions(permission_groups) => { - let groups = permission_groups.iter().map(|group| { - let perms = group.iter(); - quote! { vec![#(#perms.to_string()),*] } - }); - - quote! { - // OR-of-AND permission check (shared ras-auth-core implementation). - // A group list with no non-empty groups means "any authenticated - // user", consistent with the REST and JSON-RPC macros. - let required_permission_groups: Vec> = vec![#(#groups),*]; - let authenticated_user = user.as_ref().expect("authenticated user exists after auth check"); - if ::ras_auth_core::check_permission_groups(auth_provider.as_ref(), authenticated_user, &required_permission_groups).is_err() { - return __ras_file_error_response(::ras_file_core::FileError::Forbidden); - } - } - } - } -} - -fn generate_router_construction(endpoints: &[Endpoint], base_path: &syn::LitStr) -> TokenStream { - let routes = endpoints.iter().map(|endpoint| { - let handler_name = format_ident!("{}_handler", endpoint.name); - let path = endpoint.path.value(); - - match &endpoint.operation { - Operation::Upload { config, .. } => { - let limit_layer = match &config.max_total_bytes { - MaxBytes::Limited(limit) => { - let limit = *limit as usize; - quote! { .layer(::axum::extract::DefaultBodyLimit::max(#limit)) } - } - MaxBytes::Unlimited => { - quote! { .layer(::axum::extract::DefaultBodyLimit::disable()) } - } - }; - quote! { - .route(#path, post(#handler_name::)#limit_layer) - } - } - Operation::Download { .. } => quote! { - .route(#path, get(#handler_name::)) - }, - } - }); - - quote! { - ::axum::Router::new() - .nest( - #base_path, - ::axum::Router::new() - #(#routes)* - .with_state((service, auth_provider, usage_tracker, duration_tracker, auth_transport)) - ) - } -} - -fn path_struct_name(service_name: &Ident, endpoint: &Endpoint) -> Ident { - format_ident!( - "{}{}Path", - service_name, - pascal_ident_segment(&endpoint.name.to_string()) - ) -} - -fn part_enum_name(service_name: &Ident, endpoint: &Endpoint) -> Ident { - format_ident!( - "{}{}Part", - service_name, - pascal_ident_segment(&endpoint.name.to_string()) - ) -} - -fn upload_state_type_name(endpoint: &Endpoint) -> Ident { - format_ident!("{}State", pascal_ident_segment(&endpoint.name.to_string())) -} - -pub fn part_variant_name(part: &UploadPart) -> Ident { - format_ident!("{}", pascal_ident_segment(&part.name.to_string())) -} - -fn part_count_ident(part: &UploadPart) -> Ident { - format_ident!("{}_count", part.name) -} - -fn pascal_ident_segment(value: &str) -> String { - let mut out = String::new(); - let mut uppercase_next = true; - - for ch in value.chars() { - if ch.is_ascii_alphanumeric() { - if uppercase_next { - out.push(ch.to_ascii_uppercase()); - uppercase_next = false; - } else { - out.push(ch); - } - } else { - uppercase_next = true; - } - } - - if out.is_empty() { - "Generated".to_string() - } else if out.chars().next().is_some_and(|ch| ch.is_ascii_digit()) { - format!("V{out}") - } else { - out - } -} diff --git a/crates/rest/ras-file-macro/src/server/auth.rs b/crates/rest/ras-file-macro/src/server/auth.rs new file mode 100644 index 0000000..593cf57 --- /dev/null +++ b/crates/rest/ras-file-macro/src/server/auth.rs @@ -0,0 +1,65 @@ +use crate::parser::AuthRequirement; +use proc_macro2::TokenStream; +use quote::quote; + +pub(super) fn generate_auth_check(auth: &AuthRequirement) -> TokenStream { + match auth { + AuthRequirement::Unauthorized => quote! { + let user: Option<::ras_auth_core::AuthenticatedUser> = None; + }, + AuthRequirement::OptionalAuth => quote! { + // Best-effort authentication for an OPTIONAL_AUTH file route — never + // rejected. Resolves to None for a missing/invalid credential (or a + // cookie that fails CSRF on an unsafe method), Some(user) otherwise. + // The caller is surfaced through FileRequestContext::new below. + let user: Option<::ras_auth_core::AuthenticatedUser> = + ::ras_auth_core::resolve_caller(method, &parts.headers, &state.4, state.1.as_deref()) + .await + .into_authenticated(); + }, + AuthRequirement::WithPermissions(_) => quote! { + let auth_provider = match state.1.as_ref() { + Some(provider) => provider, + None => return __ras_file_error_response(::ras_file_core::FileError::Internal), + }; + + let auth_credential = match ::ras_auth_core::extract_auth_credential(&parts.headers, &state.4) { + Ok(credential) => credential, + Err(_) => return __ras_file_error_response(::ras_file_core::FileError::Unauthorized), + }; + + if ::ras_auth_core::validate_csrf_for_credential(method, &parts.headers, &auth_credential, &state.4).is_err() { + return __ras_file_error_response(::ras_file_core::FileError::Forbidden); + } + + let user = match auth_provider.authenticate(auth_credential.token().to_string()).await { + Ok(user) => Some(user), + Err(_) => return __ras_file_error_response(::ras_file_core::FileError::Unauthorized), + }; + }, + } +} + +pub(super) fn generate_permission_check(auth: &AuthRequirement) -> TokenStream { + match auth { + // Public routes (Unauthorized / OptionalAuth) have no permission gate. + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => quote! {}, + AuthRequirement::WithPermissions(permission_groups) => { + let groups = permission_groups.iter().map(|group| { + let perms = group.iter(); + quote! { vec![#(#perms.to_string()),*] } + }); + + quote! { + // OR-of-AND permission check (shared ras-auth-core implementation). + // A group list with no non-empty groups means "any authenticated + // user", consistent with the REST and JSON-RPC macros. + let required_permission_groups: Vec> = vec![#(#groups),*]; + let authenticated_user = user.as_ref().expect("authenticated user exists after auth check"); + if ::ras_auth_core::check_permission_groups(auth_provider.as_ref(), authenticated_user, &required_permission_groups).is_err() { + return __ras_file_error_response(::ras_file_core::FileError::Forbidden); + } + } + } + } +} diff --git a/crates/rest/ras-file-macro/src/server/download.rs b/crates/rest/ras-file-macro/src/server/download.rs new file mode 100644 index 0000000..8df1cf6 --- /dev/null +++ b/crates/rest/ras-file-macro/src/server/download.rs @@ -0,0 +1,72 @@ +use super::auth::{generate_auth_check, generate_permission_check}; +use super::routes::generate_path_extraction; +use super::types::path_struct_name; +use crate::parser::{Endpoint, FileServiceDefinition}; +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; + +pub(super) fn generate_download_handler( + definition: &FileServiceDefinition, + endpoint: &Endpoint, + trait_name: &Ident, +) -> TokenStream { + let handler_fn = format_ident!("{}_handler", endpoint.name); + let handler_name = &endpoint.name; + let path = endpoint.path.value(); + let path_struct = path_struct_name(&definition.service_name, endpoint); + let auth = generate_auth_check(&endpoint.auth); + let permission_check = generate_permission_check(&endpoint.auth); + let path_extraction = generate_path_extraction(&endpoint.path_params, &path_struct); + + quote! { + async fn #handler_fn( + state: ::axum::extract::State<( + ::std::sync::Arc, + Option<::std::sync::Arc>, + Option<::std::sync::Arc>>, + Option<::std::sync::Arc>>, + ::ras_auth_core::AuthTransportConfig, + )>, + req: ::axum::http::Request<::axum::body::Body>, + ) -> ::axum::response::Response + where + S: #trait_name + Send + Sync + 'static, + A: ::ras_auth_core::AuthProvider + Send + Sync + 'static, + { + let start = std::time::Instant::now(); + let method = "GET"; + let request_path = req.uri().path().to_string(); + let (mut parts, _body) = req.into_parts(); + + if let Some(tracker) = &state.2 { + let tracker_headers = + ::ras_auth_core::redact_sensitive_headers_for_auth_transport(&parts.headers, &state.4); + tracker(&tracker_headers, method, &request_path); + } + + #auth + #permission_check + #path_extraction + + let ctx = ::ras_file_core::FileRequestContext::new( + method, + &request_path, + #path, + &parts.headers, + user.as_ref(), + ); + + let service = &state.0.0; + let response = match service.#handler_name(&ctx, path_value).await { + Ok(response) => response, + Err(error) => return __ras_file_error_response(error), + }; + + if let Some(tracker) = &state.3 { + tracker(method, &request_path, start.elapsed()); + } + + __ras_file_download_response(response) + } + } +} diff --git a/crates/rest/ras-file-macro/src/server/mod.rs b/crates/rest/ras-file-macro/src/server/mod.rs new file mode 100644 index 0000000..961cd4d --- /dev/null +++ b/crates/rest/ras-file-macro/src/server/mod.rs @@ -0,0 +1,213 @@ +mod auth; +mod download; +mod routes; +mod types; +mod upload; + +use crate::parser::{FileServiceDefinition, Operation}; +use download::generate_download_handler; +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; +use routes::generate_router_construction; +use types::{generate_support_types, generate_trait_methods}; +use upload::generate_upload_handler; + +pub fn generate_server(definition: &FileServiceDefinition) -> TokenStream { + let service_name = &definition.service_name; + let base_path = &definition.base_path; + + let trait_name = format_ident!("{}Trait", service_name); + let builder_name = format_ident!("{}Builder", service_name); + let error_name = format_ident!("{}FileError", service_name); + + let support_types = generate_support_types(definition); + let trait_methods = generate_trait_methods(definition, &trait_name); + let handler_functions = generate_handlers(definition, &trait_name); + let router_construction = generate_router_construction(&definition.endpoints, base_path); + + quote! { + pub type #error_name = ::ras_file_core::FileError; + + #support_types + + #[async_trait::async_trait] + pub trait #trait_name: Send + Sync + 'static { + #trait_methods + } + + pub struct #builder_name { + service: S, + auth_provider: Option, + auth_transport: ::ras_auth_core::AuthTransportConfig, + usage_tracker: Option>, + duration_tracker: Option>, + } + + impl #builder_name + where + S: #trait_name + Send + Sync + 'static, + A: ::ras_auth_core::AuthProvider + Clone + Send + Sync + 'static, + { + pub fn new(service: S) -> Self { + Self { + service, + auth_provider: None, + auth_transport: ::ras_auth_core::AuthTransportConfig::default(), + usage_tracker: None, + duration_tracker: None, + } + } + + pub fn auth_provider(mut self, provider: A) -> Self { + self.auth_provider = Some(provider); + self + } + + pub fn auth_cookie(mut self, cookie: ::ras_auth_core::AuthCookieConfig) -> Self { + self.auth_transport.cookie = Some(cookie); + if self.auth_transport.csrf.is_none() { + self.auth_transport.csrf = Some(::ras_auth_core::CsrfConfig::default()); + } + self + } + + pub fn auth_transport(mut self, transport: ::ras_auth_core::AuthTransportConfig) -> Self { + self.auth_transport = transport; + self + } + + pub fn csrf_protection(mut self, csrf: ::ras_auth_core::CsrfConfig) -> Self { + self.auth_transport.csrf = Some(csrf); + self + } + + pub fn with_usage_tracker(mut self, tracker: F) -> Self + where + F: Fn(&::axum::http::HeaderMap, &str, &str) + Send + Sync + 'static, + { + self.usage_tracker = Some(Box::new(tracker)); + self + } + + pub fn with_duration_tracker(mut self, tracker: F) -> Self + where + F: Fn(&str, &str, std::time::Duration) + Send + Sync + 'static, + { + self.duration_tracker = Some(Box::new(tracker)); + self + } + + pub fn build(self) -> ::axum::Router { + use ::axum::routing::{get, post}; + + self.auth_transport + .validate() + .expect("invalid auth transport configuration"); + + let service = ::std::sync::Arc::new(self.service); + let auth_provider = self.auth_provider.map(::std::sync::Arc::new); + let auth_transport = self.auth_transport; + let usage_tracker = self.usage_tracker.map(::std::sync::Arc::new); + let duration_tracker = self.duration_tracker.map(::std::sync::Arc::new); + + #router_construction + } + } + + fn __ras_file_error_response(error: ::ras_file_core::FileError) -> ::axum::response::Response { + use ::axum::response::IntoResponse; + let status = error.status(); + let message = error.client_message(); + ( + status, + ::axum::Json(::serde_json::json!({ "error": message })), + ).into_response() + } + + /// Map a multipart parse error to a `FileError`. The axum detail + /// (which can echo field names and parser state) is logged at `warn` + /// server-side; the client receives a fixed generic message. + fn __ras_file_multipart_error(error: ::axum::extract::multipart::MultipartError) -> ::ras_file_core::FileError { + if error.status() == ::axum::http::StatusCode::PAYLOAD_TOO_LARGE { + ::ras_file_core::FileError::PayloadTooLarge + } else { + ::ras_file_core::tracing::warn!( + status = error.status().as_u16(), + detail = %::ras_file_core::sanitize_log_detail(&error.body_text()), + "rejected request: invalid multipart body" + ); + ::ras_file_core::FileError::bad_request("invalid multipart body") + } + } + + fn __ras_file_download_response(response: ::ras_file_core::DownloadResponse) -> ::axum::response::Response { + use ::axum::response::IntoResponse; + let mut builder = ::axum::response::Response::builder().status(response.status); + let headers = builder.headers_mut().expect("response builder is valid before body"); + for (name, value) in response.headers.iter() { + headers.insert(name.clone(), value.clone()); + } + + let body = match response.body { + ::ras_file_core::DownloadBody::Empty => ::axum::body::Body::empty(), + ::ras_file_core::DownloadBody::Bytes(bytes) => ::axum::body::Body::from(bytes), + ::ras_file_core::DownloadBody::Stream(stream) => ::axum::body::Body::from_stream(stream), + }; + + builder + .body(body) + .unwrap_or_else(|_| { + ( + ::axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "failed to build file response", + ).into_response() + }) + } + + async fn __ras_read_field_bytes( + mut field: ::axum::extract::multipart::Field<'_>, + max_bytes: u64, + remaining_total: Option, + ) -> ::ras_file_core::FileResult<::ras_file_core::bytes::Bytes> { + let mut bytes = Vec::new(); + + while let Some(chunk) = field.chunk().await.map_err(__ras_file_multipart_error)? { + let next_len = bytes + .len() + .checked_add(chunk.len()) + .ok_or(::ras_file_core::FileError::PayloadTooLarge)?; + + if next_len as u64 > max_bytes { + return Err(::ras_file_core::FileError::PayloadTooLarge); + } + + if let Some(remaining_total) = remaining_total { + if next_len as u64 > remaining_total { + return Err(::ras_file_core::FileError::PayloadTooLarge); + } + } + + bytes.extend_from_slice(&chunk); + } + + Ok(::ras_file_core::bytes::Bytes::from(bytes)) + } + + #handler_functions + } +} + +fn generate_handlers(definition: &FileServiceDefinition, trait_name: &Ident) -> TokenStream { + definition + .endpoints + .iter() + .map(|endpoint| match &endpoint.operation { + Operation::Upload { config, .. } => { + generate_upload_handler(definition, endpoint, config, trait_name) + } + Operation::Download { .. } => { + generate_download_handler(definition, endpoint, trait_name) + } + }) + .collect() +} diff --git a/crates/rest/ras-file-macro/src/server/routes.rs b/crates/rest/ras-file-macro/src/server/routes.rs new file mode 100644 index 0000000..09c7b2b --- /dev/null +++ b/crates/rest/ras-file-macro/src/server/routes.rs @@ -0,0 +1,107 @@ +use crate::parser::{Endpoint, MaxBytes, Operation, PathParam}; +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; + +pub(super) fn generate_path_extraction( + path_params: &[PathParam], + path_struct: &Ident, +) -> TokenStream { + if path_params.is_empty() { + return quote! { let path_value = #path_struct {}; }; + } + + let fields = path_params.iter().enumerate().map(|(idx, param)| { + let name = ¶m.name; + if path_params.len() == 1 { + quote! { #name: path_params } + } else { + let idx = syn::Index::from(idx); + quote! { #name: path_params.#idx } + } + }); + + let extraction = if path_params.len() == 1 { + let ty = &path_params[0].ty; + quote! { + let ::axum::extract::Path(path_params) = + match <::axum::extract::Path<#ty> as ::axum::extract::FromRequestParts<_>>::from_request_parts(&mut parts, &state).await { + Ok(path) => path, + Err(error) => { + // The axum rejection echoes the offending path value; + // log it server-side and send a fixed message. + ::ras_file_core::tracing::warn!( + status = error.status().as_u16(), + detail = %::ras_file_core::sanitize_log_detail(&error.body_text()), + "rejected request: invalid path parameters" + ); + return __ras_file_error_response(::ras_file_core::FileError::bad_request("invalid path parameters")); + } + }; + } + } else { + let tys = path_params.iter().map(|param| ¶m.ty); + quote! { + let ::axum::extract::Path(path_params) = + match <::axum::extract::Path<(#(#tys),*)> as ::axum::extract::FromRequestParts<_>>::from_request_parts(&mut parts, &state).await { + Ok(path) => path, + Err(error) => { + // The axum rejection echoes the offending path value; + // log it server-side and send a fixed message. + ::ras_file_core::tracing::warn!( + status = error.status().as_u16(), + detail = %::ras_file_core::sanitize_log_detail(&error.body_text()), + "rejected request: invalid path parameters" + ); + return __ras_file_error_response(::ras_file_core::FileError::bad_request("invalid path parameters")); + } + }; + } + }; + + quote! { + #extraction + let path_value = #path_struct { + #(#fields),* + }; + } +} + +pub(super) fn generate_router_construction( + endpoints: &[Endpoint], + base_path: &syn::LitStr, +) -> TokenStream { + let routes = endpoints.iter().map(|endpoint| { + let handler_name = format_ident!("{}_handler", endpoint.name); + let path = endpoint.path.value(); + + match &endpoint.operation { + Operation::Upload { config, .. } => { + let limit_layer = match &config.max_total_bytes { + MaxBytes::Limited(limit) => { + let limit = *limit as usize; + quote! { .layer(::axum::extract::DefaultBodyLimit::max(#limit)) } + } + MaxBytes::Unlimited => { + quote! { .layer(::axum::extract::DefaultBodyLimit::disable()) } + } + }; + quote! { + .route(#path, post(#handler_name::)#limit_layer) + } + } + Operation::Download { .. } => quote! { + .route(#path, get(#handler_name::)) + }, + } + }); + + quote! { + ::axum::Router::new() + .nest( + #base_path, + ::axum::Router::new() + #(#routes)* + .with_state((service, auth_provider, usage_tracker, duration_tracker, auth_transport)) + ) + } +} diff --git a/crates/rest/ras-file-macro/src/server/types.rs b/crates/rest/ras-file-macro/src/server/types.rs new file mode 100644 index 0000000..93585a8 --- /dev/null +++ b/crates/rest/ras-file-macro/src/server/types.rs @@ -0,0 +1,219 @@ +use crate::parser::{Endpoint, FileServiceDefinition, Operation, UploadPart, UploadPartKind}; +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; + +pub(super) fn generate_support_types(definition: &FileServiceDefinition) -> TokenStream { + let support = definition.endpoints.iter().flat_map(|endpoint| { + let path_struct = path_struct_name(&definition.service_name, endpoint); + let path_fields = endpoint.path_params.iter().map(|param| { + let name = ¶m.name; + let ty = ¶m.ty; + quote! { pub #name: #ty } + }); + + let mut tokens = vec![quote! { + #[derive(Debug, Clone)] + pub struct #path_struct { + #(#path_fields),* + } + }]; + + if let Operation::Upload { config, .. } = &endpoint.operation { + let part_enum = part_enum_name(&definition.service_name, endpoint); + let has_file_part = config + .parts + .iter() + .any(|part| part.kind == UploadPartKind::File); + let variants = config.parts.iter().map(|part| { + let variant = part_variant_name(part); + match part.kind { + UploadPartKind::File => quote! { #variant(::ras_file_core::IncomingFile<'a>) }, + UploadPartKind::Json => { + let ty = part.ty.as_ref().expect("json part type"); + quote! { #variant(#ty) } + } + UploadPartKind::Text => quote! { #variant(String) }, + } + }); + let lifetime_variant = if has_file_part { + quote! {} + } else { + quote! { #[doc(hidden)] __Lifetime(std::marker::PhantomData<&'a ()>), } + }; + + let consumed_arms = config.parts.iter().map(|part| { + let variant = part_variant_name(part); + match part.kind { + UploadPartKind::File => quote! { Self::#variant(file) => file.is_finished() }, + UploadPartKind::Json | UploadPartKind::Text => { + quote! { Self::#variant(_) => true } + } + } + }); + let lifetime_consumed_arm = if has_file_part { + quote! {} + } else { + quote! { Self::__Lifetime(_) => true, } + }; + + let bytes_arms = config.parts.iter().map(|part| { + let variant = part_variant_name(part); + match part.kind { + UploadPartKind::File => quote! { Self::#variant(file) => file.bytes_read() }, + UploadPartKind::Json | UploadPartKind::Text => { + quote! { Self::#variant(_) => 0 } + } + } + }); + let lifetime_bytes_arm = if has_file_part { + quote! {} + } else { + quote! { Self::__Lifetime(_) => 0, } + }; + + tokens.push(quote! { + pub enum #part_enum<'a> { + #lifetime_variant + #(#variants),* + } + + impl #part_enum<'_> { + pub fn is_consumed(&self) -> bool { + match self { + #lifetime_consumed_arm + #(#consumed_arms),* + } + } + + pub fn bytes_read(&self) -> u64 { + match self { + #lifetime_bytes_arm + #(#bytes_arms),* + } + } + } + }); + } + + tokens + }); + + quote! { #(#support)* } +} + +pub(super) fn generate_trait_methods( + definition: &FileServiceDefinition, + _trait_name: &Ident, +) -> TokenStream { + let methods = definition.endpoints.iter().map(|endpoint| { + let path_struct = path_struct_name(&definition.service_name, endpoint); + let handler_name = &endpoint.name; + + match &endpoint.operation { + Operation::Upload { response_type, .. } => { + let state_type = upload_state_type_name(endpoint); + let begin = format_ident!("{}_begin", handler_name); + let part = format_ident!("{}_part", handler_name); + let finish = format_ident!("{}_finish", handler_name); + let abort = format_ident!("{}_abort", handler_name); + let part_enum = part_enum_name(&definition.service_name, endpoint); + + quote! { + type #state_type: Send; + + async fn #begin( + &self, + ctx: &::ras_file_core::FileRequestContext<'_>, + path: &#path_struct, + ) -> ::ras_file_core::FileResult; + + async fn #part( + &self, + ctx: &::ras_file_core::FileRequestContext<'_>, + path: &#path_struct, + state: &mut Self::#state_type, + part: &mut #part_enum<'_>, + ) -> ::ras_file_core::FileResult<()>; + + async fn #finish( + &self, + ctx: &::ras_file_core::FileRequestContext<'_>, + path: &#path_struct, + state: Self::#state_type, + summary: ::ras_file_core::UploadSummary, + ) -> ::ras_file_core::FileResult<::ras_file_core::JsonResponse<#response_type>>; + + async fn #abort( + &self, + _ctx: &::ras_file_core::FileRequestContext<'_>, + _path: &#path_struct, + _state: Self::#state_type, + _error: &::ras_file_core::FileError, + ) { + } + } + } + Operation::Download { .. } => { + quote! { + async fn #handler_name( + &self, + ctx: &::ras_file_core::FileRequestContext<'_>, + path: #path_struct, + ) -> ::ras_file_core::FileResult<::ras_file_core::DownloadResponse>; + } + } + } + }); + + quote! { #(#methods)* } +} + +pub(super) fn path_struct_name(service_name: &Ident, endpoint: &Endpoint) -> Ident { + format_ident!( + "{}{}Path", + service_name, + pascal_ident_segment(&endpoint.name.to_string()) + ) +} + +pub(super) fn part_enum_name(service_name: &Ident, endpoint: &Endpoint) -> Ident { + format_ident!( + "{}{}Part", + service_name, + pascal_ident_segment(&endpoint.name.to_string()) + ) +} + +fn upload_state_type_name(endpoint: &Endpoint) -> Ident { + format_ident!("{}State", pascal_ident_segment(&endpoint.name.to_string())) +} + +pub(super) fn part_variant_name(part: &UploadPart) -> Ident { + format_ident!("{}", pascal_ident_segment(&part.name.to_string())) +} + +fn pascal_ident_segment(value: &str) -> String { + let mut out = String::new(); + let mut uppercase_next = true; + + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + if uppercase_next { + out.push(ch.to_ascii_uppercase()); + uppercase_next = false; + } else { + out.push(ch); + } + } else { + uppercase_next = true; + } + } + + if out.is_empty() { + "Generated".to_string() + } else if out.chars().next().is_some_and(|ch| ch.is_ascii_digit()) { + format!("V{out}") + } else { + out + } +} diff --git a/crates/rest/ras-file-macro/src/server/upload.rs b/crates/rest/ras-file-macro/src/server/upload.rs new file mode 100644 index 0000000..1eb0240 --- /dev/null +++ b/crates/rest/ras-file-macro/src/server/upload.rs @@ -0,0 +1,446 @@ +use super::auth::{generate_auth_check, generate_permission_check}; +use super::routes::generate_path_extraction; +use super::types::{part_enum_name, part_variant_name, path_struct_name}; +use crate::parser::{ + Endpoint, FileServiceDefinition, FilenamePolicy, MaxBytes, UploadConfig, UploadPart, + UploadPartKind, +}; +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; + +pub(super) fn generate_upload_handler( + definition: &FileServiceDefinition, + endpoint: &Endpoint, + config: &UploadConfig, + trait_name: &Ident, +) -> TokenStream { + let handler_fn = format_ident!("{}_handler", endpoint.name); + let begin = format_ident!("{}_begin", endpoint.name); + let part_method = format_ident!("{}_part", endpoint.name); + let finish = format_ident!("{}_finish", endpoint.name); + let abort = format_ident!("{}_abort", endpoint.name); + let path = endpoint.path.value(); + let path_struct = path_struct_name(&definition.service_name, endpoint); + let part_enum = part_enum_name(&definition.service_name, endpoint); + let auth = generate_auth_check(&endpoint.auth); + let permission_check = generate_permission_check(&endpoint.auth); + let path_extraction = generate_path_extraction(&endpoint.path_params, &path_struct); + let content_length_limit = match &config.max_total_bytes { + MaxBytes::Limited(limit) => quote! { + if let Some(content_length) = parts.headers + .get(::axum::http::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + { + if content_length > #limit { + return __ras_file_error_response(::ras_file_core::FileError::PayloadTooLarge); + } + } + }, + MaxBytes::Unlimited => quote! {}, + }; + let max_total_limit = match &config.max_total_bytes { + MaxBytes::Limited(limit) => quote! { Some(#limit as u64) }, + MaxBytes::Unlimited => quote! { None }, + }; + let part_dispatch = generate_part_dispatch(config, &part_enum, &part_method, &abort); + let required_checks = generate_required_checks(config, &abort); + let part_count_vars = config.parts.iter().map(|part| { + let count_ident = part_count_ident(part); + quote! { let mut #count_ident: usize = 0; } + }); + + quote! { + async fn #handler_fn( + state: ::axum::extract::State<( + ::std::sync::Arc, + Option<::std::sync::Arc>, + Option<::std::sync::Arc>>, + Option<::std::sync::Arc>>, + ::ras_auth_core::AuthTransportConfig, + )>, + req: ::axum::http::Request<::axum::body::Body>, + ) -> ::axum::response::Response + where + S: #trait_name + Send + Sync + 'static, + A: ::ras_auth_core::AuthProvider + Send + Sync + 'static, + { + use ::axum::extract::FromRequest; + use ::axum::response::IntoResponse; + + let start = std::time::Instant::now(); + let method = "POST"; + let request_path = req.uri().path().to_string(); + let (mut parts, body) = req.into_parts(); + + if let Some(tracker) = &state.2 { + let tracker_headers = + ::ras_auth_core::redact_sensitive_headers_for_auth_transport(&parts.headers, &state.4); + tracker(&tracker_headers, method, &request_path); + } + + #auth + #permission_check + #path_extraction + + #content_length_limit + + let content_type = parts.headers + .get(::axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + if !content_type.starts_with("multipart/form-data") { + return __ras_file_error_response(::ras_file_core::FileError::unsupported_media_type( + "expected multipart/form-data", + )); + } + + let request_headers = parts.headers.clone(); + let ctx = ::ras_file_core::FileRequestContext::new( + method, + &request_path, + #path, + &request_headers, + user.as_ref(), + ); + + let req = ::axum::http::Request::from_parts(parts, body); + let mut multipart = match <::axum::extract::Multipart as FromRequest<_>>::from_request(req, &state).await { + Ok(multipart) => multipart, + Err(rejection) => { + // Never echo the axum rejection body (it can include the + // offending header value); log it and send a fixed message. + ::ras_file_core::tracing::warn!( + status = rejection.status().as_u16(), + detail = %::ras_file_core::sanitize_log_detail(&rejection.body_text()), + "rejected request: invalid multipart request" + ); + return __ras_file_error_response( + ::ras_file_core::FileError::bad_request("invalid multipart request"), + ); + } + }; + + let service = &state.0.0; + let mut upload_state = Some(match service.#begin(&ctx, &path_value).await { + Ok(upload_state) => upload_state, + Err(error) => return __ras_file_error_response(error), + }); + + let mut summary = ::ras_file_core::UploadSummary::default(); + let mut total_bytes: u64 = 0; + let max_total_bytes: Option = #max_total_limit; + #(#part_count_vars)* + + while let Some(mut field) = match multipart.next_field().await { + Ok(field) => field, + Err(error) => { + let error = __ras_file_multipart_error(error); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + } { + let field_name = field.name().unwrap_or("").to_string(); + #part_dispatch + } + + #required_checks + + let upload_state = upload_state.take().expect("upload state is present before finish"); + let response = match service.#finish(&ctx, &path_value, upload_state, summary).await { + Ok(response) => response, + Err(error) => return __ras_file_error_response(error), + }; + + if let Some(tracker) = &state.3 { + tracker(method, &request_path, start.elapsed()); + } + + let (status, headers, body) = response.into_parts(); + let mut response = (status, ::axum::Json(body)).into_response(); + response.headers_mut().extend(headers); + response + } + } +} + +fn generate_part_dispatch( + config: &UploadConfig, + part_enum: &Ident, + part_method: &Ident, + abort: &Ident, +) -> TokenStream { + let arms = config + .parts + .iter() + .map(|part| generate_part_arm(part, part_enum, part_method, abort)); + let unknown = if config.reject_unknown_fields { + quote! { + { + let error = ::ras_file_core::FileError::bad_request(format!("unknown multipart field `{}`", field_name)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + } + } else { + quote! { + { + let mut ignored_bytes: u64 = 0; + loop { + let maybe_chunk = match field.chunk().await { + Ok(chunk) => chunk, + Err(error) => { + let error = __ras_file_multipart_error(error); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + }; + + let Some(chunk) = maybe_chunk else { + break; + }; + + ignored_bytes = ignored_bytes.saturating_add(chunk.len() as u64); + if let Some(max_total) = max_total_bytes { + if total_bytes.saturating_add(ignored_bytes) > max_total { + let error = ::ras_file_core::FileError::PayloadTooLarge; + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + } + } + total_bytes = total_bytes.saturating_add(ignored_bytes); + } + } + }; + + quote! { + match field_name.as_str() { + #(#arms,)* + _ => #unknown, + } + } +} + +fn generate_part_arm( + part: &UploadPart, + part_enum: &Ident, + part_method: &Ident, + abort: &Ident, +) -> TokenStream { + let field_name = part.name.to_string(); + let count_ident = part_count_ident(part); + let max_count = part.max_count; + let max_bytes = part.max_bytes; + let variant = part_variant_name(part); + + let content_type_check = if part.content_types.is_empty() { + quote! {} + } else { + let allowed = part.content_types.iter(); + quote! { + let content_type = field.content_type().unwrap_or("").to_string(); + if ![#(#allowed),*].contains(&content_type.as_str()) { + let error = ::ras_file_core::FileError::unsupported_media_type( + format!("unsupported content type `{}` for field `{}`", content_type, #field_name), + ); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + } + }; + + let count_check = quote! { + if #count_ident >= #max_count { + let error = ::ras_file_core::FileError::bad_request(format!("too many `{}` parts", #field_name)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + #count_ident += 1; + }; + + match part.kind { + UploadPartKind::File => { + let filename_check = match part.filename { + FilenamePolicy::Optional => quote! {}, + FilenamePolicy::Required => quote! { + if field.file_name().is_none() { + let error = ::ras_file_core::FileError::bad_request(format!("field `{}` requires a filename", #field_name)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + }, + FilenamePolicy::Forbidden => quote! { + if field.file_name().is_some() { + let error = ::ras_file_core::FileError::bad_request(format!("field `{}` must not include a filename", #field_name)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + }, + }; + + quote! { + #field_name => { + #count_check + #content_type_check + #filename_check + + let remaining_total = max_total_bytes + .map(|max| max.saturating_sub(total_bytes)) + .unwrap_or(u64::MAX); + let part_limit = std::cmp::min(#max_bytes as u64, remaining_total); + // Reduce the client-supplied name to a single safe path + // component before the handler ever sees it. + let file_name = field.file_name().map(::ras_file_core::sanitize_filename); + let content_type = field.content_type().map(ToString::to_string); + let headers = field.headers().clone(); + let stream = ::ras_file_core::futures_util::StreamExt::map(field, |chunk| { + chunk.map_err(__ras_file_multipart_error) + }); + let file = ::ras_file_core::IncomingFile::new( + #field_name, + file_name, + content_type, + headers, + part_limit, + Box::pin(stream), + ); + let mut part = #part_enum::#variant(file); + + let part_result = { + let upload_state = upload_state.as_mut().expect("upload state is present while handling parts"); + service.#part_method(&ctx, &path_value, upload_state, &mut part).await + }; + if let Err(error) = part_result { + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + + if !part.is_consumed() { + let error = ::ras_file_core::FileError::handler_contract(format!("handler did not consume file field `{}`", #field_name)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + + let bytes_read = part.bytes_read(); + if let Some(max_total) = max_total_bytes { + if total_bytes.saturating_add(bytes_read) > max_total { + let error = ::ras_file_core::FileError::PayloadTooLarge; + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + } + total_bytes = total_bytes.saturating_add(bytes_read); + summary.record(#field_name, bytes_read); + } + } + } + UploadPartKind::Json => { + let ty = part.ty.as_ref().expect("json part type"); + quote! { + #field_name => { + #count_check + #content_type_check + let bytes = match __ras_read_field_bytes(field, #max_bytes as u64, max_total_bytes.map(|max| max.saturating_sub(total_bytes))).await { + Ok(bytes) => bytes, + Err(error) => { + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + }; + let value: #ty = match ::serde_json::from_slice(&bytes) { + Ok(value) => value, + Err(error) => { + let error = ::ras_file_core::FileError::bad_request(format!("invalid JSON in field `{}`: {}", #field_name, error)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + }; + let mut part = #part_enum::#variant(value); + let part_result = { + let upload_state = upload_state.as_mut().expect("upload state is present while handling parts"); + service.#part_method(&ctx, &path_value, upload_state, &mut part).await + }; + if let Err(error) = part_result { + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + total_bytes = total_bytes.saturating_add(bytes.len() as u64); + summary.record(#field_name, bytes.len() as u64); + } + } + } + UploadPartKind::Text => { + quote! { + #field_name => { + #count_check + #content_type_check + let bytes = match __ras_read_field_bytes(field, #max_bytes as u64, max_total_bytes.map(|max| max.saturating_sub(total_bytes))).await { + Ok(bytes) => bytes, + Err(error) => { + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + }; + let value = match String::from_utf8(bytes.to_vec()) { + Ok(value) => value, + Err(error) => { + let error = ::ras_file_core::FileError::bad_request(format!("invalid UTF-8 in field `{}`: {}", #field_name, error)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + }; + let mut part = #part_enum::#variant(value); + let part_result = { + let upload_state = upload_state.as_mut().expect("upload state is present while handling parts"); + service.#part_method(&ctx, &path_value, upload_state, &mut part).await + }; + if let Err(error) = part_result { + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + total_bytes = total_bytes.saturating_add(bytes.len() as u64); + summary.record(#field_name, bytes.len() as u64); + } + } + } + } +} + +fn generate_required_checks(config: &UploadConfig, abort: &Ident) -> TokenStream { + let checks = config.parts.iter().filter(|part| part.required).map(|part| { + let field_name = part.name.to_string(); + let count_ident = part_count_ident(part); + quote! { + if #count_ident == 0 { + let error = ::ras_file_core::FileError::bad_request(format!("missing required multipart field `{}`", #field_name)); + let upload_state = upload_state.take().expect("upload state is present before abort"); + service.#abort(&ctx, &path_value, upload_state, &error).await; + return __ras_file_error_response(error); + } + } + }); + + quote! { #(#checks)* } +} + +fn part_count_ident(part: &UploadPart) -> Ident { + format_ident!("{}_count", part.name) +} diff --git a/documentation/reviews/refactor-progress.md b/documentation/reviews/refactor-progress.md index 230d554..542a147 100644 --- a/documentation/reviews/refactor-progress.md +++ b/documentation/reviews/refactor-progress.md @@ -22,3 +22,4 @@ REST baseline: 61/61 passed. | 6 | WebSocket subscription policy/accounting and handler test organization | 112 server/macro tests including all 29 moved handler tests; docs and Clippy; chat server consumer build. Checked mutation and egress checks unchanged. | | 7 | WebSocket handler contract, socket IO, and lifecycle configuration | 112 server/macro tests; docs and Clippy. Public handler paths re-export moved types; connection loop remains together. | | 8 | Explorer markup, styles, rendering, state, and request assets | Assembled HTML remains byte-identical (60,172 bytes); 120 tests; docs/Clippy/macro features; 11 browser tests; packaged and unpacked macro builds. | +| 9 | File-service types, uploads, downloads, routes, and auth generation | Baseline and result: 49 tests; docs/Clippy; no-default/server/client macro checks; native and WASM API consumer checks. | From c99b62c1bec5acda06fef13de7143ec85d8b73b1 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 11:28:12 +0200 Subject: [PATCH 10/35] refactor(rest): separate OpenAPI schema and operation emission --- crates/rest/ras-rest-macro/src/openapi.rs | 775 ------------------ crates/rest/ras-rest-macro/src/openapi/mod.rs | 136 +++ .../ras-rest-macro/src/openapi/operations.rs | 312 +++++++ .../rest/ras-rest-macro/src/openapi/schema.rs | 376 +++++++++ documentation/reviews/refactor-progress.md | 1 + 5 files changed, 825 insertions(+), 775 deletions(-) delete mode 100644 crates/rest/ras-rest-macro/src/openapi.rs create mode 100644 crates/rest/ras-rest-macro/src/openapi/mod.rs create mode 100644 crates/rest/ras-rest-macro/src/openapi/operations.rs create mode 100644 crates/rest/ras-rest-macro/src/openapi/schema.rs diff --git a/crates/rest/ras-rest-macro/src/openapi.rs b/crates/rest/ras-rest-macro/src/openapi.rs deleted file mode 100644 index 9575815..0000000 --- a/crates/rest/ras-rest-macro/src/openapi.rs +++ /dev/null @@ -1,775 +0,0 @@ -//! OpenAPI 3.0 document generation module -//! -//! This module provides functionality to generate OpenAPI 3.0 specification documents -//! from the rest_service macro definitions. - -use crate::{AuthRequirement, OpenApiConfig, ServiceDefinition}; -use proc_macro2::TokenStream; -use quote::quote; -use std::collections::HashMap; - -/// Generates OpenAPI document creation code -pub fn generate_openapi_code( - service_def: &ServiceDefinition, - config: &OpenApiConfig, -) -> TokenStream { - let service_name = &service_def.service_name; - let openapi_fn_name = quote::format_ident!( - "generate_{}_openapi", - service_name.to_string().to_lowercase() - ); - let openapi_to_file_fn_name = quote::format_ident!( - "generate_{}_openapi_to_file", - service_name.to_string().to_lowercase() - ); - let endpoint_info_struct_name = quote::format_ident!("{}OpenApiEndpointInfo", service_name); - - let output_path_code = match config { - OpenApiConfig::Enabled => { - let service_name_lower = service_name.to_string().to_lowercase(); - quote! { - format!("target/openapi/{}.json", #service_name_lower) - } - } - OpenApiConfig::WithPath(path) => { - quote! { - #path.to_string() - } - } - }; - - let mut unique_types = std::collections::HashMap::new(); - for endpoint in &service_def.endpoints { - if let Some(request_type) = &endpoint.request_type { - let request_type_str = quote!(#request_type).to_string(); - unique_types.insert(request_type_str, quote!(#request_type)); - } - - let response_type = &endpoint.response_type; - let response_type_str = quote!(#response_type).to_string(); - unique_types.insert(response_type_str, quote!(#response_type)); - - for path_param in &endpoint.path_params { - let param_type = &path_param.param_type; - let param_type_str = quote!(#param_type).to_string(); - unique_types.insert(param_type_str, quote!(#param_type)); - } - - for query_param in &endpoint.query_params { - let param_type = &query_param.param_type; - let param_type_str = quote!(#param_type).to_string(); - unique_types.insert(param_type_str, quote!(#param_type)); - } - - for version in &endpoint.versions { - if let Some(request_type) = &version.request_type { - let request_type_str = quote!(#request_type).to_string(); - unique_types.insert(request_type_str, quote!(#request_type)); - } - - let response_type = &version.response_type; - let response_type_str = quote!(#response_type).to_string(); - unique_types.insert(response_type_str, quote!(#response_type)); - - for path_param in &version.path_params { - let param_type = &path_param.param_type; - let param_type_str = quote!(#param_type).to_string(); - unique_types.insert(param_type_str, quote!(#param_type)); - } - - for query_param in &version.query_params { - let param_type = &query_param.param_type; - let param_type_str = quote!(#param_type).to_string(); - unique_types.insert(param_type_str, quote!(#param_type)); - } - } - } - - let sanitize_type_name = |type_name: &str| -> String { - if type_name == "()" { - "Unit".to_string() - } else { - type_name - .replace("::", "_") - .replace("<", "_") - .replace(">", "") - .replace(" ", "") - .replace(",", "_") - .replace("(", "_") - .replace(")", "_") - } - }; - - let schema_fns: Vec = unique_types - .iter() - .map(|(type_name, type_tokens)| { - if type_name == "()" { - quote! {} // Skip unit type, we'll handle it separately - } else { - let sanitized_name = sanitize_type_name(type_name); - let fn_name = quote::format_ident!( - "_generate_schema_for_{}_{}", - service_name.to_string().to_lowercase(), - sanitized_name - ); - quote! { - fn #fn_name() -> serde_json::Value { - let schema = schemars::schema_for!(#type_tokens); - let mut schema_value = serde_json::to_value(&schema).unwrap_or_else(|_| { - serde_json::json!({ - "type": "object", - "description": format!("Schema for {}", #type_name) - }) - }); - - // Post-process schemas for broad OpenAPI explorer compatibility. - normalize_nullable_properties(&mut schema_value); - fix_option_types(&mut schema_value); - schema_value - } - } - } - }) - .collect(); - - let schema_insertions: Vec = unique_types - .keys() - .map(|type_name| { - if type_name == "()" { - quote! { - schemas.insert("Unit".to_string(), serde_json::json!({ - "type": "null", - "description": "Unit type (empty response)" - })); - } - } else { - let sanitized_name = sanitize_type_name(type_name); - let fn_name = quote::format_ident!( - "_generate_schema_for_{}_{}", - service_name.to_string().to_lowercase(), - sanitized_name - ); - quote! { - schemas.insert(#sanitized_name.to_string(), #fn_name()); - } - } - }) - .collect(); - - let endpoint_infos: Vec = service_def - .endpoints - .iter() - .flat_map(|endpoint| { - let method = endpoint.method.as_str(); - let path = &endpoint.path; - let canonical_version = endpoint.version.clone(); - let canonical_version_tokens = match &canonical_version { - Some(version) => quote! { Some(#version.to_string()) }, - None => quote! { None }, - }; - let (summary, description) = match &endpoint.docs { - Some(docs) => { - let summary = &docs.summary; - let description = &docs.description; - ( - quote! { Some(#summary.to_string()) }, - quote! { Some(#description.to_string()) }, - ) - } - None => (quote! { None }, quote! { None }), - }; - let auth_required = matches!(endpoint.auth, AuthRequirement::WithPermissions(_)); - // OPTIONAL_AUTH advertises an *optional* security requirement. - let auth_optional = matches!(endpoint.auth, AuthRequirement::OptionalAuth); - let permissions = match &endpoint.auth { - AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], - AuthRequirement::WithPermissions(groups) => { - groups.iter().flatten().cloned().collect() - } - }; - let permission_groups = permission_groups_for_spec(&endpoint.auth); - let permission_groups_tokens = permission_groups_tokens(&permission_groups); - - let request_type_name = if let Some(request_type) = &endpoint.request_type { - sanitize_type_name("e!(#request_type).to_string()) - } else { - "Unit".to_string() - }; - - let response_type = &endpoint.response_type; - let response_type_name = if quote!(#response_type).to_string() == "()" { - "Unit".to_string() - } else { - sanitize_type_name("e!(#response_type).to_string()) - }; - let path_param_infos: Vec = endpoint - .path_params - .iter() - .map(|param| { - let param_name = param.name.to_string(); - let param_type = ¶m.param_type; - let param_type_str = sanitize_type_name("e!(#param_type).to_string()); - quote! { - (#param_name.to_string(), #param_type_str.to_string()) - } - }) - .collect(); - - let query_param_infos: Vec = endpoint - .query_params - .iter() - .map(|param| { - let param_name = param.name.to_string(); - let param_type = ¶m.param_type; - let param_type_str = sanitize_type_name("e!(#param_type).to_string()); - quote! { - (#param_name.to_string(), #param_type_str.to_string()) - } - }) - .collect(); - - let mut infos = vec![quote! { - #endpoint_info_struct_name { - method: #method.to_string(), - path: #path.to_string(), - summary: #summary, - description: #description, - auth_required: #auth_required, - auth_optional: #auth_optional, - permissions: vec![#(#permissions.to_string()),*], - permission_groups: #permission_groups_tokens, - request_type_name: #request_type_name.to_string(), - response_type_name: #response_type_name.to_string(), - path_params: vec![#(#path_param_infos),*] as Vec<(String, String)>, - query_params: vec![#(#query_param_infos),*] as Vec<(String, String)>, - version: #canonical_version_tokens, - canonical_version: #canonical_version_tokens, - canonical_path: #path.to_string(), - } - }]; - - infos.extend(endpoint.versions.iter().map(|version| { - let path = &version.path; - let version_label = &version.version; - let canonical_version = canonical_version - .clone() - .unwrap_or_else(|| "current".to_string()); - let canonical_path = endpoint.path.clone(); - let request_type_name = if let Some(request_type) = &version.request_type { - sanitize_type_name("e!(#request_type).to_string()) - } else { - "Unit".to_string() - }; - let response_type = &version.response_type; - let response_type_name = if quote!(#response_type).to_string() == "()" { - "Unit".to_string() - } else { - sanitize_type_name("e!(#response_type).to_string()) - }; - let path_param_infos: Vec = version - .path_params - .iter() - .map(|param| { - let param_name = param.name.to_string(); - let param_type = ¶m.param_type; - let param_type_str = sanitize_type_name("e!(#param_type).to_string()); - quote! { - (#param_name.to_string(), #param_type_str.to_string()) - } - }) - .collect(); - let query_param_infos: Vec = version - .query_params - .iter() - .map(|param| { - let param_name = param.name.to_string(); - let param_type = ¶m.param_type; - let param_type_str = sanitize_type_name("e!(#param_type).to_string()); - quote! { - (#param_name.to_string(), #param_type_str.to_string()) - } - }) - .collect(); - let permissions = permissions.clone(); - let permission_groups_tokens = permission_groups_tokens.clone(); - let summary = summary.clone(); - let description = description.clone(); - - quote! { - #endpoint_info_struct_name { - method: #method.to_string(), - path: #path.to_string(), - summary: #summary, - description: #description, - auth_required: #auth_required, - auth_optional: #auth_optional, - permissions: vec![#(#permissions.to_string()),*], - permission_groups: #permission_groups_tokens, - request_type_name: #request_type_name.to_string(), - response_type_name: #response_type_name.to_string(), - path_params: vec![#(#path_param_infos),*] as Vec<(String, String)>, - query_params: vec![#(#query_param_infos),*] as Vec<(String, String)>, - version: Some(#version_label.to_string()), - canonical_version: Some(#canonical_version.to_string()), - canonical_path: #canonical_path.to_string(), - } - } - })); - - infos - }) - .collect(); - - quote! { - #[derive(serde::Serialize)] - struct #endpoint_info_struct_name { - method: String, - path: String, - summary: Option, - description: Option, - auth_required: bool, - auth_optional: bool, - permissions: Vec, - permission_groups: Vec>, - request_type_name: String, - response_type_name: String, - path_params: Vec<(String, String)>, // (name, type) - query_params: Vec<(String, String)>, // (name, type) - version: Option, - canonical_version: Option, - canonical_path: String, - } - - // Helper function to fix schema references and flatten nested definitions - fn fix_schema_refs(value: &mut serde_json::Value, schemas: &mut serde_json::Map) { - match value { - serde_json::Value::Object(obj) => { - if let Some(defs) = obj.remove("definitions") { - if let serde_json::Value::Object(defs_obj) = defs { - for (name, schema) in defs_obj { - let mut schema_copy = schema.clone(); - fix_schema_refs(&mut schema_copy, schemas); - schemas.insert(name, schema_copy); - } - } - } - - if let Some(defs) = obj.remove("$defs") { - if let serde_json::Value::Object(defs_obj) = defs { - for (name, schema) in defs_obj { - let mut schema_copy = schema.clone(); - fix_schema_refs(&mut schema_copy, schemas); - schemas.insert(name, schema_copy); - } - } - } - - if let Some(ref_val) = obj.get_mut("$ref") { - if let serde_json::Value::String(ref_str) = ref_val { - if ref_str.starts_with("#/definitions/") { - let name = ref_str.trim_start_matches("#/definitions/"); - *ref_str = format!("#/components/schemas/{}", name); - } else if ref_str.starts_with("#/$defs/") { - let name = ref_str.trim_start_matches("#/$defs/"); - *ref_str = format!("#/components/schemas/{}", name); - } - } - } - - // Remove $schema field as it's not needed in OpenAPI - obj.remove("$schema"); - - for (_, v) in obj.iter_mut() { - fix_schema_refs(v, schemas); - } - } - serde_json::Value::Array(arr) => { - for item in arr.iter_mut() { - fix_schema_refs(item, schemas); - } - } - _ => {} - } - } - - // Helper function to normalize nullable properties for better OpenAPI explorer compatibility. - fn normalize_nullable_properties(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(obj) => { - if let Some(properties) = obj.get_mut("properties") { - if let serde_json::Value::Object(props) = properties { - for (_, prop_value) in props.iter_mut() { - if let serde_json::Value::Object(prop_obj) = prop_value { - if let Some(type_val) = prop_obj.get("type") { - if let serde_json::Value::Array(type_array) = type_val { - if type_array.len() == 2 { - let null_value = serde_json::Value::String("null".to_string()); - if type_array.contains(&null_value) { - let non_null_type = type_array.iter() - .find(|t| **t != null_value) - .cloned(); - - if let Some(actual_type) = non_null_type { - prop_obj.insert("type".to_string(), actual_type); - prop_obj.insert("nullable".to_string(), serde_json::Value::Bool(true)); - } - } - } - } - } - } - normalize_nullable_properties(prop_value); - } - } - } - - if let Some(definitions) = obj.get_mut("definitions") { - normalize_nullable_properties(definitions); - } - - for (_, v) in obj.iter_mut() { - normalize_nullable_properties(v); - } - } - serde_json::Value::Array(arr) => { - for item in arr.iter_mut() { - normalize_nullable_properties(item); - } - } - _ => {} - } - } - - // Helper function to fix Option types that use anyOf with null or type arrays - fn fix_option_types(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(obj) => { - if let Some(type_val) = obj.get("type") { - if let serde_json::Value::Array(type_array) = type_val { - if type_array.len() == 2 { - let null_value = serde_json::Value::String("null".to_string()); - if type_array.contains(&null_value) { - let non_null_type = type_array.iter() - .find(|t| **t != null_value) - .cloned(); - - if let Some(actual_type) = non_null_type { - obj.insert("type".to_string(), actual_type); - obj.insert("nullable".to_string(), serde_json::Value::Bool(true)); - } - } - } - } - } - - if let Some(any_of) = obj.get_mut("anyOf") { - if let serde_json::Value::Array(any_of_array) = any_of { - if any_of_array.len() == 2 { - let has_null = any_of_array.iter().any(|item| { - if let serde_json::Value::Object(item_obj) = item { - if let Some(type_val) = item_obj.get("type") { - if let serde_json::Value::String(type_str) = type_val { - return type_str == "null"; - } - } - } - false - }); - - if has_null { - let non_null_schema = any_of_array.iter().find(|item| { - if let serde_json::Value::Object(item_obj) = item { - if let Some(type_val) = item_obj.get("type") { - if let serde_json::Value::String(type_str) = type_val { - return type_str != "null"; - } - } - // If it has other properties besides type, it's not the null schema - return item_obj.len() > 1 || !item_obj.contains_key("type"); - } - true - }).cloned(); - - if let Some(schema) = non_null_schema { - obj.remove("anyOf"); - if let serde_json::Value::Object(schema_obj) = schema { - for (key, val) in schema_obj { - obj.insert(key, val); - } - } - obj.insert("nullable".to_string(), serde_json::Value::Bool(true)); - } - } - } - } - } - - for (_, v) in obj.iter_mut() { - fix_option_types(v); - } - } - serde_json::Value::Array(arr) => { - for item in arr.iter_mut() { - fix_option_types(item); - } - } - _ => {} - } - } - - #(#schema_fns)* - - /// Generate OpenAPI 3.0 document for this service - pub fn #openapi_fn_name() -> serde_json::Value { - use serde_json::json; - use schemars::{schema_for, JsonSchema}; - use std::collections::HashMap; - - let endpoints: Vec<#endpoint_info_struct_name> = vec![ - #(#endpoint_infos),* - ]; - - let mut schemas = HashMap::new(); - - #(#schema_insertions)* - - let mut final_schemas = serde_json::Map::new(); - for (name, mut schema) in schemas { - fix_schema_refs(&mut schema, &mut final_schemas); - fix_option_types(&mut schema); - final_schemas.insert(name, schema); - } - - let mut paths = serde_json::Map::new(); - - for endpoint in &endpoints { - let path_item = paths.entry(endpoint.path.clone()).or_insert_with(|| json!({})); - - let method_lower = endpoint.method.to_lowercase(); - let operation_summary = endpoint - .summary - .clone() - .unwrap_or_else(|| format!("{} {}", endpoint.method, endpoint.path)); - let operation_description = endpoint - .description - .clone() - .unwrap_or_else(|| format!("Handles {} requests to {}", endpoint.method, endpoint.path)); - let mut operation = json!({ - "summary": operation_summary, - "description": operation_description, - "operationId": format!("{}_{}", method_lower, endpoint.path.replace("/", "_").replace("{", "").replace("}", "").trim_start_matches('_')), - "responses": { - "200": { - "description": "Successful response", - "content": { - "application/json": { - "schema": { - "$ref": format!("#/components/schemas/{}", endpoint.response_type_name) - } - } - } - }, - "400": { - "description": "Bad request" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Forbidden" - }, - "500": { - "description": "Internal server error" - } - } - }); - - let mut parameters = vec![]; - - for (param_name, param_type) in &endpoint.path_params { - parameters.push(json!({ - "name": param_name, - "in": "path", - "required": true, - "description": format!("Path parameter of type {}", param_type), - "schema": { - "$ref": format!("#/components/schemas/{}", param_type) - } - })); - } - - for (param_name, param_type) in &endpoint.query_params { - let is_optional = param_type.starts_with("Option_") || param_type.starts_with("Option<") || param_type.starts_with("Option <"); - parameters.push(json!({ - "name": param_name, - "in": "query", - "required": !is_optional, - "description": format!("Query parameter of type {}", param_type), - "schema": { - "$ref": format!("#/components/schemas/{}", param_type) - } - })); - } - - if !parameters.is_empty() { - operation["parameters"] = json!(parameters); - } - - if let Some(version) = &endpoint.version { - operation["x-ras-version"] = json!(version); - } - - if let Some(canonical_version) = &endpoint.canonical_version { - operation["x-ras-canonical-version"] = json!(canonical_version); - operation["x-ras-canonical-path"] = json!(endpoint.canonical_path); - } - - if endpoint.method != "GET" && endpoint.request_type_name != "Unit" { - operation["requestBody"] = json!({ - "description": "Request body", - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": format!("#/components/schemas/{}", endpoint.request_type_name) - } - } - } - }); - } - - if endpoint.auth_required { - operation["security"] = json!([{ - "bearerAuth": [] - }]); - - if !endpoint.permissions.is_empty() { - operation["x-permissions"] = json!(endpoint.permissions); - } - - if !endpoint.permission_groups.is_empty() { - operation["x-permission-groups"] = json!(endpoint.permission_groups); - } - } else if endpoint.auth_optional { - // OPTIONAL_AUTH: anonymous is acceptable ({}), and a bearer is honoured. - operation["security"] = json!([{}, { "bearerAuth": [] }]); - } - - path_item[method_lower] = operation; - } - - json!({ - "openapi": "3.0.3", - "info": { - "title": format!("{} REST API", stringify!(#service_name)), - "version": "1.0.0", - "description": format!("OpenAPI 3.0 specification for the {} service", stringify!(#service_name)) - }, - "paths": paths, - "components": { - "schemas": final_schemas, - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "description": "Bearer token for authentication" - } - } - } - }) - } - - /// Write OpenAPI document to the target directory - pub fn #openapi_to_file_fn_name() -> std::io::Result<()> { - let doc = #openapi_fn_name(); - let output_path = #output_path_code; - - if let Some(parent) = std::path::Path::new(&output_path).parent() { - std::fs::create_dir_all(parent)?; - } - - let json_string = serde_json::to_string_pretty(&doc)?; - std::fs::write(&output_path, &json_string)?; - - println!("Generated OpenAPI document at: {}", output_path); - - Ok(()) - } - - } -} - -fn permission_groups_for_spec(auth: &AuthRequirement) -> Vec> { - match auth { - AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], - AuthRequirement::WithPermissions(groups) => groups.clone(), - } -} - -fn permission_groups_tokens(groups: &[Vec]) -> TokenStream { - let groups = groups - .iter() - .map(|group| quote! { vec![#(#group.to_string()),*] }); - quote! { vec![#(#groups),*] } -} - -/// Generates code to include schema generation for types when schemars is available -pub fn generate_schema_impl_checks(service_def: &ServiceDefinition) -> TokenStream { - let mut unique_types = HashMap::new(); - - for endpoint in &service_def.endpoints { - if let Some(request_type) = &endpoint.request_type { - unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); - } - - let response_type = &endpoint.response_type; - unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); - - for path_param in &endpoint.path_params { - let param_type = &path_param.param_type; - unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); - } - - for query_param in &endpoint.query_params { - let param_type = &query_param.param_type; - unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); - } - - for version in &endpoint.versions { - if let Some(request_type) = &version.request_type { - unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); - } - - let response_type = &version.response_type; - unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); - - for path_param in &version.path_params { - let param_type = &path_param.param_type; - unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); - } - - for query_param in &version.query_params { - let param_type = &query_param.param_type; - unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); - } - } - } - - let type_checks: Vec = unique_types - .values() - .map(|type_tokens| { - quote! { - const _: () = { - fn _assert_json_schema() {} - fn _check() { - _assert_json_schema::<#type_tokens>(); - } - }; - } - }) - .collect(); - - quote! { - #(#type_checks)* - } -} diff --git a/crates/rest/ras-rest-macro/src/openapi/mod.rs b/crates/rest/ras-rest-macro/src/openapi/mod.rs new file mode 100644 index 0000000..386dbaf --- /dev/null +++ b/crates/rest/ras-rest-macro/src/openapi/mod.rs @@ -0,0 +1,136 @@ +//! OpenAPI 3.0 document generation module +//! +//! This module provides functionality to generate OpenAPI 3.0 specification documents +//! from the rest_service macro definitions. + +use crate::{OpenApiConfig, ServiceDefinition}; +use proc_macro2::TokenStream; +use quote::quote; +mod operations; +mod schema; +pub use schema::generate_schema_impl_checks; + +/// Generates OpenAPI document creation code +pub fn generate_openapi_code( + service_def: &ServiceDefinition, + config: &OpenApiConfig, +) -> TokenStream { + let service_name = &service_def.service_name; + let openapi_fn_name = quote::format_ident!( + "generate_{}_openapi", + service_name.to_string().to_lowercase() + ); + let openapi_to_file_fn_name = quote::format_ident!( + "generate_{}_openapi_to_file", + service_name.to_string().to_lowercase() + ); + let endpoint_info_struct_name = quote::format_ident!("{}OpenApiEndpointInfo", service_name); + + let output_path_code = match config { + OpenApiConfig::Enabled => { + let service_name_lower = service_name.to_string().to_lowercase(); + quote! { + format!("target/openapi/{}.json", #service_name_lower) + } + } + OpenApiConfig::WithPath(path) => { + quote! { + #path.to_string() + } + } + }; + + let unique_types = schema::collect_types(service_def); + let (schema_fns, schema_insertions) = schema::generate_schemas(service_name, &unique_types); + let endpoint_infos = + operations::generate_endpoint_infos(service_def, &endpoint_info_struct_name); + let normalization = schema::generate_normalization(); + let paths = operations::generate_paths(); + + quote! { + #[derive(serde::Serialize)] + struct #endpoint_info_struct_name { + method: String, + path: String, + summary: Option, + description: Option, + auth_required: bool, + auth_optional: bool, + permissions: Vec, + permission_groups: Vec>, + request_type_name: String, + response_type_name: String, + path_params: Vec<(String, String)>, // (name, type) + query_params: Vec<(String, String)>, // (name, type) + version: Option, + canonical_version: Option, + canonical_path: String, + } + + #normalization + + #(#schema_fns)* + + /// Generate OpenAPI 3.0 document for this service + pub fn #openapi_fn_name() -> serde_json::Value { + use serde_json::json; + use schemars::{schema_for, JsonSchema}; + use std::collections::HashMap; + + let endpoints: Vec<#endpoint_info_struct_name> = vec![ + #(#endpoint_infos),* + ]; + + let mut schemas = HashMap::new(); + + #(#schema_insertions)* + + let mut final_schemas = serde_json::Map::new(); + for (name, mut schema) in schemas { + fix_schema_refs(&mut schema, &mut final_schemas); + fix_option_types(&mut schema); + final_schemas.insert(name, schema); + } + + #paths + + json!({ + "openapi": "3.0.3", + "info": { + "title": format!("{} REST API", stringify!(#service_name)), + "version": "1.0.0", + "description": format!("OpenAPI 3.0 specification for the {} service", stringify!(#service_name)) + }, + "paths": paths, + "components": { + "schemas": final_schemas, + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "Bearer token for authentication" + } + } + } + }) + } + + /// Write OpenAPI document to the target directory + pub fn #openapi_to_file_fn_name() -> std::io::Result<()> { + let doc = #openapi_fn_name(); + let output_path = #output_path_code; + + if let Some(parent) = std::path::Path::new(&output_path).parent() { + std::fs::create_dir_all(parent)?; + } + + let json_string = serde_json::to_string_pretty(&doc)?; + std::fs::write(&output_path, &json_string)?; + + println!("Generated OpenAPI document at: {}", output_path); + + Ok(()) + } + + } +} diff --git a/crates/rest/ras-rest-macro/src/openapi/operations.rs b/crates/rest/ras-rest-macro/src/openapi/operations.rs new file mode 100644 index 0000000..ce8327b --- /dev/null +++ b/crates/rest/ras-rest-macro/src/openapi/operations.rs @@ -0,0 +1,312 @@ +use super::schema::sanitize_type_name; +use crate::{AuthRequirement, ServiceDefinition}; +use proc_macro2::{Ident, TokenStream}; +use quote::quote; + +pub(super) fn generate_endpoint_infos( + service_def: &ServiceDefinition, + endpoint_info_struct_name: &Ident, +) -> Vec { + let endpoint_infos: Vec = service_def + .endpoints + .iter() + .flat_map(|endpoint| { + let method = endpoint.method.as_str(); + let path = &endpoint.path; + let canonical_version = endpoint.version.clone(); + let canonical_version_tokens = match &canonical_version { + Some(version) => quote! { Some(#version.to_string()) }, + None => quote! { None }, + }; + let (summary, description) = match &endpoint.docs { + Some(docs) => { + let summary = &docs.summary; + let description = &docs.description; + ( + quote! { Some(#summary.to_string()) }, + quote! { Some(#description.to_string()) }, + ) + } + None => (quote! { None }, quote! { None }), + }; + let auth_required = matches!(endpoint.auth, AuthRequirement::WithPermissions(_)); + // OPTIONAL_AUTH advertises an *optional* security requirement. + let auth_optional = matches!(endpoint.auth, AuthRequirement::OptionalAuth); + let permissions = match &endpoint.auth { + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], + AuthRequirement::WithPermissions(groups) => { + groups.iter().flatten().cloned().collect() + } + }; + let permission_groups = permission_groups_for_spec(&endpoint.auth); + let permission_groups_tokens = permission_groups_tokens(&permission_groups); + + let request_type_name = if let Some(request_type) = &endpoint.request_type { + sanitize_type_name("e!(#request_type).to_string()) + } else { + "Unit".to_string() + }; + + let response_type = &endpoint.response_type; + let response_type_name = if quote!(#response_type).to_string() == "()" { + "Unit".to_string() + } else { + sanitize_type_name("e!(#response_type).to_string()) + }; + let path_param_infos: Vec = endpoint + .path_params + .iter() + .map(|param| { + let param_name = param.name.to_string(); + let param_type = ¶m.param_type; + let param_type_str = sanitize_type_name("e!(#param_type).to_string()); + quote! { + (#param_name.to_string(), #param_type_str.to_string()) + } + }) + .collect(); + + let query_param_infos: Vec = endpoint + .query_params + .iter() + .map(|param| { + let param_name = param.name.to_string(); + let param_type = ¶m.param_type; + let param_type_str = sanitize_type_name("e!(#param_type).to_string()); + quote! { + (#param_name.to_string(), #param_type_str.to_string()) + } + }) + .collect(); + + let mut infos = vec![quote! { + #endpoint_info_struct_name { + method: #method.to_string(), + path: #path.to_string(), + summary: #summary, + description: #description, + auth_required: #auth_required, + auth_optional: #auth_optional, + permissions: vec![#(#permissions.to_string()),*], + permission_groups: #permission_groups_tokens, + request_type_name: #request_type_name.to_string(), + response_type_name: #response_type_name.to_string(), + path_params: vec![#(#path_param_infos),*] as Vec<(String, String)>, + query_params: vec![#(#query_param_infos),*] as Vec<(String, String)>, + version: #canonical_version_tokens, + canonical_version: #canonical_version_tokens, + canonical_path: #path.to_string(), + } + }]; + + infos.extend(endpoint.versions.iter().map(|version| { + let path = &version.path; + let version_label = &version.version; + let canonical_version = canonical_version + .clone() + .unwrap_or_else(|| "current".to_string()); + let canonical_path = endpoint.path.clone(); + let request_type_name = if let Some(request_type) = &version.request_type { + sanitize_type_name("e!(#request_type).to_string()) + } else { + "Unit".to_string() + }; + let response_type = &version.response_type; + let response_type_name = if quote!(#response_type).to_string() == "()" { + "Unit".to_string() + } else { + sanitize_type_name("e!(#response_type).to_string()) + }; + let path_param_infos: Vec = version + .path_params + .iter() + .map(|param| { + let param_name = param.name.to_string(); + let param_type = ¶m.param_type; + let param_type_str = sanitize_type_name("e!(#param_type).to_string()); + quote! { + (#param_name.to_string(), #param_type_str.to_string()) + } + }) + .collect(); + let query_param_infos: Vec = version + .query_params + .iter() + .map(|param| { + let param_name = param.name.to_string(); + let param_type = ¶m.param_type; + let param_type_str = sanitize_type_name("e!(#param_type).to_string()); + quote! { + (#param_name.to_string(), #param_type_str.to_string()) + } + }) + .collect(); + let permissions = permissions.clone(); + let permission_groups_tokens = permission_groups_tokens.clone(); + let summary = summary.clone(); + let description = description.clone(); + + quote! { + #endpoint_info_struct_name { + method: #method.to_string(), + path: #path.to_string(), + summary: #summary, + description: #description, + auth_required: #auth_required, + auth_optional: #auth_optional, + permissions: vec![#(#permissions.to_string()),*], + permission_groups: #permission_groups_tokens, + request_type_name: #request_type_name.to_string(), + response_type_name: #response_type_name.to_string(), + path_params: vec![#(#path_param_infos),*] as Vec<(String, String)>, + query_params: vec![#(#query_param_infos),*] as Vec<(String, String)>, + version: Some(#version_label.to_string()), + canonical_version: Some(#canonical_version.to_string()), + canonical_path: #canonical_path.to_string(), + } + } + })); + + infos + }) + .collect(); + + endpoint_infos +} + +pub(super) fn generate_paths() -> TokenStream { + quote! { + let mut paths = serde_json::Map::new(); + + for endpoint in &endpoints { + let path_item = paths.entry(endpoint.path.clone()).or_insert_with(|| json!({})); + + let method_lower = endpoint.method.to_lowercase(); + let operation_summary = endpoint + .summary + .clone() + .unwrap_or_else(|| format!("{} {}", endpoint.method, endpoint.path)); + let operation_description = endpoint + .description + .clone() + .unwrap_or_else(|| format!("Handles {} requests to {}", endpoint.method, endpoint.path)); + let mut operation = json!({ + "summary": operation_summary, + "description": operation_description, + "operationId": format!("{}_{}", method_lower, endpoint.path.replace("/", "_").replace("{", "").replace("}", "").trim_start_matches('_')), + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": format!("#/components/schemas/{}", endpoint.response_type_name) + } + } + } + }, + "400": { + "description": "Bad request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal server error" + } + } + }); + + let mut parameters = vec![]; + + for (param_name, param_type) in &endpoint.path_params { + parameters.push(json!({ + "name": param_name, + "in": "path", + "required": true, + "description": format!("Path parameter of type {}", param_type), + "schema": { + "$ref": format!("#/components/schemas/{}", param_type) + } + })); + } + + for (param_name, param_type) in &endpoint.query_params { + let is_optional = param_type.starts_with("Option_") || param_type.starts_with("Option<") || param_type.starts_with("Option <"); + parameters.push(json!({ + "name": param_name, + "in": "query", + "required": !is_optional, + "description": format!("Query parameter of type {}", param_type), + "schema": { + "$ref": format!("#/components/schemas/{}", param_type) + } + })); + } + + if !parameters.is_empty() { + operation["parameters"] = json!(parameters); + } + + if let Some(version) = &endpoint.version { + operation["x-ras-version"] = json!(version); + } + + if let Some(canonical_version) = &endpoint.canonical_version { + operation["x-ras-canonical-version"] = json!(canonical_version); + operation["x-ras-canonical-path"] = json!(endpoint.canonical_path); + } + + if endpoint.method != "GET" && endpoint.request_type_name != "Unit" { + operation["requestBody"] = json!({ + "description": "Request body", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": format!("#/components/schemas/{}", endpoint.request_type_name) + } + } + } + }); + } + + if endpoint.auth_required { + operation["security"] = json!([{ + "bearerAuth": [] + }]); + + if !endpoint.permissions.is_empty() { + operation["x-permissions"] = json!(endpoint.permissions); + } + + if !endpoint.permission_groups.is_empty() { + operation["x-permission-groups"] = json!(endpoint.permission_groups); + } + } else if endpoint.auth_optional { + // OPTIONAL_AUTH: anonymous is acceptable ({}), and a bearer is honoured. + operation["security"] = json!([{}, { "bearerAuth": [] }]); + } + + path_item[method_lower] = operation; + } + + } +} + +fn permission_groups_for_spec(auth: &AuthRequirement) -> Vec> { + match auth { + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], + AuthRequirement::WithPermissions(groups) => groups.clone(), + } +} + +fn permission_groups_tokens(groups: &[Vec]) -> TokenStream { + let groups = groups + .iter() + .map(|group| quote! { vec![#(#group.to_string()),*] }); + quote! { vec![#(#groups),*] } +} diff --git a/crates/rest/ras-rest-macro/src/openapi/schema.rs b/crates/rest/ras-rest-macro/src/openapi/schema.rs new file mode 100644 index 0000000..a2cc991 --- /dev/null +++ b/crates/rest/ras-rest-macro/src/openapi/schema.rs @@ -0,0 +1,376 @@ +use crate::ServiceDefinition; +use proc_macro2::{Ident, TokenStream}; +use quote::quote; +use std::collections::HashMap; + +pub(super) fn collect_types(service_def: &ServiceDefinition) -> HashMap { + let mut unique_types = std::collections::HashMap::new(); + for endpoint in &service_def.endpoints { + if let Some(request_type) = &endpoint.request_type { + let request_type_str = quote!(#request_type).to_string(); + unique_types.insert(request_type_str, quote!(#request_type)); + } + + let response_type = &endpoint.response_type; + let response_type_str = quote!(#response_type).to_string(); + unique_types.insert(response_type_str, quote!(#response_type)); + + for path_param in &endpoint.path_params { + let param_type = &path_param.param_type; + let param_type_str = quote!(#param_type).to_string(); + unique_types.insert(param_type_str, quote!(#param_type)); + } + + for query_param in &endpoint.query_params { + let param_type = &query_param.param_type; + let param_type_str = quote!(#param_type).to_string(); + unique_types.insert(param_type_str, quote!(#param_type)); + } + + for version in &endpoint.versions { + if let Some(request_type) = &version.request_type { + let request_type_str = quote!(#request_type).to_string(); + unique_types.insert(request_type_str, quote!(#request_type)); + } + + let response_type = &version.response_type; + let response_type_str = quote!(#response_type).to_string(); + unique_types.insert(response_type_str, quote!(#response_type)); + + for path_param in &version.path_params { + let param_type = &path_param.param_type; + let param_type_str = quote!(#param_type).to_string(); + unique_types.insert(param_type_str, quote!(#param_type)); + } + + for query_param in &version.query_params { + let param_type = &query_param.param_type; + let param_type_str = quote!(#param_type).to_string(); + unique_types.insert(param_type_str, quote!(#param_type)); + } + } + } + + unique_types +} + +pub(super) fn sanitize_type_name(type_name: &str) -> String { + if type_name == "()" { + "Unit".to_string() + } else { + type_name + .replace("::", "_") + .replace("<", "_") + .replace(">", "") + .replace(" ", "") + .replace(",", "_") + .replace("(", "_") + .replace(")", "_") + } +} + +pub(super) fn generate_schemas( + service_name: &Ident, + unique_types: &HashMap, +) -> (Vec, Vec) { + let schema_fns: Vec = unique_types + .iter() + .map(|(type_name, type_tokens)| { + if type_name == "()" { + quote! {} // Skip unit type, we'll handle it separately + } else { + let sanitized_name = sanitize_type_name(type_name); + let fn_name = quote::format_ident!( + "_generate_schema_for_{}_{}", + service_name.to_string().to_lowercase(), + sanitized_name + ); + quote! { + fn #fn_name() -> serde_json::Value { + let schema = schemars::schema_for!(#type_tokens); + let mut schema_value = serde_json::to_value(&schema).unwrap_or_else(|_| { + serde_json::json!({ + "type": "object", + "description": format!("Schema for {}", #type_name) + }) + }); + + // Post-process schemas for broad OpenAPI explorer compatibility. + normalize_nullable_properties(&mut schema_value); + fix_option_types(&mut schema_value); + schema_value + } + } + } + }) + .collect(); + + let schema_insertions: Vec = unique_types + .keys() + .map(|type_name| { + if type_name == "()" { + quote! { + schemas.insert("Unit".to_string(), serde_json::json!({ + "type": "null", + "description": "Unit type (empty response)" + })); + } + } else { + let sanitized_name = sanitize_type_name(type_name); + let fn_name = quote::format_ident!( + "_generate_schema_for_{}_{}", + service_name.to_string().to_lowercase(), + sanitized_name + ); + quote! { + schemas.insert(#sanitized_name.to_string(), #fn_name()); + } + } + }) + .collect(); + + (schema_fns, schema_insertions) +} + +pub(super) fn generate_normalization() -> TokenStream { + quote! { + // Helper function to fix schema references and flatten nested definitions + fn fix_schema_refs(value: &mut serde_json::Value, schemas: &mut serde_json::Map) { + match value { + serde_json::Value::Object(obj) => { + if let Some(defs) = obj.remove("definitions") { + if let serde_json::Value::Object(defs_obj) = defs { + for (name, schema) in defs_obj { + let mut schema_copy = schema.clone(); + fix_schema_refs(&mut schema_copy, schemas); + schemas.insert(name, schema_copy); + } + } + } + + if let Some(defs) = obj.remove("$defs") { + if let serde_json::Value::Object(defs_obj) = defs { + for (name, schema) in defs_obj { + let mut schema_copy = schema.clone(); + fix_schema_refs(&mut schema_copy, schemas); + schemas.insert(name, schema_copy); + } + } + } + + if let Some(ref_val) = obj.get_mut("$ref") { + if let serde_json::Value::String(ref_str) = ref_val { + if ref_str.starts_with("#/definitions/") { + let name = ref_str.trim_start_matches("#/definitions/"); + *ref_str = format!("#/components/schemas/{}", name); + } else if ref_str.starts_with("#/$defs/") { + let name = ref_str.trim_start_matches("#/$defs/"); + *ref_str = format!("#/components/schemas/{}", name); + } + } + } + + // Remove $schema field as it's not needed in OpenAPI + obj.remove("$schema"); + + for (_, v) in obj.iter_mut() { + fix_schema_refs(v, schemas); + } + } + serde_json::Value::Array(arr) => { + for item in arr.iter_mut() { + fix_schema_refs(item, schemas); + } + } + _ => {} + } + } + + // Helper function to normalize nullable properties for better OpenAPI explorer compatibility. + fn normalize_nullable_properties(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(obj) => { + if let Some(properties) = obj.get_mut("properties") { + if let serde_json::Value::Object(props) = properties { + for (_, prop_value) in props.iter_mut() { + if let serde_json::Value::Object(prop_obj) = prop_value { + if let Some(type_val) = prop_obj.get("type") { + if let serde_json::Value::Array(type_array) = type_val { + if type_array.len() == 2 { + let null_value = serde_json::Value::String("null".to_string()); + if type_array.contains(&null_value) { + let non_null_type = type_array.iter() + .find(|t| **t != null_value) + .cloned(); + + if let Some(actual_type) = non_null_type { + prop_obj.insert("type".to_string(), actual_type); + prop_obj.insert("nullable".to_string(), serde_json::Value::Bool(true)); + } + } + } + } + } + } + normalize_nullable_properties(prop_value); + } + } + } + + if let Some(definitions) = obj.get_mut("definitions") { + normalize_nullable_properties(definitions); + } + + for (_, v) in obj.iter_mut() { + normalize_nullable_properties(v); + } + } + serde_json::Value::Array(arr) => { + for item in arr.iter_mut() { + normalize_nullable_properties(item); + } + } + _ => {} + } + } + + // Helper function to fix Option types that use anyOf with null or type arrays + fn fix_option_types(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(obj) => { + if let Some(type_val) = obj.get("type") { + if let serde_json::Value::Array(type_array) = type_val { + if type_array.len() == 2 { + let null_value = serde_json::Value::String("null".to_string()); + if type_array.contains(&null_value) { + let non_null_type = type_array.iter() + .find(|t| **t != null_value) + .cloned(); + + if let Some(actual_type) = non_null_type { + obj.insert("type".to_string(), actual_type); + obj.insert("nullable".to_string(), serde_json::Value::Bool(true)); + } + } + } + } + } + + if let Some(any_of) = obj.get_mut("anyOf") { + if let serde_json::Value::Array(any_of_array) = any_of { + if any_of_array.len() == 2 { + let has_null = any_of_array.iter().any(|item| { + if let serde_json::Value::Object(item_obj) = item { + if let Some(type_val) = item_obj.get("type") { + if let serde_json::Value::String(type_str) = type_val { + return type_str == "null"; + } + } + } + false + }); + + if has_null { + let non_null_schema = any_of_array.iter().find(|item| { + if let serde_json::Value::Object(item_obj) = item { + if let Some(type_val) = item_obj.get("type") { + if let serde_json::Value::String(type_str) = type_val { + return type_str != "null"; + } + } + // If it has other properties besides type, it's not the null schema + return item_obj.len() > 1 || !item_obj.contains_key("type"); + } + true + }).cloned(); + + if let Some(schema) = non_null_schema { + obj.remove("anyOf"); + if let serde_json::Value::Object(schema_obj) = schema { + for (key, val) in schema_obj { + obj.insert(key, val); + } + } + obj.insert("nullable".to_string(), serde_json::Value::Bool(true)); + } + } + } + } + } + + for (_, v) in obj.iter_mut() { + fix_option_types(v); + } + } + serde_json::Value::Array(arr) => { + for item in arr.iter_mut() { + fix_option_types(item); + } + } + _ => {} + } + } + + } +} + +/// Generates code to include schema generation for types when schemars is available +pub fn generate_schema_impl_checks(service_def: &ServiceDefinition) -> TokenStream { + let mut unique_types = HashMap::new(); + + for endpoint in &service_def.endpoints { + if let Some(request_type) = &endpoint.request_type { + unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); + } + + let response_type = &endpoint.response_type; + unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); + + for path_param in &endpoint.path_params { + let param_type = &path_param.param_type; + unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); + } + + for query_param in &endpoint.query_params { + let param_type = &query_param.param_type; + unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); + } + + for version in &endpoint.versions { + if let Some(request_type) = &version.request_type { + unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); + } + + let response_type = &version.response_type; + unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); + + for path_param in &version.path_params { + let param_type = &path_param.param_type; + unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); + } + + for query_param in &version.query_params { + let param_type = &query_param.param_type; + unique_types.insert(quote!(#param_type).to_string(), quote!(#param_type)); + } + } + } + + let type_checks: Vec = unique_types + .values() + .map(|type_tokens| { + quote! { + const _: () = { + fn _assert_json_schema() {} + fn _check() { + _assert_json_schema::<#type_tokens>(); + } + }; + } + }) + .collect(); + + quote! { + #(#type_checks)* + } +} diff --git a/documentation/reviews/refactor-progress.md b/documentation/reviews/refactor-progress.md index 542a147..7afc4ff 100644 --- a/documentation/reviews/refactor-progress.md +++ b/documentation/reviews/refactor-progress.md @@ -23,3 +23,4 @@ REST baseline: 61/61 passed. | 7 | WebSocket handler contract, socket IO, and lifecycle configuration | 112 server/macro tests; docs and Clippy. Public handler paths re-export moved types; connection loop remains together. | | 8 | Explorer markup, styles, rendering, state, and request assets | Assembled HTML remains byte-identical (60,172 bytes); 120 tests; docs/Clippy/macro features; 11 browser tests; packaged and unpacked macro builds. | | 9 | File-service types, uploads, downloads, routes, and auth generation | Baseline and result: 49 tests; docs/Clippy; no-default/server/client macro checks; native and WASM API consumer checks. | +| 10 | OpenAPI schema collection/normalization and operation emission | 61 tests, doctest, Clippy/features, 11 browser tests. 64 original and extracted document samples produce the same four JSON variants: schema titles already vary with HashMap insertion order. No output policy changed. | From db78da5f268d853f77ea6219afa5d89d0974f851 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 11:29:56 +0200 Subject: [PATCH 11/35] refactor(jsonrpc): separate OpenRPC schema examples and methods --- .gitignore | 4 + crates/rpc/ras-jsonrpc-macro/src/openrpc.rs | 611 ------------------ .../ras-jsonrpc-macro/src/openrpc/examples.rs | 68 ++ .../ras-jsonrpc-macro/src/openrpc/methods.rs | 237 +++++++ .../rpc/ras-jsonrpc-macro/src/openrpc/mod.rs | 168 +++++ .../ras-jsonrpc-macro/src/openrpc/schema.rs | 200 ++++++ documentation/reviews/refactor-progress.md | 1 + 7 files changed, 678 insertions(+), 611 deletions(-) delete mode 100644 crates/rpc/ras-jsonrpc-macro/src/openrpc.rs create mode 100644 crates/rpc/ras-jsonrpc-macro/src/openrpc/examples.rs create mode 100644 crates/rpc/ras-jsonrpc-macro/src/openrpc/methods.rs create mode 100644 crates/rpc/ras-jsonrpc-macro/src/openrpc/mod.rs create mode 100644 crates/rpc/ras-jsonrpc-macro/src/openrpc/schema.rs diff --git a/.gitignore b/.gitignore index 34f2131..96a33c5 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ sketchpad/ # External security review input (not part of the published repo) SECURITY_FINDINGS.md + +# Specification emitter source modules are not generated documents. +!crates/rest/ras-rest-macro/src/openapi/ +!crates/rpc/ras-jsonrpc-macro/src/openrpc/ diff --git a/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs b/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs deleted file mode 100644 index b83bbaa..0000000 --- a/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs +++ /dev/null @@ -1,611 +0,0 @@ -//! OpenRPC document generation module -//! -//! This module provides functionality to generate OpenRPC specification documents -//! from the jsonrpc_service macro definitions. - -use crate::{AuthRequirement, OpenRpcConfig, ServiceDefinition}; -use proc_macro2::TokenStream; -use quote::quote; -use std::collections::HashMap; - -/// Generates OpenRPC document creation code -pub fn generate_openrpc_code( - service_def: &ServiceDefinition, - config: &OpenRpcConfig, -) -> TokenStream { - let service_name = &service_def.service_name; - let openrpc_fn_name = quote::format_ident!( - "generate_{}_openrpc", - service_name.to_string().to_lowercase() - ); - let openrpc_to_file_fn_name = quote::format_ident!( - "generate_{}_openrpc_to_file", - service_name.to_string().to_lowercase() - ); - let method_info_struct_name = quote::format_ident!("{}OpenRpcMethodInfo", service_name); - - let output_path_code = match config { - OpenRpcConfig::Enabled => { - let service_name_lower = service_name.to_string().to_lowercase(); - quote! { - format!("target/openrpc/{}.json", #service_name_lower) - } - } - OpenRpcConfig::WithPath(path) => { - quote! { - #path.to_string() - } - } - }; - - let flatten_fn_name = quote::format_ident!( - "_flatten_schema_defs_{}", - service_name.to_string().to_lowercase() - ); - let update_refs_fn_name = quote::format_ident!( - "_update_refs_recursive_{}", - service_name.to_string().to_lowercase() - ); - let generate_example_fn_name = quote::format_ident!( - "_generate_example_from_schema_{}", - service_name.to_string().to_lowercase() - ); - - let mut unique_types = std::collections::HashMap::new(); - for method in &service_def.methods { - let request_type = &method.request_type; - let response_type = &method.response_type; - - let request_type_str = quote!(#request_type).to_string(); - let response_type_str = quote!(#response_type).to_string(); - - unique_types.insert(request_type_str, quote!(#request_type)); - unique_types.insert(response_type_str, quote!(#response_type)); - - for version in &method.versions { - let request_type = &version.request_type; - let response_type = &version.response_type; - let request_type_str = quote!(#request_type).to_string(); - let response_type_str = quote!(#response_type).to_string(); - - unique_types.insert(request_type_str, quote!(#request_type)); - unique_types.insert(response_type_str, quote!(#response_type)); - } - } - - let schema_fns: Vec = unique_types - .iter() - .map(|(type_name, type_tokens)| { - if type_name == "()" { - quote! {} // Skip unit type, we'll handle it separately - } else { - let fn_name = quote::format_ident!( - "_generate_schema_for_{}_{}", - service_name.to_string().to_lowercase(), - type_name - .replace("::", "_") - .replace("<", "_") - .replace(">", "_") - .replace(" ", "_") - ); - quote! { - fn #fn_name() -> (serde_json::Value, std::collections::HashMap) { - let schema = schemars::schema_for!(#type_tokens); - let schema_value = serde_json::to_value(&schema).unwrap_or_else(|_| { - serde_json::json!({ - "type": "object", - "description": format!("Schema for {}", #type_name) - }) - }); - - let mut extracted_defs = std::collections::HashMap::new(); - let flattened_schema = #flatten_fn_name(schema_value, &mut extracted_defs); - (flattened_schema, extracted_defs) - } - } - } - }) - .collect(); - - let schema_insertions: Vec = unique_types - .keys() - .map(|type_name| { - if type_name == "()" { - quote! { - schemas.insert("()".to_string(), serde_json::json!({ - "type": "null", - "description": "Unit type" - })); - } - } else { - let fn_name = quote::format_ident!( - "_generate_schema_for_{}_{}", - service_name.to_string().to_lowercase(), - type_name - .replace("::", "_") - .replace("<", "_") - .replace(">", "_") - .replace(" ", "_") - ); - quote! { - let (schema, defs) = #fn_name(); - let sanitized_name = #type_name.to_string().replace(" ", ""); - schemas.insert(sanitized_name, schema); - for (def_name, def_schema) in defs { - let sanitized_def_name = def_name.replace(" ", ""); - schemas.insert(sanitized_def_name, def_schema); - } - } - } - }) - .collect(); - - let method_infos: Vec = service_def - .methods - .iter() - .flat_map(|method| { - let canonical_method_name = method - .wire_name - .clone() - .unwrap_or_else(|| method.name.to_string()); - let canonical_version = method.version.clone(); - let canonical_version_tokens = match &canonical_version { - Some(version) => quote! { Some(#version.to_string()) }, - None => quote! { None }, - }; - let auth_required = matches!(method.auth, AuthRequirement::WithPermissions(_)); - let auth_optional = matches!(method.auth, AuthRequirement::OptionalAuth); - let permissions = match &method.auth { - AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], - AuthRequirement::WithPermissions(groups) => { - groups.iter().flatten().cloned().collect() - } - }; - let permission_groups = permission_groups_for_spec(&method.auth); - let permission_groups_tokens = permission_groups_tokens(&permission_groups); - - let request_type = &method.request_type; - let response_type = &method.response_type; - let (summary, description) = match &method.docs { - Some(docs) => { - let summary = &docs.summary; - let description = &docs.description; - ( - quote! { Some(#summary.to_string()) }, - quote! { Some(#description.to_string()) }, - ) - } - None => (quote! { None }, quote! { None }), - }; - - let mut infos = vec![quote! { - #method_info_struct_name { - name: #canonical_method_name.to_string(), - summary: #summary, - description: #description, - auth_required: #auth_required, - auth_optional: #auth_optional, - permissions: vec![#(#permissions.to_string()),*], - permission_groups: #permission_groups_tokens, - request_type_name: stringify!(#request_type).to_string(), - response_type_name: stringify!(#response_type).to_string(), - version: #canonical_version_tokens, - canonical_version: #canonical_version_tokens, - canonical_method: #canonical_method_name.to_string(), - } - }]; - - infos.extend(method.versions.iter().map(|version| { - let method_name = &version.wire_name; - let version_label = &version.version; - let request_type = &version.request_type; - let response_type = &version.response_type; - let canonical_version = canonical_version - .clone() - .unwrap_or_else(|| "current".to_string()); - let canonical_method_name = canonical_method_name.clone(); - let permissions = permissions.clone(); - let permission_groups_tokens = permission_groups_tokens.clone(); - let summary = summary.clone(); - let description = description.clone(); - - quote! { - #method_info_struct_name { - name: #method_name.to_string(), - summary: #summary, - description: #description, - auth_required: #auth_required, - auth_optional: #auth_optional, - permissions: vec![#(#permissions.to_string()),*], - permission_groups: #permission_groups_tokens, - request_type_name: stringify!(#request_type).to_string(), - response_type_name: stringify!(#response_type).to_string(), - version: Some(#version_label.to_string()), - canonical_version: Some(#canonical_version.to_string()), - canonical_method: #canonical_method_name.to_string(), - } - } - })); - - infos - }) - .collect(); - - quote! { - #[derive(serde::Serialize)] - struct #method_info_struct_name { - name: String, - summary: Option, - description: Option, - auth_required: bool, - auth_optional: bool, - permissions: Vec, - permission_groups: Vec>, - request_type_name: String, - response_type_name: String, - version: Option, - canonical_version: Option, - canonical_method: String, - } - - /// Helper function to extract examples from a JSON schema - fn #flatten_fn_name( - mut schema: serde_json::Value, - extracted_defs: &mut std::collections::HashMap - ) -> serde_json::Value { - if let Some(obj) = schema.as_object_mut() { - if let Some(defs) = obj.remove("$defs") { - if let Some(defs_obj) = defs.as_object() { - for (def_name, def_schema) in defs_obj { - let flattened_def = #flatten_fn_name(def_schema.clone(), extracted_defs); - extracted_defs.insert(def_name.clone(), flattened_def); - } - } - } - - #update_refs_fn_name(&mut schema); - } - - schema - } - - /// Recursively update all $ref paths from #/$defs/ to #/components/schemas/ - fn #update_refs_fn_name(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(obj) => { - for (key, val) in obj.iter_mut() { - if key == "$ref" { - if let Some(ref_str) = val.as_str() { - if ref_str.starts_with("#/$defs/") { - *val = serde_json::Value::String( - ref_str.replace("#/$defs/", "#/components/schemas/") - ); - } - } - } else { - #update_refs_fn_name(val); - } - } - } - serde_json::Value::Array(arr) => { - for item in arr.iter_mut() { - #update_refs_fn_name(item); - } - } - _ => {} - } - } - - /// Generate example value from schema - fn #generate_example_fn_name(schema: &serde_json::Value, schemas: &std::collections::HashMap) -> serde_json::Value { - if let Some(examples) = schema.get("examples") { - if let Some(arr) = examples.as_array() { - if let Some(first) = arr.first() { - return first.clone(); - } - } - } - - if let Some(example) = schema.get("example") { - return example.clone(); - } - - if let Some(ref_str) = schema.get("$ref").and_then(|v| v.as_str()) { - if let Some(ref_name) = ref_str.strip_prefix("#/components/schemas/") { - if let Some(ref_schema) = schemas.get(ref_name) { - return #generate_example_fn_name(ref_schema, schemas); - } - } - } - - // Handle oneOf/anyOf - pick the first variant - if let Some(one_of) = schema.get("oneOf").and_then(|v| v.as_array()) { - if let Some(first_variant) = one_of.first() { - return #generate_example_fn_name(first_variant, schemas); - } - } - if let Some(any_of) = schema.get("anyOf").and_then(|v| v.as_array()) { - if let Some(first_variant) = any_of.first() { - return #generate_example_fn_name(first_variant, schemas); - } - } - - match schema.get("type").and_then(|v| v.as_str()) { - Some("string") => serde_json::json!("example_string"), - Some("number") | Some("integer") => serde_json::json!(42), - Some("boolean") => serde_json::json!(true), - Some("array") => { - if let Some(items) = schema.get("items") { - serde_json::json!([#generate_example_fn_name(items, schemas)]) - } else { - serde_json::json!(["example_item"]) - } - } - Some("object") => { - let mut obj = serde_json::Map::new(); - if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) { - for (key, prop_schema) in props { - obj.insert(key.clone(), #generate_example_fn_name(prop_schema, schemas)); - } - serde_json::json!(obj) - } else { - serde_json::json!({"example_key": "example_value"}) - } - } - Some("null") => serde_json::json!(null), - _ => serde_json::json!({"example": "value"}) - } - } - - #(#schema_fns)* - - /// Generate OpenRPC document for this service - pub fn #openrpc_fn_name() -> serde_json::Value { - use serde_json::json; - use schemars::{schema_for, JsonSchema}; - use std::collections::HashMap; - - let methods = vec![ - #(#method_infos),* - ]; - - let mut schemas = HashMap::new(); - - #(#schema_insertions)* - - let openrpc_methods: Vec = methods.iter().map(|method| { - let mut params = vec![]; - - if method.request_type_name != "()" { - let sanitized_request_type = method.request_type_name.replace(" ", ""); - let example = if let Some(schema) = schemas.get(&sanitized_request_type) { - #generate_example_fn_name(schema, &schemas) - } else { - json!({"example": "value"}) - }; - - params.push(json!({ - "name": "params", - "summary": format!("Request parameters of type {}", method.request_type_name), - "required": true, - "schema": { - "$ref": format!("#/components/schemas/{}", sanitized_request_type) - } - })); - } - - let mut extensions: std::collections::HashMap = std::collections::HashMap::new(); - - if method.auth_required { - extensions.insert("x-authentication".to_string(), json!({ - "required": true, - "type": "bearer" - })); - - if !method.permissions.is_empty() { - extensions.insert("x-permissions".to_string(), json!(method.permissions)); - } - - if !method.permission_groups.is_empty() { - extensions.insert("x-permission-groups".to_string(), json!(method.permission_groups)); - } - } else if method.auth_optional { - // OPTIONAL_AUTH: authentication is honoured but not required. - extensions.insert("x-authentication".to_string(), json!({ - "required": false, - "type": "bearer" - })); - } - - if let Some(version) = &method.version { - extensions.insert("x-ras-version".to_string(), json!(version)); - } - - if let Some(canonical_version) = &method.canonical_version { - extensions.insert("x-ras-canonical-version".to_string(), json!(canonical_version)); - extensions.insert("x-ras-canonical-method".to_string(), json!(method.canonical_method)); - } - - let mut examples = vec![]; - if method.request_type_name != "()" { - let sanitized_request_type = method.request_type_name.replace(" ", ""); - let sanitized_response_type = method.response_type_name.replace(" ", ""); - - let request_example = if let Some(schema) = schemas.get(&sanitized_request_type) { - #generate_example_fn_name(schema, &schemas) - } else { - json!({"example": "value"}) - }; - - let response_example = if method.response_type_name != "()" { - if let Some(schema) = schemas.get(&sanitized_response_type) { - #generate_example_fn_name(schema, &schemas) - } else { - json!({"example": "response"}) - } - } else { - json!(null) - }; - - examples.push(json!({ - "name": format!("{}_example", method.name), - "description": format!("Example call to {}", method.name), - "params": [{"name": "params", "value": request_example}], - "result": {"name": "result", "value": response_example} - })); - } - - let sanitized_response_type = method.response_type_name.replace(" ", ""); - let method_summary = method - .summary - .clone() - .unwrap_or_else(|| format!("Calls the {} method", method.name)); - - let mut method_obj = json!({ - "name": method.name, - "summary": method_summary, - "params": params, - "result": { - "name": "result", - "description": format!("Response of type {}", method.response_type_name), - "schema": { - "$ref": format!("#/components/schemas/{}", sanitized_response_type) - } - } - }); - - // Note: Examples are intentionally omitted as they're optional in OpenRPC - // and can cause validation issues with some validators - - if let Some(obj) = method_obj.as_object_mut() { - if let Some(description) = &method.description { - obj.insert("description".to_string(), json!(description)); - } - - for (key, value) in extensions { - obj.insert(key, value); - } - } - - method_obj - }).collect(); - - json!({ - "openrpc": "1.3.2", - "info": { - "title": format!("{} JSON-RPC API", stringify!(#service_name)), - "version": "1.0.0", - "description": format!("OpenRPC specification for the {} service", stringify!(#service_name)) - }, - "methods": openrpc_methods, - "components": { - "schemas": schemas, - "errors": { - "ParseError": { - "code": -32700, - "message": "Parse error" - }, - "InvalidRequest": { - "code": -32600, - "message": "Invalid Request" - }, - "MethodNotFound": { - "code": -32601, - "message": "Method not found" - }, - "InvalidParams": { - "code": -32602, - "message": "Invalid params" - }, - "InternalError": { - "code": -32603, - "message": "Internal error" - }, - "AuthenticationRequired": { - "code": -32001, - "message": "Authentication required" - }, - "InsufficientPermissions": { - "code": -32002, - "message": "Insufficient permissions" - }, - "TokenExpired": { - "code": -32003, - "message": "Token expired" - } - } - } - }) - } - - /// Write OpenRPC document to the target directory - pub fn #openrpc_to_file_fn_name() -> std::io::Result<()> { - let doc = #openrpc_fn_name(); - let output_path = #output_path_code; - - if let Some(parent) = std::path::Path::new(&output_path).parent() { - std::fs::create_dir_all(parent)?; - } - - let json_string = serde_json::to_string_pretty(&doc)?; - std::fs::write(&output_path, &json_string)?; - - println!("Generated OpenRPC document at: {}", output_path); - - Ok(()) - } - } -} - -fn permission_groups_for_spec(auth: &AuthRequirement) -> Vec> { - match auth { - AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], - AuthRequirement::WithPermissions(groups) => groups.clone(), - } -} - -fn permission_groups_tokens(groups: &[Vec]) -> TokenStream { - let groups = groups - .iter() - .map(|group| quote! { vec![#(#group.to_string()),*] }); - quote! { vec![#(#groups),*] } -} - -/// Generates code to include schema generation for types when schemars is available -pub fn generate_schema_impl_checks(service_def: &ServiceDefinition) -> TokenStream { - let mut unique_types = HashMap::new(); - - for method in &service_def.methods { - let request_type = &method.request_type; - let response_type = &method.response_type; - - unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); - unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); - - for version in &method.versions { - let request_type = &version.request_type; - let response_type = &version.response_type; - - unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); - unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); - } - } - - let type_checks: Vec = unique_types - .values() - .map(|type_tokens| { - quote! { - const _: () = { - fn _assert_json_schema() {} - fn _check() { - _assert_json_schema::<#type_tokens>(); - } - }; - } - }) - .collect(); - - quote! { - #(#type_checks)* - } -} diff --git a/crates/rpc/ras-jsonrpc-macro/src/openrpc/examples.rs b/crates/rpc/ras-jsonrpc-macro/src/openrpc/examples.rs new file mode 100644 index 0000000..8b35f66 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/openrpc/examples.rs @@ -0,0 +1,68 @@ +use proc_macro2::{Ident, TokenStream}; +use quote::quote; + +pub(super) fn generate_examples(generate_example_fn_name: &Ident) -> TokenStream { + quote! { + /// Generate example value from schema + fn #generate_example_fn_name(schema: &serde_json::Value, schemas: &std::collections::HashMap) -> serde_json::Value { + if let Some(examples) = schema.get("examples") { + if let Some(arr) = examples.as_array() { + if let Some(first) = arr.first() { + return first.clone(); + } + } + } + + if let Some(example) = schema.get("example") { + return example.clone(); + } + + if let Some(ref_str) = schema.get("$ref").and_then(|v| v.as_str()) { + if let Some(ref_name) = ref_str.strip_prefix("#/components/schemas/") { + if let Some(ref_schema) = schemas.get(ref_name) { + return #generate_example_fn_name(ref_schema, schemas); + } + } + } + + // Handle oneOf/anyOf - pick the first variant + if let Some(one_of) = schema.get("oneOf").and_then(|v| v.as_array()) { + if let Some(first_variant) = one_of.first() { + return #generate_example_fn_name(first_variant, schemas); + } + } + if let Some(any_of) = schema.get("anyOf").and_then(|v| v.as_array()) { + if let Some(first_variant) = any_of.first() { + return #generate_example_fn_name(first_variant, schemas); + } + } + + match schema.get("type").and_then(|v| v.as_str()) { + Some("string") => serde_json::json!("example_string"), + Some("number") | Some("integer") => serde_json::json!(42), + Some("boolean") => serde_json::json!(true), + Some("array") => { + if let Some(items) = schema.get("items") { + serde_json::json!([#generate_example_fn_name(items, schemas)]) + } else { + serde_json::json!(["example_item"]) + } + } + Some("object") => { + let mut obj = serde_json::Map::new(); + if let Some(props) = schema.get("properties").and_then(|v| v.as_object()) { + for (key, prop_schema) in props { + obj.insert(key.clone(), #generate_example_fn_name(prop_schema, schemas)); + } + serde_json::json!(obj) + } else { + serde_json::json!({"example_key": "example_value"}) + } + } + Some("null") => serde_json::json!(null), + _ => serde_json::json!({"example": "value"}) + } + } + + } +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/openrpc/methods.rs b/crates/rpc/ras-jsonrpc-macro/src/openrpc/methods.rs new file mode 100644 index 0000000..d5c3462 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/openrpc/methods.rs @@ -0,0 +1,237 @@ +use crate::{AuthRequirement, ServiceDefinition}; +use proc_macro2::{Ident, TokenStream}; +use quote::quote; + +pub(super) fn generate_method_infos( + service_def: &ServiceDefinition, + method_info_struct_name: &Ident, +) -> Vec { + let method_infos: Vec = service_def + .methods + .iter() + .flat_map(|method| { + let canonical_method_name = method + .wire_name + .clone() + .unwrap_or_else(|| method.name.to_string()); + let canonical_version = method.version.clone(); + let canonical_version_tokens = match &canonical_version { + Some(version) => quote! { Some(#version.to_string()) }, + None => quote! { None }, + }; + let auth_required = matches!(method.auth, AuthRequirement::WithPermissions(_)); + let auth_optional = matches!(method.auth, AuthRequirement::OptionalAuth); + let permissions = match &method.auth { + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], + AuthRequirement::WithPermissions(groups) => { + groups.iter().flatten().cloned().collect() + } + }; + let permission_groups = permission_groups_for_spec(&method.auth); + let permission_groups_tokens = permission_groups_tokens(&permission_groups); + + let request_type = &method.request_type; + let response_type = &method.response_type; + let (summary, description) = match &method.docs { + Some(docs) => { + let summary = &docs.summary; + let description = &docs.description; + ( + quote! { Some(#summary.to_string()) }, + quote! { Some(#description.to_string()) }, + ) + } + None => (quote! { None }, quote! { None }), + }; + + let mut infos = vec![quote! { + #method_info_struct_name { + name: #canonical_method_name.to_string(), + summary: #summary, + description: #description, + auth_required: #auth_required, + auth_optional: #auth_optional, + permissions: vec![#(#permissions.to_string()),*], + permission_groups: #permission_groups_tokens, + request_type_name: stringify!(#request_type).to_string(), + response_type_name: stringify!(#response_type).to_string(), + version: #canonical_version_tokens, + canonical_version: #canonical_version_tokens, + canonical_method: #canonical_method_name.to_string(), + } + }]; + + infos.extend(method.versions.iter().map(|version| { + let method_name = &version.wire_name; + let version_label = &version.version; + let request_type = &version.request_type; + let response_type = &version.response_type; + let canonical_version = canonical_version + .clone() + .unwrap_or_else(|| "current".to_string()); + let canonical_method_name = canonical_method_name.clone(); + let permissions = permissions.clone(); + let permission_groups_tokens = permission_groups_tokens.clone(); + let summary = summary.clone(); + let description = description.clone(); + + quote! { + #method_info_struct_name { + name: #method_name.to_string(), + summary: #summary, + description: #description, + auth_required: #auth_required, + auth_optional: #auth_optional, + permissions: vec![#(#permissions.to_string()),*], + permission_groups: #permission_groups_tokens, + request_type_name: stringify!(#request_type).to_string(), + response_type_name: stringify!(#response_type).to_string(), + version: Some(#version_label.to_string()), + canonical_version: Some(#canonical_version.to_string()), + canonical_method: #canonical_method_name.to_string(), + } + } + })); + + infos + }) + .collect(); + + method_infos +} + +pub(super) fn generate_methods(generate_example_fn_name: &Ident) -> TokenStream { + quote! { + let openrpc_methods: Vec = methods.iter().map(|method| { + let mut params = vec![]; + + if method.request_type_name != "()" { + let sanitized_request_type = method.request_type_name.replace(" ", ""); + let example = if let Some(schema) = schemas.get(&sanitized_request_type) { + #generate_example_fn_name(schema, &schemas) + } else { + json!({"example": "value"}) + }; + + params.push(json!({ + "name": "params", + "summary": format!("Request parameters of type {}", method.request_type_name), + "required": true, + "schema": { + "$ref": format!("#/components/schemas/{}", sanitized_request_type) + } + })); + } + + let mut extensions: std::collections::HashMap = std::collections::HashMap::new(); + + if method.auth_required { + extensions.insert("x-authentication".to_string(), json!({ + "required": true, + "type": "bearer" + })); + + if !method.permissions.is_empty() { + extensions.insert("x-permissions".to_string(), json!(method.permissions)); + } + + if !method.permission_groups.is_empty() { + extensions.insert("x-permission-groups".to_string(), json!(method.permission_groups)); + } + } else if method.auth_optional { + // OPTIONAL_AUTH: authentication is honoured but not required. + extensions.insert("x-authentication".to_string(), json!({ + "required": false, + "type": "bearer" + })); + } + + if let Some(version) = &method.version { + extensions.insert("x-ras-version".to_string(), json!(version)); + } + + if let Some(canonical_version) = &method.canonical_version { + extensions.insert("x-ras-canonical-version".to_string(), json!(canonical_version)); + extensions.insert("x-ras-canonical-method".to_string(), json!(method.canonical_method)); + } + + let mut examples = vec![]; + if method.request_type_name != "()" { + let sanitized_request_type = method.request_type_name.replace(" ", ""); + let sanitized_response_type = method.response_type_name.replace(" ", ""); + + let request_example = if let Some(schema) = schemas.get(&sanitized_request_type) { + #generate_example_fn_name(schema, &schemas) + } else { + json!({"example": "value"}) + }; + + let response_example = if method.response_type_name != "()" { + if let Some(schema) = schemas.get(&sanitized_response_type) { + #generate_example_fn_name(schema, &schemas) + } else { + json!({"example": "response"}) + } + } else { + json!(null) + }; + + examples.push(json!({ + "name": format!("{}_example", method.name), + "description": format!("Example call to {}", method.name), + "params": [{"name": "params", "value": request_example}], + "result": {"name": "result", "value": response_example} + })); + } + + let sanitized_response_type = method.response_type_name.replace(" ", ""); + let method_summary = method + .summary + .clone() + .unwrap_or_else(|| format!("Calls the {} method", method.name)); + + let mut method_obj = json!({ + "name": method.name, + "summary": method_summary, + "params": params, + "result": { + "name": "result", + "description": format!("Response of type {}", method.response_type_name), + "schema": { + "$ref": format!("#/components/schemas/{}", sanitized_response_type) + } + } + }); + + // Note: Examples are intentionally omitted as they're optional in OpenRPC + // and can cause validation issues with some validators + + if let Some(obj) = method_obj.as_object_mut() { + if let Some(description) = &method.description { + obj.insert("description".to_string(), json!(description)); + } + + for (key, value) in extensions { + obj.insert(key, value); + } + } + + method_obj + }).collect(); + + } +} + +fn permission_groups_for_spec(auth: &AuthRequirement) -> Vec> { + match auth { + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], + AuthRequirement::WithPermissions(groups) => groups.clone(), + } +} + +fn permission_groups_tokens(groups: &[Vec]) -> TokenStream { + let groups = groups + .iter() + .map(|group| quote! { vec![#(#group.to_string()),*] }); + quote! { vec![#(#groups),*] } +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/openrpc/mod.rs b/crates/rpc/ras-jsonrpc-macro/src/openrpc/mod.rs new file mode 100644 index 0000000..faeab0b --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/openrpc/mod.rs @@ -0,0 +1,168 @@ +//! OpenRPC document generation module +//! +//! This module provides functionality to generate OpenRPC specification documents +//! from the jsonrpc_service macro definitions. + +use crate::{OpenRpcConfig, ServiceDefinition}; +use proc_macro2::TokenStream; +use quote::quote; +mod examples; +mod methods; +mod schema; +pub use schema::generate_schema_impl_checks; + +/// Generates OpenRPC document creation code +pub fn generate_openrpc_code( + service_def: &ServiceDefinition, + config: &OpenRpcConfig, +) -> TokenStream { + let service_name = &service_def.service_name; + let openrpc_fn_name = quote::format_ident!( + "generate_{}_openrpc", + service_name.to_string().to_lowercase() + ); + let openrpc_to_file_fn_name = quote::format_ident!( + "generate_{}_openrpc_to_file", + service_name.to_string().to_lowercase() + ); + let method_info_struct_name = quote::format_ident!("{}OpenRpcMethodInfo", service_name); + + let output_path_code = match config { + OpenRpcConfig::Enabled => { + let service_name_lower = service_name.to_string().to_lowercase(); + quote! { + format!("target/openrpc/{}.json", #service_name_lower) + } + } + OpenRpcConfig::WithPath(path) => { + quote! { + #path.to_string() + } + } + }; + + let flatten_fn_name = quote::format_ident!( + "_flatten_schema_defs_{}", + service_name.to_string().to_lowercase() + ); + let update_refs_fn_name = quote::format_ident!( + "_update_refs_recursive_{}", + service_name.to_string().to_lowercase() + ); + let generate_example_fn_name = quote::format_ident!( + "_generate_example_from_schema_{}", + service_name.to_string().to_lowercase() + ); + + let unique_types = schema::collect_types(service_def); + let (schema_fns, schema_insertions) = + schema::generate_schemas(service_name, &flatten_fn_name, &unique_types); + let method_infos = methods::generate_method_infos(service_def, &method_info_struct_name); + let normalization = schema::generate_normalization(&flatten_fn_name, &update_refs_fn_name); + let examples = examples::generate_examples(&generate_example_fn_name); + let methods = methods::generate_methods(&generate_example_fn_name); + + quote! { + #[derive(serde::Serialize)] + struct #method_info_struct_name { + name: String, + summary: Option, + description: Option, + auth_required: bool, + auth_optional: bool, + permissions: Vec, + permission_groups: Vec>, + request_type_name: String, + response_type_name: String, + version: Option, + canonical_version: Option, + canonical_method: String, + } + + #normalization + #examples + + #(#schema_fns)* + + /// Generate OpenRPC document for this service + pub fn #openrpc_fn_name() -> serde_json::Value { + use serde_json::json; + use schemars::{schema_for, JsonSchema}; + use std::collections::HashMap; + + let methods = vec![ + #(#method_infos),* + ]; + + let mut schemas = HashMap::new(); + + #(#schema_insertions)* + + #methods + + json!({ + "openrpc": "1.3.2", + "info": { + "title": format!("{} JSON-RPC API", stringify!(#service_name)), + "version": "1.0.0", + "description": format!("OpenRPC specification for the {} service", stringify!(#service_name)) + }, + "methods": openrpc_methods, + "components": { + "schemas": schemas, + "errors": { + "ParseError": { + "code": -32700, + "message": "Parse error" + }, + "InvalidRequest": { + "code": -32600, + "message": "Invalid Request" + }, + "MethodNotFound": { + "code": -32601, + "message": "Method not found" + }, + "InvalidParams": { + "code": -32602, + "message": "Invalid params" + }, + "InternalError": { + "code": -32603, + "message": "Internal error" + }, + "AuthenticationRequired": { + "code": -32001, + "message": "Authentication required" + }, + "InsufficientPermissions": { + "code": -32002, + "message": "Insufficient permissions" + }, + "TokenExpired": { + "code": -32003, + "message": "Token expired" + } + } + } + }) + } + + /// Write OpenRPC document to the target directory + pub fn #openrpc_to_file_fn_name() -> std::io::Result<()> { + let doc = #openrpc_fn_name(); + let output_path = #output_path_code; + + if let Some(parent) = std::path::Path::new(&output_path).parent() { + std::fs::create_dir_all(parent)?; + } + + let json_string = serde_json::to_string_pretty(&doc)?; + std::fs::write(&output_path, &json_string)?; + + println!("Generated OpenRPC document at: {}", output_path); + + Ok(()) + } + } +} diff --git a/crates/rpc/ras-jsonrpc-macro/src/openrpc/schema.rs b/crates/rpc/ras-jsonrpc-macro/src/openrpc/schema.rs new file mode 100644 index 0000000..3735b65 --- /dev/null +++ b/crates/rpc/ras-jsonrpc-macro/src/openrpc/schema.rs @@ -0,0 +1,200 @@ +use crate::ServiceDefinition; +use proc_macro2::{Ident, TokenStream}; +use quote::quote; +use std::collections::HashMap; + +pub(super) fn collect_types(service_def: &ServiceDefinition) -> HashMap { + let mut unique_types = std::collections::HashMap::new(); + for method in &service_def.methods { + let request_type = &method.request_type; + let response_type = &method.response_type; + + let request_type_str = quote!(#request_type).to_string(); + let response_type_str = quote!(#response_type).to_string(); + + unique_types.insert(request_type_str, quote!(#request_type)); + unique_types.insert(response_type_str, quote!(#response_type)); + + for version in &method.versions { + let request_type = &version.request_type; + let response_type = &version.response_type; + let request_type_str = quote!(#request_type).to_string(); + let response_type_str = quote!(#response_type).to_string(); + + unique_types.insert(request_type_str, quote!(#request_type)); + unique_types.insert(response_type_str, quote!(#response_type)); + } + } + + unique_types +} + +pub(super) fn generate_schemas( + service_name: &Ident, + flatten_fn_name: &Ident, + unique_types: &HashMap, +) -> (Vec, Vec) { + let schema_fns: Vec = unique_types + .iter() + .map(|(type_name, type_tokens)| { + if type_name == "()" { + quote! {} // Skip unit type, we'll handle it separately + } else { + let fn_name = quote::format_ident!( + "_generate_schema_for_{}_{}", + service_name.to_string().to_lowercase(), + type_name + .replace("::", "_") + .replace("<", "_") + .replace(">", "_") + .replace(" ", "_") + ); + quote! { + fn #fn_name() -> (serde_json::Value, std::collections::HashMap) { + let schema = schemars::schema_for!(#type_tokens); + let schema_value = serde_json::to_value(&schema).unwrap_or_else(|_| { + serde_json::json!({ + "type": "object", + "description": format!("Schema for {}", #type_name) + }) + }); + + let mut extracted_defs = std::collections::HashMap::new(); + let flattened_schema = #flatten_fn_name(schema_value, &mut extracted_defs); + (flattened_schema, extracted_defs) + } + } + } + }) + .collect(); + + let schema_insertions: Vec = unique_types + .keys() + .map(|type_name| { + if type_name == "()" { + quote! { + schemas.insert("()".to_string(), serde_json::json!({ + "type": "null", + "description": "Unit type" + })); + } + } else { + let fn_name = quote::format_ident!( + "_generate_schema_for_{}_{}", + service_name.to_string().to_lowercase(), + type_name + .replace("::", "_") + .replace("<", "_") + .replace(">", "_") + .replace(" ", "_") + ); + quote! { + let (schema, defs) = #fn_name(); + let sanitized_name = #type_name.to_string().replace(" ", ""); + schemas.insert(sanitized_name, schema); + for (def_name, def_schema) in defs { + let sanitized_def_name = def_name.replace(" ", ""); + schemas.insert(sanitized_def_name, def_schema); + } + } + } + }) + .collect(); + + (schema_fns, schema_insertions) +} + +pub(super) fn generate_normalization( + flatten_fn_name: &Ident, + update_refs_fn_name: &Ident, +) -> TokenStream { + quote! { + /// Helper function to extract examples from a JSON schema + fn #flatten_fn_name( + mut schema: serde_json::Value, + extracted_defs: &mut std::collections::HashMap + ) -> serde_json::Value { + if let Some(obj) = schema.as_object_mut() { + if let Some(defs) = obj.remove("$defs") { + if let Some(defs_obj) = defs.as_object() { + for (def_name, def_schema) in defs_obj { + let flattened_def = #flatten_fn_name(def_schema.clone(), extracted_defs); + extracted_defs.insert(def_name.clone(), flattened_def); + } + } + } + + #update_refs_fn_name(&mut schema); + } + + schema + } + + /// Recursively update all $ref paths from #/$defs/ to #/components/schemas/ + fn #update_refs_fn_name(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(obj) => { + for (key, val) in obj.iter_mut() { + if key == "$ref" { + if let Some(ref_str) = val.as_str() { + if ref_str.starts_with("#/$defs/") { + *val = serde_json::Value::String( + ref_str.replace("#/$defs/", "#/components/schemas/") + ); + } + } + } else { + #update_refs_fn_name(val); + } + } + } + serde_json::Value::Array(arr) => { + for item in arr.iter_mut() { + #update_refs_fn_name(item); + } + } + _ => {} + } + } + + } +} + +/// Generates code to include schema generation for types when schemars is available +pub fn generate_schema_impl_checks(service_def: &ServiceDefinition) -> TokenStream { + let mut unique_types = HashMap::new(); + + for method in &service_def.methods { + let request_type = &method.request_type; + let response_type = &method.response_type; + + unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); + unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); + + for version in &method.versions { + let request_type = &version.request_type; + let response_type = &version.response_type; + + unique_types.insert(quote!(#request_type).to_string(), quote!(#request_type)); + unique_types.insert(quote!(#response_type).to_string(), quote!(#response_type)); + } + } + + let type_checks: Vec = unique_types + .values() + .map(|type_tokens| { + quote! { + const _: () = { + fn _assert_json_schema() {} + fn _check() { + _assert_json_schema::<#type_tokens>(); + } + }; + } + }) + .collect(); + + quote! { + #(#type_checks)* + } +} diff --git a/documentation/reviews/refactor-progress.md b/documentation/reviews/refactor-progress.md index 7afc4ff..d0773e2 100644 --- a/documentation/reviews/refactor-progress.md +++ b/documentation/reviews/refactor-progress.md @@ -24,3 +24,4 @@ REST baseline: 61/61 passed. | 8 | Explorer markup, styles, rendering, state, and request assets | Assembled HTML remains byte-identical (60,172 bytes); 120 tests; docs/Clippy/macro features; 11 browser tests; packaged and unpacked macro builds. | | 9 | File-service types, uploads, downloads, routes, and auth generation | Baseline and result: 49 tests; docs/Clippy; no-default/server/client macro checks; native and WASM API consumer checks. | | 10 | OpenAPI schema collection/normalization and operation emission | 61 tests, doctest, Clippy/features, 11 browser tests. 64 original and extracted document samples produce the same four JSON variants: schema titles already vary with HashMap insertion order. No output policy changed. | +| 11 | OpenRPC schemas/references, examples, and methods | 59 tests, docs/Clippy/features, 11 browser tests; three baseline JSON documents equal all 64 extracted samples each. | From aceaf08ac052d27412131de82ebb6254b4b4143f Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 11:32:10 +0200 Subject: [PATCH 12/35] refactor(auth): separate HTTP credential cookie and CSRF policy --- crates/core/ras-auth-core/src/transport.rs | 1305 ----------------- .../ras-auth-core/src/transport/cookie.rs | 279 ++++ .../ras-auth-core/src/transport/credential.rs | 72 + .../core/ras-auth-core/src/transport/csrf.rs | 289 ++++ .../core/ras-auth-core/src/transport/mod.rs | 164 +++ .../ras-auth-core/src/transport/redaction.rs | 52 + .../src/transport/tests/config.rs | 148 ++ .../src/transport/tests/cookie.rs | 74 + .../src/transport/tests/credential.rs | 74 + .../ras-auth-core/src/transport/tests/csrf.rs | 83 ++ .../ras-auth-core/src/transport/tests/mod.rs | 55 + .../src/transport/tests/redaction.rs | 41 + documentation/reviews/refactor-progress.md | 1 + 13 files changed, 1332 insertions(+), 1305 deletions(-) delete mode 100644 crates/core/ras-auth-core/src/transport.rs create mode 100644 crates/core/ras-auth-core/src/transport/cookie.rs create mode 100644 crates/core/ras-auth-core/src/transport/credential.rs create mode 100644 crates/core/ras-auth-core/src/transport/csrf.rs create mode 100644 crates/core/ras-auth-core/src/transport/mod.rs create mode 100644 crates/core/ras-auth-core/src/transport/redaction.rs create mode 100644 crates/core/ras-auth-core/src/transport/tests/config.rs create mode 100644 crates/core/ras-auth-core/src/transport/tests/cookie.rs create mode 100644 crates/core/ras-auth-core/src/transport/tests/credential.rs create mode 100644 crates/core/ras-auth-core/src/transport/tests/csrf.rs create mode 100644 crates/core/ras-auth-core/src/transport/tests/mod.rs create mode 100644 crates/core/ras-auth-core/src/transport/tests/redaction.rs diff --git a/crates/core/ras-auth-core/src/transport.rs b/crates/core/ras-auth-core/src/transport.rs deleted file mode 100644 index 92bb7bb..0000000 --- a/crates/core/ras-auth-core/src/transport.rs +++ /dev/null @@ -1,1305 +0,0 @@ -//! HTTP credential transport helpers for bearer and cookie-based sessions. - -use cookie::{ - Cookie, SameSite, - time::{Duration, OffsetDateTime}, -}; -use http::header::{AUTHORIZATION, COOKIE, HeaderName, SET_COOKIE}; -use http::{HeaderMap, HeaderValue}; -use subtle::ConstantTimeEq; -use thiserror::Error; - -const DEFAULT_COOKIE_NAME: &str = "__Host-ras-session"; -const DEFAULT_CSRF_COOKIE_NAME: &str = "__Host-ras-csrf"; -const DEFAULT_CSRF_HEADER: &str = "x-ras-csrf"; - -/// Header names that provide no CSRF protection because a browser either sends -/// them automatically cross-origin (CORS-safelisted request headers) or -/// populates them itself (forbidden headers a page cannot control). A CSRF -/// header must be a custom header, since only a custom header forces a CORS -/// preflight that a cross-site attacker cannot satisfy. -const CSRF_UNSAFE_HEADER_NAMES: &[&str] = &[ - // CORS-safelisted request headers — sent cross-origin without a preflight. - "accept", - "accept-language", - "content-language", - "content-type", - // Browser-controlled / forbidden headers — auto-sent, not page-settable. - "cookie", - "origin", - "referer", - "host", - "user-agent", - "content-length", - "connection", - "accept-encoding", - "date", -]; - -/// Source from which an authentication token was extracted. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AuthTokenSource { - /// `Authorization: Bearer ...` - Bearer, - /// Configured HTTP cookie. - Cookie, -} - -/// Authentication token extracted from an HTTP request. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AuthCredential { - token: String, - source: AuthTokenSource, -} - -impl AuthCredential { - /// Create a credential for tests or custom extractors. - pub fn new(token: impl Into, source: AuthTokenSource) -> Self { - Self { - token: token.into(), - source, - } - } - - /// The token value to pass to `AuthProvider::authenticate`. - pub fn token(&self) -> &str { - &self.token - } - - /// The transport that supplied the token. - pub fn source(&self) -> AuthTokenSource { - self.source - } -} - -/// Errors that can occur while extracting or validating HTTP auth credentials. -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum AuthTransportError { - /// No configured credential transport found a token. - #[error("missing authentication credentials")] - MissingCredentials, - - /// The `Authorization` header was present but was not a valid bearer token. - #[error("invalid authorization header")] - InvalidAuthorizationHeader, - - /// Cookie-authenticated request failed CSRF validation. - #[error("CSRF validation failed")] - CsrfValidationFailed, - - /// Cookie configuration is internally inconsistent. - #[error("invalid cookie configuration: {0}")] - InvalidCookieConfig(String), - - /// The request contained ambiguous or invalid cookie credentials. - #[error("invalid cookie header: {0}")] - InvalidCookieHeader(String), - - /// CSRF configuration is internally inconsistent. - #[error("invalid CSRF configuration: {0}")] - InvalidCsrfConfig(String), - - /// Auth transport configuration is internally inconsistent. - #[error("invalid auth transport configuration: {0}")] - InvalidAuthTransportConfig(String), - - /// Generated cookie header could not be represented as an HTTP header. - #[error("invalid set-cookie header: {0}")] - InvalidSetCookieHeader(String), -} - -/// SameSite setting for generated session cookies. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CookieSameSite { - /// Send cookies for same-site requests and top-level cross-site navigations. - Lax, - /// Send cookies only for same-site requests. - Strict, - /// Send cookies cross-site. Requires `Secure`. - None, -} - -impl CookieSameSite { - fn as_cookie_same_site(self) -> SameSite { - match self { - Self::Lax => SameSite::Lax, - Self::Strict => SameSite::Strict, - Self::None => SameSite::None, - } - } -} - -/// Configuration for accepting and emitting a session cookie. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AuthCookieConfig { - /// Cookie name. Defaults to a host-only secure-cookie prefix. - pub name: String, - /// Cookie path. Defaults to `/`. - pub path: String, - /// Optional cookie domain. Must remain `None` for `__Host-` cookies. - pub domain: Option, - /// Whether to emit `Secure`. - pub secure: bool, - /// Whether to emit `HttpOnly`. - pub http_only: bool, - /// SameSite policy. - pub same_site: CookieSameSite, - /// Optional `Max-Age` in seconds for the set-cookie helper. - pub max_age_seconds: Option, -} - -impl Default for AuthCookieConfig { - fn default() -> Self { - Self { - name: DEFAULT_COOKIE_NAME.to_string(), - path: "/".to_string(), - domain: None, - secure: true, - http_only: true, - same_site: CookieSameSite::Lax, - max_age_seconds: None, - } - } -} - -impl AuthCookieConfig { - /// Create a secure cookie configuration with a custom name. - /// - /// Prefer [`Self::default`] or [`Self::host_prefixed`] for production browser sessions. - /// Plain shared-domain names are easier to confuse with cookies set by subdomains. - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - ..Self::default() - } - } - - /// Create a secure `__Host-` prefixed cookie configuration with a custom suffix. - pub fn host_prefixed(name: impl Into) -> Self { - let name = name.into(); - let suffix = name.strip_prefix("__Host-").unwrap_or(&name); - Self { - name: format!("__Host-{suffix}"), - ..Self::default() - } - } - - /// Relax `Secure` for local HTTP development. - /// - /// Do not use this in production. - pub fn insecure_for_local_development(mut self) -> Self { - self.secure = false; - if let Some(name) = self.name.strip_prefix("__Host-") { - self.name = name.to_string(); - } - self - } - - /// Validate cookie prefix and browser-enforced security invariants. - pub fn validate(&self) -> Result<(), AuthTransportError> { - validate_cookie_name(&self.name)?; - - if self.path.trim().is_empty() { - return Err(AuthTransportError::InvalidCookieConfig( - "cookie path must not be empty".to_string(), - )); - } - - if !self.path.starts_with('/') { - return Err(AuthTransportError::InvalidCookieConfig( - "cookie path must start with '/'".to_string(), - )); - } - - if self.name.starts_with("__Secure-") && !self.secure { - return Err(AuthTransportError::InvalidCookieConfig( - "__Secure- cookies must be Secure".to_string(), - )); - } - - if self.name.starts_with("__Host-") { - if !self.secure { - return Err(AuthTransportError::InvalidCookieConfig( - "__Host- cookies must be Secure".to_string(), - )); - } - if self.domain.is_some() { - return Err(AuthTransportError::InvalidCookieConfig( - "__Host- cookies must not set Domain".to_string(), - )); - } - if self.path != "/" { - return Err(AuthTransportError::InvalidCookieConfig( - "__Host- cookies must use Path=/".to_string(), - )); - } - } - - if self.same_site == CookieSameSite::None && !self.secure { - return Err(AuthTransportError::InvalidCookieConfig( - "SameSite=None cookies must be Secure".to_string(), - )); - } - - if let Some(domain) = &self.domain - && domain.trim().is_empty() - { - return Err(AuthTransportError::InvalidCookieConfig( - "cookie domain must not be empty".to_string(), - )); - } - - Ok(()) - } - - /// Build a `Set-Cookie` header value for a newly issued session token. - pub fn session_cookie_header_value( - &self, - token: &str, - ) -> Result { - self.validate()?; - - let mut builder = Cookie::build((self.name.clone(), token.to_string())) - .path(self.path.clone()) - .secure(self.secure) - .http_only(self.http_only) - .same_site(self.same_site.as_cookie_same_site()); - - if let Some(domain) = &self.domain { - builder = builder.domain(domain.clone()); - } - - if let Some(max_age) = self.max_age_seconds { - builder = builder.max_age(Duration::seconds(max_age)); - } - - set_cookie_value(builder.build().to_string()) - } - - /// Build a `Set-Cookie` header value that clears this session cookie. - pub fn clear_cookie_header_value(&self) -> Result { - self.validate()?; - - let mut builder = Cookie::build((self.name.clone(), "")) - .path(self.path.clone()) - .secure(self.secure) - .http_only(self.http_only) - .same_site(self.same_site.as_cookie_same_site()) - .max_age(Duration::seconds(0)) - .expires(OffsetDateTime::UNIX_EPOCH); - - if let Some(domain) = &self.domain { - builder = builder.domain(domain.clone()); - } - - set_cookie_value(builder.build().to_string()) - } -} - -fn validate_cookie_name(name: &str) -> Result<(), AuthTransportError> { - if name.trim().is_empty() { - return Err(AuthTransportError::InvalidCookieConfig( - "cookie name must not be empty".to_string(), - )); - } - - if name.trim() != name { - return Err(AuthTransportError::InvalidCookieConfig( - "cookie name must not contain leading or trailing whitespace".to_string(), - )); - } - - for byte in name.bytes() { - if byte <= 0x20 - || byte >= 0x7f - || matches!( - byte, - b'(' | b')' - | b'<' - | b'>' - | b'@' - | b',' - | b';' - | b':' - | b'\\' - | b'"' - | b'/' - | b'[' - | b']' - | b'?' - | b'=' - | b'{' - | b'}' - ) - { - return Err(AuthTransportError::InvalidCookieConfig( - "cookie name must be a valid RFC6265 token".to_string(), - )); - } - } - - Ok(()) -} - -fn set_cookie_value(value: String) -> Result { - HeaderValue::from_str(&value) - .map_err(|err| AuthTransportError::InvalidSetCookieHeader(err.to_string())) -} - -/// CSRF guard configuration for cookie-authenticated unsafe requests. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CsrfConfig { - /// Header that must be present on unsafe cookie-authenticated requests. - pub header_name: HeaderName, - /// Optional exact value the header must carry. If set, this value is used - /// instead of double-submit cookie validation. - pub expected_value: Option, - /// Cookie whose value must match the CSRF header. Enabled by default. - pub cookie_name: Option, -} - -impl Default for CsrfConfig { - fn default() -> Self { - Self { - header_name: HeaderName::from_static(DEFAULT_CSRF_HEADER), - expected_value: None, - cookie_name: Some(DEFAULT_CSRF_COOKIE_NAME.to_string()), - } - } -} - -impl CsrfConfig { - /// Require a custom header and the default double-submit CSRF cookie. - pub fn new(header_name: HeaderName) -> Self { - Self { - header_name, - ..Self::default() - } - } - - /// Require the custom header to carry a single, static, process-wide value. - /// - /// **Dangerous.** A static value is not bound to a session: any attacker - /// who learns it once (from a leaked bundle, a shared client, or a single - /// captured request) can forge unsafe cookie-authenticated requests for - /// every user until the value is rotated. This disables the double-submit - /// cookie check. Prefer [`Self::default`] for browser sessions. - pub fn dangerous_static_value(mut self, expected_value: impl Into) -> Self { - self.expected_value = Some(expected_value.into()); - self.cookie_name = None; - self - } - - /// Deprecated alias for [`Self::dangerous_static_value`]. - #[deprecated( - since = "0.3.0", - note = "renamed to `dangerous_static_value`; a static CSRF value is not \ - bound to a session and is a weak CSRF defense" - )] - pub fn with_expected_value(self, expected_value: impl Into) -> Self { - self.dangerous_static_value(expected_value) - } - - /// Require the custom header to match this CSRF cookie. - pub fn with_cookie_name(mut self, cookie_name: impl Into) -> Self { - self.cookie_name = Some(cookie_name.into()); - self.expected_value = None; - self - } - - /// Require only a non-empty custom header. - /// - /// **Dangerous.** This mode relies entirely on the browser refusing to send - /// a custom header cross-origin without a successful CORS preflight. It is - /// only sound behind a restrictive credentialed CORS policy and is not a - /// complete CSRF defense by itself. Prefer [`Self::default`] for browser - /// sessions. - pub fn dangerous_header_presence_only(header_name: HeaderName) -> Self { - Self { - header_name, - expected_value: None, - cookie_name: None, - } - } - - /// Deprecated alias for [`Self::dangerous_header_presence_only`]. - #[deprecated( - since = "0.3.0", - note = "renamed to `dangerous_header_presence_only`; presence-only CSRF \ - depends on restrictive CORS and is a weak CSRF defense" - )] - pub fn header_presence_only(header_name: HeaderName) -> Self { - Self::dangerous_header_presence_only(header_name) - } - - /// Whether this configuration uses one of the weak, opt-in modes - /// ([`Self::dangerous_static_value`] or - /// [`Self::dangerous_header_presence_only`]) rather than the default - /// double-submit cookie check. - /// - /// Returns the mode name for logging, or `None` for the double-submit mode. - pub fn dangerous_mode(&self) -> Option<&'static str> { - match (&self.expected_value, &self.cookie_name) { - (Some(_), _) => Some("static_value"), - (None, None) => Some("header_presence_only"), - (None, Some(_)) => None, - } - } - - /// Emit a `warn!` if this CSRF config is in a weak mode. Called from the - /// [`AuthTransportConfig`] builders (once per construction) and, as a - /// fallback for struct-literal construction, once per process from - /// [`AuthTransportConfig::validate`]. - fn warn_if_dangerous(&self) { - if let Some(mode) = self.dangerous_mode() { - tracing::warn!( - csrf_mode = mode, - csrf_header = %self.header_name, - "cookie auth is configured with a weak CSRF mode \ - (`CsrfConfig::dangerous_*`); this is not a complete CSRF defense. \ - Prefer the default double-submit cookie mode for browser sessions" - ); - } - } - - /// Build a `Set-Cookie` header value for the double-submit CSRF token. - /// - /// The CSRF cookie is intentionally not `HttpOnly` so browser clients can - /// copy its value into the configured CSRF header. - pub fn csrf_cookie_header_value(&self, token: &str) -> Result { - self.csrf_cookie_config()? - .session_cookie_header_value(token) - } - - /// Build a `Set-Cookie` header value that clears the CSRF cookie. - pub fn clear_csrf_cookie_header_value(&self) -> Result { - self.csrf_cookie_config()?.clear_cookie_header_value() - } - - /// Validate CSRF configuration. - pub fn validate(&self) -> Result<(), AuthTransportError> { - // A CORS-safelisted or browser-controlled header name provides zero CSRF - // protection (it is sent automatically cross-origin), so reject it — - // otherwise `dangerous_header_presence_only(HeaderName::from_static("accept"))` - // would produce a config that passes validation but never blocks a - // forged request. - let header = self.header_name.as_str(); - if CSRF_UNSAFE_HEADER_NAMES - .iter() - .any(|name| header.eq_ignore_ascii_case(name)) - { - return Err(AuthTransportError::InvalidCsrfConfig(format!( - "CSRF header `{header}` is CORS-safelisted or browser-controlled \ - and provides no protection; use a custom header name (e.g. \ - `x-csrf-token`)" - ))); - } - - if let Some(expected) = &self.expected_value - && expected.trim().is_empty() - { - return Err(AuthTransportError::InvalidCsrfConfig( - "expected CSRF value must not be empty".to_string(), - )); - } - - if let Some(cookie_name) = &self.cookie_name { - let cookie = AuthCookieConfig { - name: cookie_name.clone(), - http_only: false, - ..AuthCookieConfig::default() - }; - cookie.validate()?; - } - - Ok(()) - } - - fn validate_headers(&self, headers: &HeaderMap) -> Result<(), AuthTransportError> { - self.validate()?; - - let value = headers - .get(&self.header_name) - .ok_or(AuthTransportError::CsrfValidationFailed)?; - let value = value - .to_str() - .map_err(|_| AuthTransportError::CsrfValidationFailed)?; - - if value.trim().is_empty() { - return Err(AuthTransportError::CsrfValidationFailed); - } - - if let Some(expected) = &self.expected_value - && !ct_eq_str(value, expected) - { - return Err(AuthTransportError::CsrfValidationFailed); - } - - if self.expected_value.is_some() { - return Ok(()); - } - - if let Some(cookie_name) = &self.cookie_name { - let Some(cookie_value) = extract_cookie(headers, cookie_name)? else { - return Err(AuthTransportError::CsrfValidationFailed); - }; - - if cookie_value.trim().is_empty() || !ct_eq_str(&cookie_value, value) { - return Err(AuthTransportError::CsrfValidationFailed); - } - } - - Ok(()) - } - - fn csrf_cookie_config(&self) -> Result { - let cookie_name = self.cookie_name.as_ref().ok_or_else(|| { - AuthTransportError::InvalidCsrfConfig( - "CSRF cookie helper requires cookie validation mode".to_string(), - ) - })?; - - let cookie = AuthCookieConfig { - name: cookie_name.clone(), - http_only: false, - ..AuthCookieConfig::default() - }; - cookie.validate()?; - Ok(cookie) - } -} - -/// Configures which HTTP transports a generated service accepts for auth. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AuthTransportConfig { - /// Accept `Authorization: Bearer ...`. - pub bearer: bool, - /// Optional secure cookie credential transport. - pub cookie: Option, - /// Optional CSRF guard for cookie-authenticated unsafe requests. - pub csrf: Option, -} - -impl Default for AuthTransportConfig { - fn default() -> Self { - Self { - bearer: true, - cookie: None, - csrf: None, - } - } -} - -impl AuthTransportConfig { - /// Enable cookie auth alongside the default bearer transport. - /// - /// Cookie credentials are vulnerable to CSRF on unsafe methods, so this also - /// installs a default double-submit [`CsrfConfig`] when none is configured - /// yet. Override it with [`Self::with_csrf`] if you need a different policy; - /// there is intentionally no builder path to cookie auth without CSRF. - pub fn with_cookie(mut self, cookie: AuthCookieConfig) -> Self { - self.cookie = Some(cookie); - if self.csrf.is_none() { - self.csrf = Some(CsrfConfig::default()); - } - self.warn_if_weak_csrf(); - self - } - - /// Enable CSRF protection for cookie-authenticated unsafe requests. - /// - /// Passing a `CsrfConfig::dangerous_*` mode together with cookie auth logs - /// a `warn!` at construction time. - pub fn with_csrf(mut self, csrf: CsrfConfig) -> Self { - self.csrf = Some(csrf); - self.warn_if_weak_csrf(); - self - } - - /// Log a warning when cookie auth is paired with a weak CSRF mode. - fn warn_if_weak_csrf(&self) { - if self.cookie.is_some() - && let Some(csrf) = &self.csrf - { - csrf.warn_if_dangerous(); - } - } - - /// Disable bearer-token extraction. - pub fn without_bearer(mut self) -> Self { - self.bearer = false; - self - } - - /// Validate all configured auth transports. - pub fn validate(&self) -> Result<(), AuthTransportError> { - if !self.bearer && self.cookie.is_none() { - return Err(AuthTransportError::InvalidAuthTransportConfig( - "at least one auth transport must be enabled".to_string(), - )); - } - - // Cookie credentials are automatically attached by the browser, so - // cookie auth without a CSRF guard lets any cross-site request act as - // the victim on unsafe methods. `with_cookie` installs a default CSRF - // config; a struct literal that clears it must fail closed here. - if self.cookie.is_some() && self.csrf.is_none() { - return Err(AuthTransportError::InvalidAuthTransportConfig( - "cookie auth requires a CSRF configuration; use with_cookie (which sets a \ - default double-submit CsrfConfig) or with_csrf" - .to_string(), - )); - } - - if let Some(cookie) = &self.cookie { - cookie.validate()?; - } - - if let Some(csrf) = &self.csrf { - csrf.validate()?; - } - - // `validate` runs on every request, so the weak-mode warning is - // rate-limited here to once per distinct weak config per process. The - // builders (`with_cookie`, `with_csrf`) warn unconditionally at - // construction time; this is the fallback for struct-literal configs. - if self.cookie.is_some() - && let Some(csrf) = &self.csrf - && let Some(mode) = csrf.dangerous_mode() - { - static WEAK_CSRF_WARNED: std::sync::Mutex> = - std::sync::Mutex::new(Vec::new()); - let key = (csrf.header_name.as_str().to_string(), mode); - let mut warned = WEAK_CSRF_WARNED - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if !warned.contains(&key) { - warned.push(key); - csrf.warn_if_dangerous(); - } - } - - Ok(()) - } -} - -/// Extract an auth credential from configured HTTP transports. -pub fn extract_auth_credential( - headers: &HeaderMap, - config: &AuthTransportConfig, -) -> Result { - config.validate()?; - - if config.bearer - && let Some(header) = headers.get(AUTHORIZATION) - { - let header = header - .to_str() - .map_err(|_| AuthTransportError::InvalidAuthorizationHeader)?; - let (scheme, token) = header - .split_once(' ') - .ok_or(AuthTransportError::InvalidAuthorizationHeader)?; - if !scheme.eq_ignore_ascii_case("Bearer") || token.trim().is_empty() { - return Err(AuthTransportError::InvalidAuthorizationHeader); - } - let token = token.trim(); - - return Ok(AuthCredential::new(token, AuthTokenSource::Bearer)); - } - - if let Some(cookie_config) = &config.cookie - && let Some(token) = extract_cookie(headers, &cookie_config.name)? - { - return Ok(AuthCredential::new(token, AuthTokenSource::Cookie)); - } - - Err(AuthTransportError::MissingCredentials) -} - -/// Validate CSRF policy for a previously extracted credential. -pub fn validate_csrf_for_credential( - method: &str, - headers: &HeaderMap, - credential: &AuthCredential, - config: &AuthTransportConfig, -) -> Result<(), AuthTransportError> { - config.validate()?; - - if credential.source != AuthTokenSource::Cookie || !is_unsafe_method(method) { - return Ok(()); - } - - match &config.csrf { - Some(csrf) => csrf.validate_headers(headers), - None => Ok(()), - } -} - -/// Header name used by cookie helper return values. -pub fn set_cookie_header_name() -> HeaderName { - SET_COOKIE -} - -/// Clone headers with known credential-bearing values replaced by `[REDACTED]`. -pub fn redact_sensitive_headers(headers: &HeaderMap) -> HeaderMap { - let mut redacted = headers.clone(); - - redact_header(&mut redacted, AUTHORIZATION); - redact_header(&mut redacted, COOKIE); - redact_header(&mut redacted, SET_COOKIE); - redact_header( - &mut redacted, - HeaderName::from_static("proxy-authorization"), - ); - redact_header(&mut redacted, HeaderName::from_static("x-auth-token")); - redact_header(&mut redacted, HeaderName::from_static("x-api-key")); - redact_header(&mut redacted, HeaderName::from_static("x-csrf-token")); - redact_header(&mut redacted, HeaderName::from_static("x-xsrf-token")); - redact_header(&mut redacted, HeaderName::from_static(DEFAULT_CSRF_HEADER)); - redact_header( - &mut redacted, - HeaderName::from_static("sec-websocket-protocol"), - ); - - redacted -} - -/// Clone headers with default sensitive values and configured auth transport -/// header secrets replaced by `[REDACTED]`. -pub fn redact_sensitive_headers_for_auth_transport( - headers: &HeaderMap, - config: &AuthTransportConfig, -) -> HeaderMap { - let mut redacted = redact_sensitive_headers(headers); - - if let Some(csrf) = &config.csrf { - redact_header(&mut redacted, csrf.header_name.clone()); - } - - redacted -} - -fn redact_header(headers: &mut HeaderMap, name: HeaderName) { - if headers.contains_key(&name) { - headers.remove(&name); - headers.insert(name, HeaderValue::from_static("[REDACTED]")); - } -} - -/// Constant-time string comparison for CSRF tokens. -/// -/// Length is allowed to leak (subtle short-circuits on differing lengths), but -/// equal-length values are compared without an input-dependent early return. -fn ct_eq_str(a: &str, b: &str) -> bool { - a.as_bytes().ct_eq(b.as_bytes()).into() -} - -fn is_unsafe_method(method: &str) -> bool { - matches!( - method.to_ascii_uppercase().as_str(), - "POST" | "PUT" | "PATCH" | "DELETE" - ) -} - -fn extract_cookie( - headers: &HeaderMap, - cookie_name: &str, -) -> Result, AuthTransportError> { - let mut found = None; - - for value in headers.get_all(COOKIE) { - let Ok(raw) = value.to_str() else { - continue; - }; - - for cookie in Cookie::split_parse(raw).filter_map(Result::ok) { - if cookie.name() == cookie_name { - if found.is_some() { - return Err(AuthTransportError::InvalidCookieHeader(format!( - "multiple {cookie_name} cookies were present" - ))); - } - found = Some(cookie.value().to_string()); - } - } - } - - Ok(found) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn headers(pairs: &[(&str, &str)]) -> HeaderMap { - let mut headers = HeaderMap::new(); - for (name, value) in pairs { - headers.append( - HeaderName::from_bytes(name.as_bytes()).unwrap(), - HeaderValue::from_str(value).unwrap(), - ); - } - headers - } - - #[test] - fn extract_auth_credential_returns_bearer_token() { - let headers = headers(&[("authorization", "Bearer abc123")]); - - let credential = extract_auth_credential(&headers, &AuthTransportConfig::default()) - .expect("bearer extracts"); - - assert_eq!(credential.token(), "abc123"); - assert_eq!(credential.source(), AuthTokenSource::Bearer); - } - - #[test] - fn extract_auth_credential_accepts_case_insensitive_bearer_scheme() { - let headers = headers(&[("authorization", "bearer abc123")]); - - let credential = extract_auth_credential(&headers, &AuthTransportConfig::default()) - .expect("bearer extracts"); - - assert_eq!(credential.token(), "abc123"); - assert_eq!(credential.source(), AuthTokenSource::Bearer); - } - - #[test] - fn extract_auth_credential_returns_cookie_when_bearer_absent() { - let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); - let headers = headers(&[("cookie", "theme=dark; __Host-ras-session=cookie-token")]); - - let credential = extract_auth_credential(&headers, &config).expect("cookie extracts"); - - assert_eq!(credential.token(), "cookie-token"); - assert_eq!(credential.source(), AuthTokenSource::Cookie); - } - - #[test] - fn extract_auth_credential_rejects_malformed_bearer_without_cookie_fallback() { - let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); - let headers = headers(&[ - ("authorization", "Basic abc123"), - ("cookie", "__Host-ras-session=cookie-token"), - ]); - - let error = extract_auth_credential(&headers, &config).unwrap_err(); - - assert_eq!(error, AuthTransportError::InvalidAuthorizationHeader); - } - - #[test] - fn extract_auth_credential_prefers_bearer_when_both_are_present() { - let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); - let headers = headers(&[ - ("authorization", "Bearer bearer-token"), - ("cookie", "__Host-ras-session=cookie-token"), - ]); - - let credential = extract_auth_credential(&headers, &config).expect("credential extracts"); - - assert_eq!(credential.token(), "bearer-token"); - assert_eq!(credential.source(), AuthTokenSource::Bearer); - } - - #[test] - fn extract_auth_credential_rejects_duplicate_session_cookies() { - let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); - let headers = headers(&[( - "cookie", - "__Host-ras-session=first; __Host-ras-session=second", - )]); - - let error = extract_auth_credential(&headers, &config).unwrap_err(); - - assert!(matches!(error, AuthTransportError::InvalidCookieHeader(_))); - } - - #[test] - fn auth_cookie_config_validates_host_prefix_constraints() { - assert!(AuthCookieConfig::default().validate().is_ok()); - - let error = AuthCookieConfig { - secure: false, - ..AuthCookieConfig::default() - } - .validate() - .unwrap_err(); - assert!(matches!(error, AuthTransportError::InvalidCookieConfig(_))); - - let error = AuthCookieConfig { - domain: Some("example.com".to_string()), - ..AuthCookieConfig::default() - } - .validate() - .unwrap_err(); - assert!(matches!(error, AuthTransportError::InvalidCookieConfig(_))); - } - - #[test] - fn auth_cookie_config_validates_secure_prefix_and_cookie_name() { - let error = AuthCookieConfig { - name: "__Secure-ras-session".to_string(), - secure: false, - ..AuthCookieConfig::default() - } - .validate() - .unwrap_err(); - assert!(matches!(error, AuthTransportError::InvalidCookieConfig(_))); - - let error = AuthCookieConfig::new("bad;name").validate().unwrap_err(); - assert!(matches!(error, AuthTransportError::InvalidCookieConfig(_))); - } - - #[test] - fn auth_transport_config_validates_cookie_config_before_extraction() { - let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig { - secure: false, - ..AuthCookieConfig::default() - }); - - let error = extract_auth_credential(&HeaderMap::new(), &config).unwrap_err(); - - assert!(matches!(error, AuthTransportError::InvalidCookieConfig(_))); - } - - #[test] - fn local_development_cookie_helper_removes_host_prefix() { - let cookie = AuthCookieConfig::default().insecure_for_local_development(); - - assert_eq!(cookie.name, "ras-session"); - assert!(!cookie.secure); - assert!(cookie.validate().is_ok()); - } - - #[test] - fn auth_cookie_config_builds_secure_set_cookie_header() { - let value = AuthCookieConfig::default() - .session_cookie_header_value("jwt-token") - .expect("set-cookie header"); - let value = value.to_str().unwrap(); - - assert!(value.starts_with("__Host-ras-session=jwt-token")); - assert!(value.contains("HttpOnly")); - assert!(value.contains("SameSite=Lax")); - assert!(value.contains("Secure")); - assert!(value.contains("Path=/")); - } - - #[test] - fn auth_cookie_config_builds_clear_cookie_header() { - let value = AuthCookieConfig::default() - .clear_cookie_header_value() - .expect("clear-cookie header"); - let value = value.to_str().unwrap(); - - assert!(value.starts_with("__Host-ras-session=")); - assert!(value.contains("Max-Age=0")); - assert!(value.contains("Expires=")); - assert!(value.contains("HttpOnly")); - assert!(value.contains("Path=/")); - } - - #[test] - fn csrf_validation_only_applies_to_cookie_auth_on_unsafe_methods() { - let config = AuthTransportConfig::default() - .with_cookie(AuthCookieConfig::default()) - .with_csrf(CsrfConfig::default()); - let bearer = AuthCredential::new("bearer-token", AuthTokenSource::Bearer); - let cookie = AuthCredential::new("cookie-token", AuthTokenSource::Cookie); - let headers_without_csrf = HeaderMap::new(); - let headers_with_csrf = headers(&[ - (DEFAULT_CSRF_HEADER, "csrf-token"), - ("cookie", "__Host-ras-csrf=csrf-token"), - ]); - let headers_with_mismatched_csrf = headers(&[ - (DEFAULT_CSRF_HEADER, "csrf-token"), - ("cookie", "__Host-ras-csrf=other-token"), - ]); - - assert!( - validate_csrf_for_credential("POST", &headers_without_csrf, &bearer, &config).is_ok() - ); - assert!( - validate_csrf_for_credential("GET", &headers_without_csrf, &cookie, &config).is_ok() - ); - assert_eq!( - validate_csrf_for_credential("POST", &headers_without_csrf, &cookie, &config) - .unwrap_err(), - AuthTransportError::CsrfValidationFailed - ); - assert!(validate_csrf_for_credential("POST", &headers_with_csrf, &cookie, &config).is_ok()); - assert_eq!( - validate_csrf_for_credential("POST", &headers_with_mismatched_csrf, &cookie, &config) - .unwrap_err(), - AuthTransportError::CsrfValidationFailed - ); - } - - #[test] - fn csrf_expected_value_mode_does_not_require_csrf_cookie() { - let config = AuthTransportConfig::default() - .with_cookie(AuthCookieConfig::default()) - .with_csrf(CsrfConfig::default().dangerous_static_value("csrf-token")); - let cookie = AuthCredential::new("cookie-token", AuthTokenSource::Cookie); - let headers = headers(&[(DEFAULT_CSRF_HEADER, "csrf-token")]); - - assert!(validate_csrf_for_credential("POST", &headers, &cookie, &config).is_ok()); - } - - #[test] - fn csrf_config_builds_readable_double_submit_cookie() { - let value = CsrfConfig::default() - .csrf_cookie_header_value("csrf-token") - .expect("set-cookie header"); - let value = value.to_str().unwrap(); - - assert!(value.starts_with("__Host-ras-csrf=csrf-token")); - assert!(!value.contains("HttpOnly")); - assert!(value.contains("SameSite=Lax")); - assert!(value.contains("Secure")); - assert!(value.contains("Path=/")); - } - - #[test] - fn redact_sensitive_headers_removes_credential_values() { - let headers = headers(&[ - ("authorization", "Bearer secret"), - ("cookie", "__Host-ras-session=secret"), - (DEFAULT_CSRF_HEADER, "csrf-secret"), - ("user-agent", "test-agent"), - ]); - - let redacted = redact_sensitive_headers(&headers); - - assert_eq!( - redacted.get("authorization").unwrap(), - HeaderValue::from_static("[REDACTED]") - ); - assert_eq!( - redacted.get("cookie").unwrap(), - HeaderValue::from_static("[REDACTED]") - ); - assert_eq!( - redacted.get(DEFAULT_CSRF_HEADER).unwrap(), - HeaderValue::from_static("[REDACTED]") - ); - assert_eq!(redacted.get("user-agent").unwrap(), "test-agent"); - } - - #[test] - fn with_cookie_installs_default_csrf_and_validates() { - let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); - - assert!(config.csrf.is_some()); - assert!(config.validate().is_ok()); - } - - #[test] - fn csrf_config_rejects_cors_safelisted_header_names() { - // A safelisted / browser-controlled header name provides no CSRF - // protection and must fail validation even though it is "present". - for name in [ - "accept", - "content-type", - "Accept-Language", - "cookie", - "origin", - ] { - let csrf = CsrfConfig::dangerous_header_presence_only( - HeaderName::from_bytes(name.as_bytes()).unwrap(), - ); - let error = csrf.validate().expect_err(name); - assert!( - matches!(error, AuthTransportError::InvalidCsrfConfig(_)), - "{name} should be rejected" - ); - } - - // A genuinely custom header (forces a CORS preflight) is accepted. - let ok = - CsrfConfig::dangerous_header_presence_only(HeaderName::from_static("x-csrf-token")); - assert!(ok.validate().is_ok()); - } - - #[test] - fn cookie_without_csrf_fails_validate() { - let config = AuthTransportConfig { - bearer: true, - cookie: Some(AuthCookieConfig::default()), - csrf: None, - }; - - let error = config.validate().unwrap_err(); - - assert!(matches!( - error, - AuthTransportError::InvalidAuthTransportConfig(_) - )); - } - - #[test] - fn with_cookie_default_still_requires_csrf_header_on_unsafe_cookie_request() { - let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); - let cookie = AuthCredential::new("cookie-token", AuthTokenSource::Cookie); - - // No CSRF header present -> unsafe cookie request is rejected. - assert_eq!( - validate_csrf_for_credential("POST", &HeaderMap::new(), &cookie, &config).unwrap_err(), - AuthTransportError::CsrfValidationFailed - ); - - // Bearer credentials stay exempt even on unsafe methods. - let bearer = AuthCredential::new("bearer-token", AuthTokenSource::Bearer); - assert!(validate_csrf_for_credential("POST", &HeaderMap::new(), &bearer, &config).is_ok()); - - // GET cookie requests stay exempt. - assert!(validate_csrf_for_credential("GET", &HeaderMap::new(), &cookie, &config).is_ok()); - - // Valid double-submit header + cookie passes. - let headers = headers(&[ - (DEFAULT_CSRF_HEADER, "csrf-token"), - ("cookie", "__Host-ras-csrf=csrf-token"), - ]); - assert!(validate_csrf_for_credential("POST", &headers, &cookie, &config).is_ok()); - } - - #[test] - fn redact_sensitive_headers_for_auth_transport_removes_custom_csrf_header() { - let csrf_header = HeaderName::from_static("x-custom-csrf"); - let config = AuthTransportConfig::default().with_csrf(CsrfConfig::new(csrf_header.clone())); - let headers = headers(&[("x-custom-csrf", "csrf-secret")]); - - let redacted = redact_sensitive_headers_for_auth_transport(&headers, &config); - - assert_eq!( - redacted.get(csrf_header).unwrap(), - HeaderValue::from_static("[REDACTED]") - ); - } - - /// Minimal `tracing` subscriber that records the messages of `WARN` events. - /// Kept dependency-free (no `tracing-subscriber`) since it only needs to - /// capture a handful of events for the A1 regression tests. - struct WarnCapture(std::sync::Mutex>); - - impl tracing::Subscriber for WarnCapture { - fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { - *metadata.level() <= tracing::Level::WARN - } - fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { - tracing::span::Id::from_u64(1) - } - fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} - fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} - fn event(&self, event: &tracing::Event<'_>) { - struct Msg(String); - impl tracing::field::Visit for Msg { - fn record_debug( - &mut self, - field: &tracing::field::Field, - value: &dyn std::fmt::Debug, - ) { - self.0.push_str(&format!("{}={:?} ", field.name(), value)); - } - } - let mut msg = Msg(String::new()); - event.record(&mut msg); - self.0.lock().unwrap().push(msg.0); - } - fn enter(&self, _: &tracing::span::Id) {} - fn exit(&self, _: &tracing::span::Id) {} - } - - fn capture_warnings(f: impl FnOnce()) -> Vec { - let capture = std::sync::Arc::new(WarnCapture(std::sync::Mutex::new(Vec::new()))); - tracing::subscriber::with_default(capture.clone(), f); - capture.0.lock().unwrap().clone() - } - - #[test] - fn a1_dangerous_modes_are_reported_and_deprecated_aliases_still_work() { - let presence = - CsrfConfig::dangerous_header_presence_only(HeaderName::from_static("x-csrf-token")); - assert_eq!(presence.dangerous_mode(), Some("header_presence_only")); - - let static_value = CsrfConfig::default().dangerous_static_value("shared-secret"); - assert_eq!(static_value.dangerous_mode(), Some("static_value")); - - assert_eq!(CsrfConfig::default().dangerous_mode(), None); - assert_eq!( - CsrfConfig::default() - .with_cookie_name("__Host-other") - .dangerous_mode(), - None - ); - - // The deprecated names remain as thin wrappers for one release. - #[allow(deprecated)] - let legacy_presence = - CsrfConfig::header_presence_only(HeaderName::from_static("x-csrf-token")); - assert_eq!(legacy_presence, presence); - #[allow(deprecated)] - let legacy_static = CsrfConfig::default().with_expected_value("shared-secret"); - assert_eq!(legacy_static, static_value); - } - - #[test] - fn a1_cookie_auth_with_weak_csrf_mode_warns_at_construction() { - let warnings = capture_warnings(|| { - let _ = AuthTransportConfig::default() - .with_cookie(AuthCookieConfig::default()) - .with_csrf(CsrfConfig::dangerous_header_presence_only( - HeaderName::from_static("x-csrf-token"), - )); - }); - assert_eq!(warnings.len(), 1, "{warnings:?}"); - assert!(warnings[0].contains("csrf_mode=\"header_presence_only\"")); - assert!(warnings[0].contains("weak CSRF mode")); - - // Ordering does not matter: csrf first, then cookie. - let warnings = capture_warnings(|| { - let _ = AuthTransportConfig::default() - .with_csrf(CsrfConfig::default().dangerous_static_value("shared-secret")) - .with_cookie(AuthCookieConfig::default()); - }); - assert_eq!(warnings.len(), 1, "{warnings:?}"); - assert!(warnings[0].contains("csrf_mode=\"static_value\"")); - - // Weak CSRF without cookie auth is irrelevant (bearer-only) — no warning. - let warnings = capture_warnings(|| { - let _ = AuthTransportConfig::default().with_csrf( - CsrfConfig::dangerous_header_presence_only(HeaderName::from_static("x-csrf-token")), - ); - }); - assert!(warnings.is_empty(), "{warnings:?}"); - - // The default double-submit mode never warns. - let warnings = capture_warnings(|| { - let _ = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); - }); - assert!(warnings.is_empty(), "{warnings:?}"); - } - - #[test] - fn a1_struct_literal_weak_csrf_warns_from_validate_once() { - // Struct-literal construction bypasses the builders, so `validate` - // warns as a fallback — but only once per distinct weak config per - // process, since it runs on every request. A header name unique to - // this test keeps it independent of test ordering. - let config = AuthTransportConfig { - bearer: true, - cookie: Some(AuthCookieConfig::default()), - csrf: Some(CsrfConfig::dangerous_header_presence_only( - HeaderName::from_static("x-a1-struct-literal-csrf"), - )), - }; - let warnings = capture_warnings(|| { - config.validate().unwrap(); - config.validate().unwrap(); - config.validate().unwrap(); - }); - assert_eq!(warnings.len(), 1, "{warnings:?}"); - assert!(warnings[0].contains("csrf_mode=\"header_presence_only\"")); - } -} diff --git a/crates/core/ras-auth-core/src/transport/cookie.rs b/crates/core/ras-auth-core/src/transport/cookie.rs new file mode 100644 index 0000000..5f7cc4d --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/cookie.rs @@ -0,0 +1,279 @@ +use super::AuthTransportError; +use ::cookie::{ + Cookie, SameSite, + time::{Duration, OffsetDateTime}, +}; +use http::{ + HeaderMap, HeaderValue, + header::{COOKIE, HeaderName, SET_COOKIE}, +}; +const DEFAULT_COOKIE_NAME: &str = "__Host-ras-session"; + +/// SameSite setting for generated session cookies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CookieSameSite { + /// Send cookies for same-site requests and top-level cross-site navigations. + Lax, + /// Send cookies only for same-site requests. + Strict, + /// Send cookies cross-site. Requires `Secure`. + None, +} + +impl CookieSameSite { + fn as_cookie_same_site(self) -> SameSite { + match self { + Self::Lax => SameSite::Lax, + Self::Strict => SameSite::Strict, + Self::None => SameSite::None, + } + } +} + +/// Configuration for accepting and emitting a session cookie. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthCookieConfig { + /// Cookie name. Defaults to a host-only secure-cookie prefix. + pub name: String, + /// Cookie path. Defaults to `/`. + pub path: String, + /// Optional cookie domain. Must remain `None` for `__Host-` cookies. + pub domain: Option, + /// Whether to emit `Secure`. + pub secure: bool, + /// Whether to emit `HttpOnly`. + pub http_only: bool, + /// SameSite policy. + pub same_site: CookieSameSite, + /// Optional `Max-Age` in seconds for the set-cookie helper. + pub max_age_seconds: Option, +} + +impl Default for AuthCookieConfig { + fn default() -> Self { + Self { + name: DEFAULT_COOKIE_NAME.to_string(), + path: "/".to_string(), + domain: None, + secure: true, + http_only: true, + same_site: CookieSameSite::Lax, + max_age_seconds: None, + } + } +} + +impl AuthCookieConfig { + /// Create a secure cookie configuration with a custom name. + /// + /// Prefer [`Self::default`] or [`Self::host_prefixed`] for production browser sessions. + /// Plain shared-domain names are easier to confuse with cookies set by subdomains. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + ..Self::default() + } + } + + /// Create a secure `__Host-` prefixed cookie configuration with a custom suffix. + pub fn host_prefixed(name: impl Into) -> Self { + let name = name.into(); + let suffix = name.strip_prefix("__Host-").unwrap_or(&name); + Self { + name: format!("__Host-{suffix}"), + ..Self::default() + } + } + + /// Relax `Secure` for local HTTP development. + /// + /// Do not use this in production. + pub fn insecure_for_local_development(mut self) -> Self { + self.secure = false; + if let Some(name) = self.name.strip_prefix("__Host-") { + self.name = name.to_string(); + } + self + } + + /// Validate cookie prefix and browser-enforced security invariants. + pub fn validate(&self) -> Result<(), AuthTransportError> { + validate_cookie_name(&self.name)?; + + if self.path.trim().is_empty() { + return Err(AuthTransportError::InvalidCookieConfig( + "cookie path must not be empty".to_string(), + )); + } + + if !self.path.starts_with('/') { + return Err(AuthTransportError::InvalidCookieConfig( + "cookie path must start with '/'".to_string(), + )); + } + + if self.name.starts_with("__Secure-") && !self.secure { + return Err(AuthTransportError::InvalidCookieConfig( + "__Secure- cookies must be Secure".to_string(), + )); + } + + if self.name.starts_with("__Host-") { + if !self.secure { + return Err(AuthTransportError::InvalidCookieConfig( + "__Host- cookies must be Secure".to_string(), + )); + } + if self.domain.is_some() { + return Err(AuthTransportError::InvalidCookieConfig( + "__Host- cookies must not set Domain".to_string(), + )); + } + if self.path != "/" { + return Err(AuthTransportError::InvalidCookieConfig( + "__Host- cookies must use Path=/".to_string(), + )); + } + } + + if self.same_site == CookieSameSite::None && !self.secure { + return Err(AuthTransportError::InvalidCookieConfig( + "SameSite=None cookies must be Secure".to_string(), + )); + } + + if let Some(domain) = &self.domain + && domain.trim().is_empty() + { + return Err(AuthTransportError::InvalidCookieConfig( + "cookie domain must not be empty".to_string(), + )); + } + + Ok(()) + } + + /// Build a `Set-Cookie` header value for a newly issued session token. + pub fn session_cookie_header_value( + &self, + token: &str, + ) -> Result { + self.validate()?; + + let mut builder = Cookie::build((self.name.clone(), token.to_string())) + .path(self.path.clone()) + .secure(self.secure) + .http_only(self.http_only) + .same_site(self.same_site.as_cookie_same_site()); + + if let Some(domain) = &self.domain { + builder = builder.domain(domain.clone()); + } + + if let Some(max_age) = self.max_age_seconds { + builder = builder.max_age(Duration::seconds(max_age)); + } + + set_cookie_value(builder.build().to_string()) + } + + /// Build a `Set-Cookie` header value that clears this session cookie. + pub fn clear_cookie_header_value(&self) -> Result { + self.validate()?; + + let mut builder = Cookie::build((self.name.clone(), "")) + .path(self.path.clone()) + .secure(self.secure) + .http_only(self.http_only) + .same_site(self.same_site.as_cookie_same_site()) + .max_age(Duration::seconds(0)) + .expires(OffsetDateTime::UNIX_EPOCH); + + if let Some(domain) = &self.domain { + builder = builder.domain(domain.clone()); + } + + set_cookie_value(builder.build().to_string()) + } +} + +fn validate_cookie_name(name: &str) -> Result<(), AuthTransportError> { + if name.trim().is_empty() { + return Err(AuthTransportError::InvalidCookieConfig( + "cookie name must not be empty".to_string(), + )); + } + + if name.trim() != name { + return Err(AuthTransportError::InvalidCookieConfig( + "cookie name must not contain leading or trailing whitespace".to_string(), + )); + } + + for byte in name.bytes() { + if byte <= 0x20 + || byte >= 0x7f + || matches!( + byte, + b'(' | b')' + | b'<' + | b'>' + | b'@' + | b',' + | b';' + | b':' + | b'\\' + | b'"' + | b'/' + | b'[' + | b']' + | b'?' + | b'=' + | b'{' + | b'}' + ) + { + return Err(AuthTransportError::InvalidCookieConfig( + "cookie name must be a valid RFC6265 token".to_string(), + )); + } + } + + Ok(()) +} + +fn set_cookie_value(value: String) -> Result { + HeaderValue::from_str(&value) + .map_err(|err| AuthTransportError::InvalidSetCookieHeader(err.to_string())) +} + +/// Header name used by cookie helper return values. +pub fn set_cookie_header_name() -> HeaderName { + SET_COOKIE +} + +pub(super) fn extract_cookie( + headers: &HeaderMap, + cookie_name: &str, +) -> Result, AuthTransportError> { + let mut found = None; + + for value in headers.get_all(COOKIE) { + let Ok(raw) = value.to_str() else { + continue; + }; + + for cookie in Cookie::split_parse(raw).filter_map(Result::ok) { + if cookie.name() == cookie_name { + if found.is_some() { + return Err(AuthTransportError::InvalidCookieHeader(format!( + "multiple {cookie_name} cookies were present" + ))); + } + found = Some(cookie.value().to_string()); + } + } + } + + Ok(found) +} diff --git a/crates/core/ras-auth-core/src/transport/credential.rs b/crates/core/ras-auth-core/src/transport/credential.rs new file mode 100644 index 0000000..4d53f43 --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/credential.rs @@ -0,0 +1,72 @@ +use super::cookie::extract_cookie; +use super::{AuthTransportConfig, AuthTransportError}; +use http::{HeaderMap, header::AUTHORIZATION}; + +/// Source from which an authentication token was extracted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthTokenSource { + /// `Authorization: Bearer ...` + Bearer, + /// Configured HTTP cookie. + Cookie, +} + +/// Authentication token extracted from an HTTP request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthCredential { + token: String, + source: AuthTokenSource, +} + +impl AuthCredential { + /// Create a credential for tests or custom extractors. + pub fn new(token: impl Into, source: AuthTokenSource) -> Self { + Self { + token: token.into(), + source, + } + } + + /// The token value to pass to `AuthProvider::authenticate`. + pub fn token(&self) -> &str { + &self.token + } + + /// The transport that supplied the token. + pub fn source(&self) -> AuthTokenSource { + self.source + } +} + +/// Extract an auth credential from configured HTTP transports. +pub fn extract_auth_credential( + headers: &HeaderMap, + config: &AuthTransportConfig, +) -> Result { + config.validate()?; + + if config.bearer + && let Some(header) = headers.get(AUTHORIZATION) + { + let header = header + .to_str() + .map_err(|_| AuthTransportError::InvalidAuthorizationHeader)?; + let (scheme, token) = header + .split_once(' ') + .ok_or(AuthTransportError::InvalidAuthorizationHeader)?; + if !scheme.eq_ignore_ascii_case("Bearer") || token.trim().is_empty() { + return Err(AuthTransportError::InvalidAuthorizationHeader); + } + let token = token.trim(); + + return Ok(AuthCredential::new(token, AuthTokenSource::Bearer)); + } + + if let Some(cookie_config) = &config.cookie + && let Some(token) = extract_cookie(headers, &cookie_config.name)? + { + return Ok(AuthCredential::new(token, AuthTokenSource::Cookie)); + } + + Err(AuthTransportError::MissingCredentials) +} diff --git a/crates/core/ras-auth-core/src/transport/csrf.rs b/crates/core/ras-auth-core/src/transport/csrf.rs new file mode 100644 index 0000000..d5d92ef --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/csrf.rs @@ -0,0 +1,289 @@ +use super::cookie::extract_cookie; +use super::{ + AuthCookieConfig, AuthCredential, AuthTokenSource, AuthTransportConfig, AuthTransportError, +}; +use http::{HeaderMap, HeaderValue, header::HeaderName}; +use subtle::ConstantTimeEq; + +const DEFAULT_CSRF_COOKIE_NAME: &str = "__Host-ras-csrf"; +pub(super) const DEFAULT_CSRF_HEADER: &str = "x-ras-csrf"; + +/// Header names that provide no CSRF protection because a browser either sends +/// them automatically cross-origin (CORS-safelisted request headers) or +/// populates them itself (forbidden headers a page cannot control). A CSRF +/// header must be a custom header, since only a custom header forces a CORS +/// preflight that a cross-site attacker cannot satisfy. +const CSRF_UNSAFE_HEADER_NAMES: &[&str] = &[ + // CORS-safelisted request headers — sent cross-origin without a preflight. + "accept", + "accept-language", + "content-language", + "content-type", + // Browser-controlled / forbidden headers — auto-sent, not page-settable. + "cookie", + "origin", + "referer", + "host", + "user-agent", + "content-length", + "connection", + "accept-encoding", + "date", +]; + +/// CSRF guard configuration for cookie-authenticated unsafe requests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CsrfConfig { + /// Header that must be present on unsafe cookie-authenticated requests. + pub header_name: HeaderName, + /// Optional exact value the header must carry. If set, this value is used + /// instead of double-submit cookie validation. + pub expected_value: Option, + /// Cookie whose value must match the CSRF header. Enabled by default. + pub cookie_name: Option, +} + +impl Default for CsrfConfig { + fn default() -> Self { + Self { + header_name: HeaderName::from_static(DEFAULT_CSRF_HEADER), + expected_value: None, + cookie_name: Some(DEFAULT_CSRF_COOKIE_NAME.to_string()), + } + } +} + +impl CsrfConfig { + /// Require a custom header and the default double-submit CSRF cookie. + pub fn new(header_name: HeaderName) -> Self { + Self { + header_name, + ..Self::default() + } + } + + /// Require the custom header to carry a single, static, process-wide value. + /// + /// **Dangerous.** A static value is not bound to a session: any attacker + /// who learns it once (from a leaked bundle, a shared client, or a single + /// captured request) can forge unsafe cookie-authenticated requests for + /// every user until the value is rotated. This disables the double-submit + /// cookie check. Prefer [`Self::default`] for browser sessions. + pub fn dangerous_static_value(mut self, expected_value: impl Into) -> Self { + self.expected_value = Some(expected_value.into()); + self.cookie_name = None; + self + } + + /// Deprecated alias for [`Self::dangerous_static_value`]. + #[deprecated( + since = "0.3.0", + note = "renamed to `dangerous_static_value`; a static CSRF value is not \ + bound to a session and is a weak CSRF defense" + )] + pub fn with_expected_value(self, expected_value: impl Into) -> Self { + self.dangerous_static_value(expected_value) + } + + /// Require the custom header to match this CSRF cookie. + pub fn with_cookie_name(mut self, cookie_name: impl Into) -> Self { + self.cookie_name = Some(cookie_name.into()); + self.expected_value = None; + self + } + + /// Require only a non-empty custom header. + /// + /// **Dangerous.** This mode relies entirely on the browser refusing to send + /// a custom header cross-origin without a successful CORS preflight. It is + /// only sound behind a restrictive credentialed CORS policy and is not a + /// complete CSRF defense by itself. Prefer [`Self::default`] for browser + /// sessions. + pub fn dangerous_header_presence_only(header_name: HeaderName) -> Self { + Self { + header_name, + expected_value: None, + cookie_name: None, + } + } + + /// Deprecated alias for [`Self::dangerous_header_presence_only`]. + #[deprecated( + since = "0.3.0", + note = "renamed to `dangerous_header_presence_only`; presence-only CSRF \ + depends on restrictive CORS and is a weak CSRF defense" + )] + pub fn header_presence_only(header_name: HeaderName) -> Self { + Self::dangerous_header_presence_only(header_name) + } + + /// Whether this configuration uses one of the weak, opt-in modes + /// ([`Self::dangerous_static_value`] or + /// [`Self::dangerous_header_presence_only`]) rather than the default + /// double-submit cookie check. + /// + /// Returns the mode name for logging, or `None` for the double-submit mode. + pub fn dangerous_mode(&self) -> Option<&'static str> { + match (&self.expected_value, &self.cookie_name) { + (Some(_), _) => Some("static_value"), + (None, None) => Some("header_presence_only"), + (None, Some(_)) => None, + } + } + + /// Emit a `warn!` if this CSRF config is in a weak mode. Called from the + /// [`AuthTransportConfig`] builders (once per construction) and, as a + /// fallback for struct-literal construction, once per process from + /// [`AuthTransportConfig::validate`]. + pub(super) fn warn_if_dangerous(&self) { + if let Some(mode) = self.dangerous_mode() { + tracing::warn!( + csrf_mode = mode, + csrf_header = %self.header_name, + "cookie auth is configured with a weak CSRF mode \ + (`CsrfConfig::dangerous_*`); this is not a complete CSRF defense. \ + Prefer the default double-submit cookie mode for browser sessions" + ); + } + } + + /// Build a `Set-Cookie` header value for the double-submit CSRF token. + /// + /// The CSRF cookie is intentionally not `HttpOnly` so browser clients can + /// copy its value into the configured CSRF header. + pub fn csrf_cookie_header_value(&self, token: &str) -> Result { + self.csrf_cookie_config()? + .session_cookie_header_value(token) + } + + /// Build a `Set-Cookie` header value that clears the CSRF cookie. + pub fn clear_csrf_cookie_header_value(&self) -> Result { + self.csrf_cookie_config()?.clear_cookie_header_value() + } + + /// Validate CSRF configuration. + pub fn validate(&self) -> Result<(), AuthTransportError> { + // A CORS-safelisted or browser-controlled header name provides zero CSRF + // protection (it is sent automatically cross-origin), so reject it — + // otherwise `dangerous_header_presence_only(HeaderName::from_static("accept"))` + // would produce a config that passes validation but never blocks a + // forged request. + let header = self.header_name.as_str(); + if CSRF_UNSAFE_HEADER_NAMES + .iter() + .any(|name| header.eq_ignore_ascii_case(name)) + { + return Err(AuthTransportError::InvalidCsrfConfig(format!( + "CSRF header `{header}` is CORS-safelisted or browser-controlled \ + and provides no protection; use a custom header name (e.g. \ + `x-csrf-token`)" + ))); + } + + if let Some(expected) = &self.expected_value + && expected.trim().is_empty() + { + return Err(AuthTransportError::InvalidCsrfConfig( + "expected CSRF value must not be empty".to_string(), + )); + } + + if let Some(cookie_name) = &self.cookie_name { + let cookie = AuthCookieConfig { + name: cookie_name.clone(), + http_only: false, + ..AuthCookieConfig::default() + }; + cookie.validate()?; + } + + Ok(()) + } + + fn validate_headers(&self, headers: &HeaderMap) -> Result<(), AuthTransportError> { + self.validate()?; + + let value = headers + .get(&self.header_name) + .ok_or(AuthTransportError::CsrfValidationFailed)?; + let value = value + .to_str() + .map_err(|_| AuthTransportError::CsrfValidationFailed)?; + + if value.trim().is_empty() { + return Err(AuthTransportError::CsrfValidationFailed); + } + + if let Some(expected) = &self.expected_value + && !ct_eq_str(value, expected) + { + return Err(AuthTransportError::CsrfValidationFailed); + } + + if self.expected_value.is_some() { + return Ok(()); + } + + if let Some(cookie_name) = &self.cookie_name { + let Some(cookie_value) = extract_cookie(headers, cookie_name)? else { + return Err(AuthTransportError::CsrfValidationFailed); + }; + + if cookie_value.trim().is_empty() || !ct_eq_str(&cookie_value, value) { + return Err(AuthTransportError::CsrfValidationFailed); + } + } + + Ok(()) + } + + fn csrf_cookie_config(&self) -> Result { + let cookie_name = self.cookie_name.as_ref().ok_or_else(|| { + AuthTransportError::InvalidCsrfConfig( + "CSRF cookie helper requires cookie validation mode".to_string(), + ) + })?; + + let cookie = AuthCookieConfig { + name: cookie_name.clone(), + http_only: false, + ..AuthCookieConfig::default() + }; + cookie.validate()?; + Ok(cookie) + } +} + +/// Validate CSRF policy for a previously extracted credential. +pub fn validate_csrf_for_credential( + method: &str, + headers: &HeaderMap, + credential: &AuthCredential, + config: &AuthTransportConfig, +) -> Result<(), AuthTransportError> { + config.validate()?; + + if credential.source() != AuthTokenSource::Cookie || !is_unsafe_method(method) { + return Ok(()); + } + + match &config.csrf { + Some(csrf) => csrf.validate_headers(headers), + None => Ok(()), + } +} + +/// Constant-time string comparison for CSRF tokens. +/// +/// Length is allowed to leak (subtle short-circuits on differing lengths), but +/// equal-length values are compared without an input-dependent early return. +fn ct_eq_str(a: &str, b: &str) -> bool { + a.as_bytes().ct_eq(b.as_bytes()).into() +} + +fn is_unsafe_method(method: &str) -> bool { + matches!( + method.to_ascii_uppercase().as_str(), + "POST" | "PUT" | "PATCH" | "DELETE" + ) +} diff --git a/crates/core/ras-auth-core/src/transport/mod.rs b/crates/core/ras-auth-core/src/transport/mod.rs new file mode 100644 index 0000000..79142f1 --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/mod.rs @@ -0,0 +1,164 @@ +//! HTTP credential transport helpers for bearer and cookie-based sessions. +mod cookie; +mod credential; +mod csrf; +mod redaction; + +pub use cookie::{AuthCookieConfig, CookieSameSite, set_cookie_header_name}; +pub use credential::{AuthCredential, AuthTokenSource, extract_auth_credential}; +pub use csrf::{CsrfConfig, validate_csrf_for_credential}; +pub use redaction::{redact_sensitive_headers, redact_sensitive_headers_for_auth_transport}; +use thiserror::Error; + +/// Errors that can occur while extracting or validating HTTP auth credentials. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum AuthTransportError { + /// No configured credential transport found a token. + #[error("missing authentication credentials")] + MissingCredentials, + + /// The `Authorization` header was present but was not a valid bearer token. + #[error("invalid authorization header")] + InvalidAuthorizationHeader, + + /// Cookie-authenticated request failed CSRF validation. + #[error("CSRF validation failed")] + CsrfValidationFailed, + + /// Cookie configuration is internally inconsistent. + #[error("invalid cookie configuration: {0}")] + InvalidCookieConfig(String), + + /// The request contained ambiguous or invalid cookie credentials. + #[error("invalid cookie header: {0}")] + InvalidCookieHeader(String), + + /// CSRF configuration is internally inconsistent. + #[error("invalid CSRF configuration: {0}")] + InvalidCsrfConfig(String), + + /// Auth transport configuration is internally inconsistent. + #[error("invalid auth transport configuration: {0}")] + InvalidAuthTransportConfig(String), + + /// Generated cookie header could not be represented as an HTTP header. + #[error("invalid set-cookie header: {0}")] + InvalidSetCookieHeader(String), +} + +/// Configures which HTTP transports a generated service accepts for auth. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthTransportConfig { + /// Accept `Authorization: Bearer ...`. + pub bearer: bool, + /// Optional secure cookie credential transport. + pub cookie: Option, + /// Optional CSRF guard for cookie-authenticated unsafe requests. + pub csrf: Option, +} + +impl Default for AuthTransportConfig { + fn default() -> Self { + Self { + bearer: true, + cookie: None, + csrf: None, + } + } +} + +impl AuthTransportConfig { + /// Enable cookie auth alongside the default bearer transport. + /// + /// Cookie credentials are vulnerable to CSRF on unsafe methods, so this also + /// installs a default double-submit [`CsrfConfig`] when none is configured + /// yet. Override it with [`Self::with_csrf`] if you need a different policy; + /// there is intentionally no builder path to cookie auth without CSRF. + pub fn with_cookie(mut self, cookie: AuthCookieConfig) -> Self { + self.cookie = Some(cookie); + if self.csrf.is_none() { + self.csrf = Some(CsrfConfig::default()); + } + self.warn_if_weak_csrf(); + self + } + + /// Enable CSRF protection for cookie-authenticated unsafe requests. + /// + /// Passing a `CsrfConfig::dangerous_*` mode together with cookie auth logs + /// a `warn!` at construction time. + pub fn with_csrf(mut self, csrf: CsrfConfig) -> Self { + self.csrf = Some(csrf); + self.warn_if_weak_csrf(); + self + } + + /// Log a warning when cookie auth is paired with a weak CSRF mode. + fn warn_if_weak_csrf(&self) { + if self.cookie.is_some() + && let Some(csrf) = &self.csrf + { + csrf.warn_if_dangerous(); + } + } + + /// Disable bearer-token extraction. + pub fn without_bearer(mut self) -> Self { + self.bearer = false; + self + } + + /// Validate all configured auth transports. + pub fn validate(&self) -> Result<(), AuthTransportError> { + if !self.bearer && self.cookie.is_none() { + return Err(AuthTransportError::InvalidAuthTransportConfig( + "at least one auth transport must be enabled".to_string(), + )); + } + + // Cookie credentials are automatically attached by the browser, so + // cookie auth without a CSRF guard lets any cross-site request act as + // the victim on unsafe methods. `with_cookie` installs a default CSRF + // config; a struct literal that clears it must fail closed here. + if self.cookie.is_some() && self.csrf.is_none() { + return Err(AuthTransportError::InvalidAuthTransportConfig( + "cookie auth requires a CSRF configuration; use with_cookie (which sets a \ + default double-submit CsrfConfig) or with_csrf" + .to_string(), + )); + } + + if let Some(cookie) = &self.cookie { + cookie.validate()?; + } + + if let Some(csrf) = &self.csrf { + csrf.validate()?; + } + + // `validate` runs on every request, so the weak-mode warning is + // rate-limited here to once per distinct weak config per process. The + // builders (`with_cookie`, `with_csrf`) warn unconditionally at + // construction time; this is the fallback for struct-literal configs. + if self.cookie.is_some() + && let Some(csrf) = &self.csrf + && let Some(mode) = csrf.dangerous_mode() + { + static WEAK_CSRF_WARNED: std::sync::Mutex> = + std::sync::Mutex::new(Vec::new()); + let key = (csrf.header_name.as_str().to_string(), mode); + let mut warned = WEAK_CSRF_WARNED + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !warned.contains(&key) { + warned.push(key); + csrf.warn_if_dangerous(); + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/core/ras-auth-core/src/transport/redaction.rs b/crates/core/ras-auth-core/src/transport/redaction.rs new file mode 100644 index 0000000..709958b --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/redaction.rs @@ -0,0 +1,52 @@ +use super::AuthTransportConfig; +use super::csrf::DEFAULT_CSRF_HEADER; +use http::{ + HeaderMap, HeaderValue, + header::{AUTHORIZATION, COOKIE, HeaderName, SET_COOKIE}, +}; + +/// Clone headers with known credential-bearing values replaced by `[REDACTED]`. +pub fn redact_sensitive_headers(headers: &HeaderMap) -> HeaderMap { + let mut redacted = headers.clone(); + + redact_header(&mut redacted, AUTHORIZATION); + redact_header(&mut redacted, COOKIE); + redact_header(&mut redacted, SET_COOKIE); + redact_header( + &mut redacted, + HeaderName::from_static("proxy-authorization"), + ); + redact_header(&mut redacted, HeaderName::from_static("x-auth-token")); + redact_header(&mut redacted, HeaderName::from_static("x-api-key")); + redact_header(&mut redacted, HeaderName::from_static("x-csrf-token")); + redact_header(&mut redacted, HeaderName::from_static("x-xsrf-token")); + redact_header(&mut redacted, HeaderName::from_static(DEFAULT_CSRF_HEADER)); + redact_header( + &mut redacted, + HeaderName::from_static("sec-websocket-protocol"), + ); + + redacted +} + +/// Clone headers with default sensitive values and configured auth transport +/// header secrets replaced by `[REDACTED]`. +pub fn redact_sensitive_headers_for_auth_transport( + headers: &HeaderMap, + config: &AuthTransportConfig, +) -> HeaderMap { + let mut redacted = redact_sensitive_headers(headers); + + if let Some(csrf) = &config.csrf { + redact_header(&mut redacted, csrf.header_name.clone()); + } + + redacted +} + +fn redact_header(headers: &mut HeaderMap, name: HeaderName) { + if headers.contains_key(&name) { + headers.remove(&name); + headers.insert(name, HeaderValue::from_static("[REDACTED]")); + } +} diff --git a/crates/core/ras-auth-core/src/transport/tests/config.rs b/crates/core/ras-auth-core/src/transport/tests/config.rs new file mode 100644 index 0000000..c585b08 --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/tests/config.rs @@ -0,0 +1,148 @@ +use super::*; + +#[test] +fn auth_transport_config_validates_cookie_config_before_extraction() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig { + secure: false, + ..AuthCookieConfig::default() + }); + + let error = extract_auth_credential(&HeaderMap::new(), &config).unwrap_err(); + + assert!(matches!(error, AuthTransportError::InvalidCookieConfig(_))); +} + +#[test] +fn with_cookie_installs_default_csrf_and_validates() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + + assert!(config.csrf.is_some()); + assert!(config.validate().is_ok()); +} + +#[test] +fn cookie_without_csrf_fails_validate() { + let config = AuthTransportConfig { + bearer: true, + cookie: Some(AuthCookieConfig::default()), + csrf: None, + }; + + let error = config.validate().unwrap_err(); + + assert!(matches!( + error, + AuthTransportError::InvalidAuthTransportConfig(_) + )); +} + +#[test] +fn with_cookie_default_still_requires_csrf_header_on_unsafe_cookie_request() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + let cookie = AuthCredential::new("cookie-token", AuthTokenSource::Cookie); + + // No CSRF header present -> unsafe cookie request is rejected. + assert_eq!( + validate_csrf_for_credential("POST", &HeaderMap::new(), &cookie, &config).unwrap_err(), + AuthTransportError::CsrfValidationFailed + ); + + // Bearer credentials stay exempt even on unsafe methods. + let bearer = AuthCredential::new("bearer-token", AuthTokenSource::Bearer); + assert!(validate_csrf_for_credential("POST", &HeaderMap::new(), &bearer, &config).is_ok()); + + // GET cookie requests stay exempt. + assert!(validate_csrf_for_credential("GET", &HeaderMap::new(), &cookie, &config).is_ok()); + + // Valid double-submit header + cookie passes. + let headers = headers(&[ + (DEFAULT_CSRF_HEADER, "csrf-token"), + ("cookie", "__Host-ras-csrf=csrf-token"), + ]); + assert!(validate_csrf_for_credential("POST", &headers, &cookie, &config).is_ok()); +} + +#[test] +fn a1_dangerous_modes_are_reported_and_deprecated_aliases_still_work() { + let presence = + CsrfConfig::dangerous_header_presence_only(HeaderName::from_static("x-csrf-token")); + assert_eq!(presence.dangerous_mode(), Some("header_presence_only")); + + let static_value = CsrfConfig::default().dangerous_static_value("shared-secret"); + assert_eq!(static_value.dangerous_mode(), Some("static_value")); + + assert_eq!(CsrfConfig::default().dangerous_mode(), None); + assert_eq!( + CsrfConfig::default() + .with_cookie_name("__Host-other") + .dangerous_mode(), + None + ); + + // The deprecated names remain as thin wrappers for one release. + #[allow(deprecated)] + let legacy_presence = CsrfConfig::header_presence_only(HeaderName::from_static("x-csrf-token")); + assert_eq!(legacy_presence, presence); + #[allow(deprecated)] + let legacy_static = CsrfConfig::default().with_expected_value("shared-secret"); + assert_eq!(legacy_static, static_value); +} + +#[test] +fn a1_cookie_auth_with_weak_csrf_mode_warns_at_construction() { + let warnings = capture_warnings(|| { + let _ = AuthTransportConfig::default() + .with_cookie(AuthCookieConfig::default()) + .with_csrf(CsrfConfig::dangerous_header_presence_only( + HeaderName::from_static("x-csrf-token"), + )); + }); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("csrf_mode=\"header_presence_only\"")); + assert!(warnings[0].contains("weak CSRF mode")); + + // Ordering does not matter: csrf first, then cookie. + let warnings = capture_warnings(|| { + let _ = AuthTransportConfig::default() + .with_csrf(CsrfConfig::default().dangerous_static_value("shared-secret")) + .with_cookie(AuthCookieConfig::default()); + }); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("csrf_mode=\"static_value\"")); + + // Weak CSRF without cookie auth is irrelevant (bearer-only) — no warning. + let warnings = capture_warnings(|| { + let _ = AuthTransportConfig::default().with_csrf( + CsrfConfig::dangerous_header_presence_only(HeaderName::from_static("x-csrf-token")), + ); + }); + assert!(warnings.is_empty(), "{warnings:?}"); + + // The default double-submit mode never warns. + let warnings = capture_warnings(|| { + let _ = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + }); + assert!(warnings.is_empty(), "{warnings:?}"); +} + +#[test] +fn a1_struct_literal_weak_csrf_warns_from_validate_once() { + // Struct-literal construction bypasses the builders, so `validate` + // warns as a fallback — but only once per distinct weak config per + // process, since it runs on every request. A header name unique to + // this test keeps it independent of test ordering. + let config = AuthTransportConfig { + bearer: true, + cookie: Some(AuthCookieConfig::default()), + csrf: Some(CsrfConfig::dangerous_header_presence_only( + HeaderName::from_static("x-a1-struct-literal-csrf"), + )), + }; + let warnings = capture_warnings(|| { + config.validate().unwrap(); + config.validate().unwrap(); + config.validate().unwrap(); + }); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("csrf_mode=\"header_presence_only\"")); +} diff --git a/crates/core/ras-auth-core/src/transport/tests/cookie.rs b/crates/core/ras-auth-core/src/transport/tests/cookie.rs new file mode 100644 index 0000000..0b6bd5c --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/tests/cookie.rs @@ -0,0 +1,74 @@ +use super::*; + +#[test] +fn auth_cookie_config_validates_host_prefix_constraints() { + assert!(AuthCookieConfig::default().validate().is_ok()); + + let error = AuthCookieConfig { + secure: false, + ..AuthCookieConfig::default() + } + .validate() + .unwrap_err(); + assert!(matches!(error, AuthTransportError::InvalidCookieConfig(_))); + + let error = AuthCookieConfig { + domain: Some("example.com".to_string()), + ..AuthCookieConfig::default() + } + .validate() + .unwrap_err(); + assert!(matches!(error, AuthTransportError::InvalidCookieConfig(_))); +} + +#[test] +fn auth_cookie_config_validates_secure_prefix_and_cookie_name() { + let error = AuthCookieConfig { + name: "__Secure-ras-session".to_string(), + secure: false, + ..AuthCookieConfig::default() + } + .validate() + .unwrap_err(); + assert!(matches!(error, AuthTransportError::InvalidCookieConfig(_))); + + let error = AuthCookieConfig::new("bad;name").validate().unwrap_err(); + assert!(matches!(error, AuthTransportError::InvalidCookieConfig(_))); +} + +#[test] +fn local_development_cookie_helper_removes_host_prefix() { + let cookie = AuthCookieConfig::default().insecure_for_local_development(); + + assert_eq!(cookie.name, "ras-session"); + assert!(!cookie.secure); + assert!(cookie.validate().is_ok()); +} + +#[test] +fn auth_cookie_config_builds_secure_set_cookie_header() { + let value = AuthCookieConfig::default() + .session_cookie_header_value("jwt-token") + .expect("set-cookie header"); + let value = value.to_str().unwrap(); + + assert!(value.starts_with("__Host-ras-session=jwt-token")); + assert!(value.contains("HttpOnly")); + assert!(value.contains("SameSite=Lax")); + assert!(value.contains("Secure")); + assert!(value.contains("Path=/")); +} + +#[test] +fn auth_cookie_config_builds_clear_cookie_header() { + let value = AuthCookieConfig::default() + .clear_cookie_header_value() + .expect("clear-cookie header"); + let value = value.to_str().unwrap(); + + assert!(value.starts_with("__Host-ras-session=")); + assert!(value.contains("Max-Age=0")); + assert!(value.contains("Expires=")); + assert!(value.contains("HttpOnly")); + assert!(value.contains("Path=/")); +} diff --git a/crates/core/ras-auth-core/src/transport/tests/credential.rs b/crates/core/ras-auth-core/src/transport/tests/credential.rs new file mode 100644 index 0000000..120e509 --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/tests/credential.rs @@ -0,0 +1,74 @@ +use super::*; + +#[test] +fn extract_auth_credential_returns_bearer_token() { + let headers = headers(&[("authorization", "Bearer abc123")]); + + let credential = extract_auth_credential(&headers, &AuthTransportConfig::default()) + .expect("bearer extracts"); + + assert_eq!(credential.token(), "abc123"); + assert_eq!(credential.source(), AuthTokenSource::Bearer); +} + +#[test] +fn extract_auth_credential_accepts_case_insensitive_bearer_scheme() { + let headers = headers(&[("authorization", "bearer abc123")]); + + let credential = extract_auth_credential(&headers, &AuthTransportConfig::default()) + .expect("bearer extracts"); + + assert_eq!(credential.token(), "abc123"); + assert_eq!(credential.source(), AuthTokenSource::Bearer); +} + +#[test] +fn extract_auth_credential_returns_cookie_when_bearer_absent() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + let headers = headers(&[("cookie", "theme=dark; __Host-ras-session=cookie-token")]); + + let credential = extract_auth_credential(&headers, &config).expect("cookie extracts"); + + assert_eq!(credential.token(), "cookie-token"); + assert_eq!(credential.source(), AuthTokenSource::Cookie); +} + +#[test] +fn extract_auth_credential_rejects_malformed_bearer_without_cookie_fallback() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + let headers = headers(&[ + ("authorization", "Basic abc123"), + ("cookie", "__Host-ras-session=cookie-token"), + ]); + + let error = extract_auth_credential(&headers, &config).unwrap_err(); + + assert_eq!(error, AuthTransportError::InvalidAuthorizationHeader); +} + +#[test] +fn extract_auth_credential_prefers_bearer_when_both_are_present() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + let headers = headers(&[ + ("authorization", "Bearer bearer-token"), + ("cookie", "__Host-ras-session=cookie-token"), + ]); + + let credential = extract_auth_credential(&headers, &config).expect("credential extracts"); + + assert_eq!(credential.token(), "bearer-token"); + assert_eq!(credential.source(), AuthTokenSource::Bearer); +} + +#[test] +fn extract_auth_credential_rejects_duplicate_session_cookies() { + let config = AuthTransportConfig::default().with_cookie(AuthCookieConfig::default()); + let headers = headers(&[( + "cookie", + "__Host-ras-session=first; __Host-ras-session=second", + )]); + + let error = extract_auth_credential(&headers, &config).unwrap_err(); + + assert!(matches!(error, AuthTransportError::InvalidCookieHeader(_))); +} diff --git a/crates/core/ras-auth-core/src/transport/tests/csrf.rs b/crates/core/ras-auth-core/src/transport/tests/csrf.rs new file mode 100644 index 0000000..34285b2 --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/tests/csrf.rs @@ -0,0 +1,83 @@ +use super::*; + +#[test] +fn csrf_validation_only_applies_to_cookie_auth_on_unsafe_methods() { + let config = AuthTransportConfig::default() + .with_cookie(AuthCookieConfig::default()) + .with_csrf(CsrfConfig::default()); + let bearer = AuthCredential::new("bearer-token", AuthTokenSource::Bearer); + let cookie = AuthCredential::new("cookie-token", AuthTokenSource::Cookie); + let headers_without_csrf = HeaderMap::new(); + let headers_with_csrf = headers(&[ + (DEFAULT_CSRF_HEADER, "csrf-token"), + ("cookie", "__Host-ras-csrf=csrf-token"), + ]); + let headers_with_mismatched_csrf = headers(&[ + (DEFAULT_CSRF_HEADER, "csrf-token"), + ("cookie", "__Host-ras-csrf=other-token"), + ]); + + assert!(validate_csrf_for_credential("POST", &headers_without_csrf, &bearer, &config).is_ok()); + assert!(validate_csrf_for_credential("GET", &headers_without_csrf, &cookie, &config).is_ok()); + assert_eq!( + validate_csrf_for_credential("POST", &headers_without_csrf, &cookie, &config).unwrap_err(), + AuthTransportError::CsrfValidationFailed + ); + assert!(validate_csrf_for_credential("POST", &headers_with_csrf, &cookie, &config).is_ok()); + assert_eq!( + validate_csrf_for_credential("POST", &headers_with_mismatched_csrf, &cookie, &config) + .unwrap_err(), + AuthTransportError::CsrfValidationFailed + ); +} + +#[test] +fn csrf_expected_value_mode_does_not_require_csrf_cookie() { + let config = AuthTransportConfig::default() + .with_cookie(AuthCookieConfig::default()) + .with_csrf(CsrfConfig::default().dangerous_static_value("csrf-token")); + let cookie = AuthCredential::new("cookie-token", AuthTokenSource::Cookie); + let headers = headers(&[(DEFAULT_CSRF_HEADER, "csrf-token")]); + + assert!(validate_csrf_for_credential("POST", &headers, &cookie, &config).is_ok()); +} + +#[test] +fn csrf_config_builds_readable_double_submit_cookie() { + let value = CsrfConfig::default() + .csrf_cookie_header_value("csrf-token") + .expect("set-cookie header"); + let value = value.to_str().unwrap(); + + assert!(value.starts_with("__Host-ras-csrf=csrf-token")); + assert!(!value.contains("HttpOnly")); + assert!(value.contains("SameSite=Lax")); + assert!(value.contains("Secure")); + assert!(value.contains("Path=/")); +} + +#[test] +fn csrf_config_rejects_cors_safelisted_header_names() { + // A safelisted / browser-controlled header name provides no CSRF + // protection and must fail validation even though it is "present". + for name in [ + "accept", + "content-type", + "Accept-Language", + "cookie", + "origin", + ] { + let csrf = CsrfConfig::dangerous_header_presence_only( + HeaderName::from_bytes(name.as_bytes()).unwrap(), + ); + let error = csrf.validate().expect_err(name); + assert!( + matches!(error, AuthTransportError::InvalidCsrfConfig(_)), + "{name} should be rejected" + ); + } + + // A genuinely custom header (forces a CORS preflight) is accepted. + let ok = CsrfConfig::dangerous_header_presence_only(HeaderName::from_static("x-csrf-token")); + assert!(ok.validate().is_ok()); +} diff --git a/crates/core/ras-auth-core/src/transport/tests/mod.rs b/crates/core/ras-auth-core/src/transport/tests/mod.rs new file mode 100644 index 0000000..e886913 --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/tests/mod.rs @@ -0,0 +1,55 @@ +use super::csrf::DEFAULT_CSRF_HEADER; +use super::*; +use http::{HeaderMap, HeaderValue, header::HeaderName}; + +fn headers(pairs: &[(&str, &str)]) -> HeaderMap { + let mut headers = HeaderMap::new(); + for (name, value) in pairs { + headers.append( + HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + headers +} + +/// Minimal `tracing` subscriber that records the messages of `WARN` events. +/// Kept dependency-free (no `tracing-subscriber`) since it only needs to +/// capture a handful of events for the A1 regression tests. +struct WarnCapture(std::sync::Mutex>); + +impl tracing::Subscriber for WarnCapture { + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + *metadata.level() <= tracing::Level::WARN + } + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + fn event(&self, event: &tracing::Event<'_>) { + struct Msg(String); + impl tracing::field::Visit for Msg { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.0.push_str(&format!("{}={:?} ", field.name(), value)); + } + } + let mut msg = Msg(String::new()); + event.record(&mut msg); + self.0.lock().unwrap().push(msg.0); + } + fn enter(&self, _: &tracing::span::Id) {} + fn exit(&self, _: &tracing::span::Id) {} +} + +fn capture_warnings(f: impl FnOnce()) -> Vec { + let capture = std::sync::Arc::new(WarnCapture(std::sync::Mutex::new(Vec::new()))); + tracing::subscriber::with_default(capture.clone(), f); + capture.0.lock().unwrap().clone() +} + +mod config; +mod cookie; +mod credential; +mod csrf; +mod redaction; diff --git a/crates/core/ras-auth-core/src/transport/tests/redaction.rs b/crates/core/ras-auth-core/src/transport/tests/redaction.rs new file mode 100644 index 0000000..d7a82cc --- /dev/null +++ b/crates/core/ras-auth-core/src/transport/tests/redaction.rs @@ -0,0 +1,41 @@ +use super::*; + +#[test] +fn redact_sensitive_headers_removes_credential_values() { + let headers = headers(&[ + ("authorization", "Bearer secret"), + ("cookie", "__Host-ras-session=secret"), + (DEFAULT_CSRF_HEADER, "csrf-secret"), + ("user-agent", "test-agent"), + ]); + + let redacted = redact_sensitive_headers(&headers); + + assert_eq!( + redacted.get("authorization").unwrap(), + HeaderValue::from_static("[REDACTED]") + ); + assert_eq!( + redacted.get("cookie").unwrap(), + HeaderValue::from_static("[REDACTED]") + ); + assert_eq!( + redacted.get(DEFAULT_CSRF_HEADER).unwrap(), + HeaderValue::from_static("[REDACTED]") + ); + assert_eq!(redacted.get("user-agent").unwrap(), "test-agent"); +} + +#[test] +fn redact_sensitive_headers_for_auth_transport_removes_custom_csrf_header() { + let csrf_header = HeaderName::from_static("x-custom-csrf"); + let config = AuthTransportConfig::default().with_csrf(CsrfConfig::new(csrf_header.clone())); + let headers = headers(&[("x-custom-csrf", "csrf-secret")]); + + let redacted = redact_sensitive_headers_for_auth_transport(&headers, &config); + + assert_eq!( + redacted.get(csrf_header).unwrap(), + HeaderValue::from_static("[REDACTED]") + ); +} diff --git a/documentation/reviews/refactor-progress.md b/documentation/reviews/refactor-progress.md index d0773e2..9d3b70c 100644 --- a/documentation/reviews/refactor-progress.md +++ b/documentation/reviews/refactor-progress.md @@ -25,3 +25,4 @@ REST baseline: 61/61 passed. | 9 | File-service types, uploads, downloads, routes, and auth generation | Baseline and result: 49 tests; docs/Clippy; no-default/server/client macro checks; native and WASM API consumer checks. | | 10 | OpenAPI schema collection/normalization and operation emission | 61 tests, doctest, Clippy/features, 11 browser tests. 64 original and extracted document samples produce the same four JSON variants: schema titles already vary with HashMap insertion order. No output policy changed. | | 11 | OpenRPC schemas/references, examples, and methods | 59 tests, docs/Clippy/features, 11 browser tests; three baseline JSON documents equal all 64 extracted samples each. | +| 12 | HTTP credential, cookie, CSRF, and redaction policy behind the transport facade | Auth baseline 39 tests; result 208 auth/HTTP macro tests; all 24 transport tests retained under scenario owners; docs/Clippy/macro feature matrix. | From 797487ef7701ab869295da47d67190ffbc347364 Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 11:34:01 +0200 Subject: [PATCH 13/35] refactor(session): separate JWT policy and session lifecycle --- .../identity/ras-identity-session/src/auth.rs | 37 + .../ras-identity-session/src/claims.rs | 23 + .../ras-identity-session/src/config.rs | 226 +++ .../identity/ras-identity-session/src/jwt.rs | 174 +++ .../identity/ras-identity-session/src/lib.rs | 1379 +---------------- .../ras-identity-session/src/session.rs | 240 +++ .../ras-identity-session/src/session/tests.rs | 680 ++++++++ documentation/reviews/refactor-progress.md | 1 + 8 files changed, 1393 insertions(+), 1367 deletions(-) create mode 100644 crates/identity/ras-identity-session/src/auth.rs create mode 100644 crates/identity/ras-identity-session/src/claims.rs create mode 100644 crates/identity/ras-identity-session/src/config.rs create mode 100644 crates/identity/ras-identity-session/src/jwt.rs create mode 100644 crates/identity/ras-identity-session/src/session.rs create mode 100644 crates/identity/ras-identity-session/src/session/tests.rs diff --git a/crates/identity/ras-identity-session/src/auth.rs b/crates/identity/ras-identity-session/src/auth.rs new file mode 100644 index 0000000..3c1bd96 --- /dev/null +++ b/crates/identity/ras-identity-session/src/auth.rs @@ -0,0 +1,37 @@ +use crate::{SessionError, SessionService}; +use async_trait::async_trait; +use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; +use std::sync::Arc; + +#[derive(Clone)] +pub struct JwtAuthProvider { + session_service: Arc, +} + +impl JwtAuthProvider { + pub fn new(session_service: Arc) -> Self { + Self { session_service } + } +} + +#[async_trait] +impl AuthProvider for JwtAuthProvider { + fn authenticate(&self, token: String) -> AuthFuture<'_> { + Box::pin(async move { + let claims = + self.session_service + .verify_session(&token) + .await + .map_err(|e| match e { + SessionError::TokenExpired => AuthError::TokenExpired, + _ => AuthError::InvalidToken, + })?; + + Ok(AuthenticatedUser { + user_id: claims.sub, + permissions: claims.permissions, + metadata: claims.metadata, + }) + }) + } +} diff --git a/crates/identity/ras-identity-session/src/claims.rs b/crates/identity/ras-identity-session/src/claims.rs new file mode 100644 index 0000000..ad1497b --- /dev/null +++ b/crates/identity/ras-identity-session/src/claims.rs @@ -0,0 +1,23 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JwtClaims { + pub sub: String, + pub exp: i64, + pub iat: i64, + /// Not-before. Optional; when present the token is rejected until then + /// (with [`crate::CLOCK_SKEW_LEEWAY_SECS`] leeway). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nbf: Option, + pub jti: String, + pub provider_id: String, + pub email: Option, + pub display_name: Option, + pub permissions: HashSet, + pub metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub iss: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub aud: Option, +} diff --git a/crates/identity/ras-identity-session/src/config.rs b/crates/identity/ras-identity-session/src/config.rs new file mode 100644 index 0000000..90dae4e --- /dev/null +++ b/crates/identity/ras-identity-session/src/config.rs @@ -0,0 +1,226 @@ +use crate::{DEFAULT_MAX_SESSIONS_PER_USER, JwtAlgorithm, SessionError}; +use chrono::Duration; +use std::collections::HashSet; + +/// Session/JWT configuration. +/// +/// Permission semantics: the permissions granted at [`crate::SessionService::begin_session`] +/// are frozen into the JWT and are **not** reloaded on verify. With the default +/// `enforce_active_sessions: true`, revoking a session (`end_session`) takes +/// effect immediately per-`jti`; otherwise grants are fixed for `jwt_ttl` +/// (default 24h). +/// +/// `iss` and `aud` are **required by default** (S2): [`SessionConfig::new`] and +/// [`crate::SessionService::new`] fail unless both are set (see +/// [`SessionConfig::with_issuer`] / [`SessionConfig::with_audience`]) or the +/// deployment explicitly opts out with [`SessionConfig::allow_unscoped_tokens`]. +/// This keeps tokens minted for one service from being accepted by another that +/// shares the same secret. +/// +/// When `enforce_active_sessions` is on, expired entries are only swept lazily +/// (at most once per minute, from `begin_session`/`verify_session`), so start +/// [`crate::SessionService::start_cleanup_task`] to keep the store bounded during +/// traffic lulls. +#[derive(Clone)] +pub struct SessionConfig { + pub jwt_secret: String, + pub jwt_ttl: Duration, + pub enforce_active_sessions: bool, + pub algorithm: JwtAlgorithm, + /// Expected token issuer. Encoded into new tokens and verified on + /// `verify_session`; a mismatch is rejected. Required unless + /// `require_iss_aud` is false. + pub iss: Option, + /// Expected token audience. Encoded into new tokens and verified on + /// `verify_session`; a token for a different `aud` is rejected. This is the + /// cross-service confused-deputy guard. Required unless + /// `require_iss_aud` is false. + pub aud: Option, + /// When true (default), validation fails if `iss` or `aud` is `None`. + /// Set to false via [`SessionConfig::allow_unscoped_tokens`] only for + /// single-service deployments that never share a secret (S2). + pub require_iss_aud: bool, + /// Maximum concurrently tracked sessions per `sub` when + /// `enforce_active_sessions` is on. Once reached, the oldest session (by + /// `iat`) is evicted when a new one begins (S5). Default 32. + pub max_sessions_per_user: usize, +} + +/// Redacting `Debug` so `jwt_secret` never lands in logs. +impl std::fmt::Debug for SessionConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionConfig") + .field("jwt_secret", &"[REDACTED]") + .field("jwt_ttl", &self.jwt_ttl) + .field("enforce_active_sessions", &self.enforce_active_sessions) + .field("algorithm", &self.algorithm) + .field("iss", &self.iss) + .field("aud", &self.aud) + .field("require_iss_aud", &self.require_iss_aud) + .field("max_sessions_per_user", &self.max_sessions_per_user) + .finish() + } +} + +impl SessionConfig { + /// Build a config with an issuer and audience. Both are required by + /// default; use [`SessionConfig::allow_unscoped_tokens`] on the result to + /// opt out for single-service deployments. + pub fn new( + jwt_secret: impl Into, + issuer: impl Into, + audience: impl Into, + ) -> Result { + let config = Self { + jwt_secret: jwt_secret.into(), + jwt_ttl: Duration::hours(24), + enforce_active_sessions: true, + algorithm: JwtAlgorithm::HS256, + iss: Some(issuer.into()), + aud: Some(audience.into()), + require_iss_aud: true, + max_sessions_per_user: DEFAULT_MAX_SESSIONS_PER_USER, + }; + config.validate()?; + Ok(config) + } + + /// Build a config **without** an issuer/audience. Only valid for + /// single-service deployments that never share `jwt_secret`; any token + /// signed with the secret is accepted regardless of which service minted + /// it. Equivalent to `new(..)` followed by [`allow_unscoped_tokens`]. + /// + /// [`allow_unscoped_tokens`]: SessionConfig::allow_unscoped_tokens + pub fn new_unscoped(jwt_secret: impl Into) -> Result { + let config = Self { + jwt_secret: jwt_secret.into(), + jwt_ttl: Duration::hours(24), + enforce_active_sessions: true, + algorithm: JwtAlgorithm::HS256, + iss: None, + aud: None, + require_iss_aud: false, + max_sessions_per_user: DEFAULT_MAX_SESSIONS_PER_USER, + }; + config.validate()?; + Ok(config) + } + + /// Explicit opt-out from the `iss`/`aud` requirement (S2). Only for + /// single-service deployments where the secret is never shared. + pub fn allow_unscoped_tokens(mut self) -> Self { + self.require_iss_aud = false; + self + } + + /// Cap concurrently tracked sessions per user (S5). Must be at least 1. + pub fn with_max_sessions_per_user(mut self, max: usize) -> Self { + self.max_sessions_per_user = max; + self + } + + /// Set the expected issuer (`iss`). Production services should set this. + pub fn with_issuer(mut self, issuer: impl Into) -> Self { + self.iss = Some(issuer.into()); + self + } + + /// Set the expected audience (`aud`). Production services should set this so + /// a token minted for another service is rejected here. + pub fn with_audience(mut self, audience: impl Into) -> Self { + self.aud = Some(audience.into()); + self + } + + pub fn validate(&self) -> Result<(), SessionError> { + validate_jwt_secret(&self.jwt_secret)?; + + if self.jwt_ttl <= Duration::zero() { + return Err(SessionError::InvalidConfig( + "jwt_ttl must be positive".to_string(), + )); + } + + if self.require_iss_aud && (self.iss.is_none() || self.aud.is_none()) { + return Err(SessionError::InvalidConfig( + "iss and aud must be set (use with_issuer/with_audience), or opt out \ + explicitly with allow_unscoped_tokens() for single-service deployments" + .to_string(), + )); + } + + if self.max_sessions_per_user == 0 { + return Err(SessionError::InvalidConfig( + "max_sessions_per_user must be at least 1".to_string(), + )); + } + + Ok(()) + } +} + +/// Minimum number of distinct byte values a secret must contain (S3). +/// +/// Ten keeps `openssl rand -hex 24` (16 possible symbols) passing in +/// practice while still rejecting repeated-pattern strings. +const MIN_DISTINCT_SECRET_BYTES: usize = 10; +/// Longest permitted run of one repeated byte in a secret (S3). +const MAX_REPEATED_SECRET_BYTES: usize = 7; + +/// Substrings (matched case-insensitively) that mark a secret as a +/// placeholder rather than random key material (S3). +const INSECURE_SECRET_SUBSTRINGS: &[&str] = &[ + "change-me", + "changeme", + "secret", + "password", + "example", + "placeholder", + "test-secret", + "dev-secret", + "insecure", + "12345678", + "abcdefgh", + "your-secret", +]; + +pub(super) fn validate_jwt_secret(secret: &str) -> Result<(), SessionError> { + let trimmed = secret.trim(); + + if trimmed.len() < 32 { + return Err(SessionError::InvalidConfig( + "jwt_secret must be at least 32 bytes".to_string(), + )); + } + + let lowered = trimmed.to_ascii_lowercase(); + if INSECURE_SECRET_SUBSTRINGS + .iter() + .any(|placeholder| lowered.contains(placeholder)) + { + return Err(SessionError::InvalidConfig( + "jwt_secret must not contain a placeholder value".to_string(), + )); + } + + let distinct: HashSet = trimmed.bytes().collect(); + if distinct.len() < MIN_DISTINCT_SECRET_BYTES { + return Err(SessionError::InvalidConfig(format!( + "jwt_secret must contain at least {MIN_DISTINCT_SECRET_BYTES} distinct byte values" + ))); + } + + let mut run = 0usize; + let mut prev = None; + for byte in trimmed.bytes() { + run = if prev == Some(byte) { run + 1 } else { 1 }; + if run > MAX_REPEATED_SECRET_BYTES { + return Err(SessionError::InvalidConfig(format!( + "jwt_secret must not repeat one byte more than {MAX_REPEATED_SECRET_BYTES} times in a row" + ))); + } + prev = Some(byte); + } + + Ok(()) +} diff --git a/crates/identity/ras-identity-session/src/jwt.rs b/crates/identity/ras-identity-session/src/jwt.rs new file mode 100644 index 0000000..d9b52b4 --- /dev/null +++ b/crates/identity/ras-identity-session/src/jwt.rs @@ -0,0 +1,174 @@ +use crate::SessionError; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use sha2::{Sha256, Sha384, Sha512}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum JwtAlgorithm { + #[serde(rename = "HS256")] + HS256, + #[serde(rename = "HS384")] + HS384, + #[serde(rename = "HS512")] + HS512, +} + +impl JwtAlgorithm { + pub fn from_name(name: &str) -> Option { + match name { + "HS256" => Some(Self::HS256), + "HS384" => Some(Self::HS384), + "HS512" => Some(Self::HS512), + _ => None, + } + } +} + +#[derive(Serialize)] +struct JwtHeader { + typ: &'static str, + alg: JwtAlgorithm, +} + +#[derive(Deserialize)] +struct DecodedJwtHeader { + alg: JwtAlgorithm, +} + +fn jwt_error(message: impl Into) -> SessionError { + SessionError::JwtError(message.into()) +} + +pub(super) fn encode_jwt( + claims: &T, + secret: &str, + algorithm: JwtAlgorithm, +) -> Result { + let header = JwtHeader { + typ: "JWT", + alg: algorithm, + }; + let header = serde_json::to_vec(&header) + .map_err(|err| jwt_error(format!("failed to encode JWT header: {err}")))?; + let claims = serde_json::to_vec(claims) + .map_err(|err| jwt_error(format!("failed to encode JWT claims: {err}")))?; + + let signing_input = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(header), + URL_SAFE_NO_PAD.encode(claims) + ); + let signature = sign_jwt(&signing_input, secret.as_bytes(), algorithm)?; + + Ok(format!( + "{}.{}", + signing_input, + URL_SAFE_NO_PAD.encode(signature) + )) +} + +pub(super) fn decode_jwt( + token: &str, + secret: &str, + expected_algorithm: JwtAlgorithm, +) -> Result { + let mut parts = token.split('.'); + let encoded_header = parts + .next() + .ok_or_else(|| jwt_error("missing JWT header"))?; + let encoded_claims = parts + .next() + .ok_or_else(|| jwt_error("missing JWT claims"))?; + let encoded_signature = parts + .next() + .ok_or_else(|| jwt_error("missing JWT signature"))?; + + if parts.next().is_some() { + return Err(jwt_error("JWT has too many segments")); + } + + let header = URL_SAFE_NO_PAD + .decode(encoded_header) + .map_err(|err| jwt_error(format!("invalid JWT header encoding: {err}")))?; + let header: DecodedJwtHeader = serde_json::from_slice(&header) + .map_err(|err| jwt_error(format!("invalid JWT header: {err}")))?; + + if header.alg != expected_algorithm { + return Err(jwt_error("unexpected JWT algorithm")); + } + + let signature = URL_SAFE_NO_PAD + .decode(encoded_signature) + .map_err(|err| jwt_error(format!("invalid JWT signature encoding: {err}")))?; + let signing_input = format!("{encoded_header}.{encoded_claims}"); + verify_jwt_signature( + &signing_input, + secret.as_bytes(), + expected_algorithm, + &signature, + )?; + + let claims = URL_SAFE_NO_PAD + .decode(encoded_claims) + .map_err(|err| jwt_error(format!("invalid JWT claims encoding: {err}")))?; + serde_json::from_slice(&claims).map_err(|err| jwt_error(format!("invalid JWT claims: {err}"))) +} + +fn sign_jwt( + signing_input: &str, + secret: &[u8], + algorithm: JwtAlgorithm, +) -> Result, SessionError> { + match algorithm { + JwtAlgorithm::HS256 => { + let mut mac = Hmac::::new_from_slice(secret) + .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; + mac.update(signing_input.as_bytes()); + Ok(mac.finalize().into_bytes().to_vec()) + } + JwtAlgorithm::HS384 => { + let mut mac = Hmac::::new_from_slice(secret) + .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; + mac.update(signing_input.as_bytes()); + Ok(mac.finalize().into_bytes().to_vec()) + } + JwtAlgorithm::HS512 => { + let mut mac = Hmac::::new_from_slice(secret) + .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; + mac.update(signing_input.as_bytes()); + Ok(mac.finalize().into_bytes().to_vec()) + } + } +} + +fn verify_jwt_signature( + signing_input: &str, + secret: &[u8], + algorithm: JwtAlgorithm, + signature: &[u8], +) -> Result<(), SessionError> { + match algorithm { + JwtAlgorithm::HS256 => { + let mut mac = Hmac::::new_from_slice(secret) + .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; + mac.update(signing_input.as_bytes()); + mac.verify_slice(signature) + .map_err(|_| jwt_error("invalid JWT signature")) + } + JwtAlgorithm::HS384 => { + let mut mac = Hmac::::new_from_slice(secret) + .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; + mac.update(signing_input.as_bytes()); + mac.verify_slice(signature) + .map_err(|_| jwt_error("invalid JWT signature")) + } + JwtAlgorithm::HS512 => { + let mut mac = Hmac::::new_from_slice(secret) + .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; + mac.update(signing_input.as_bytes()); + mac.verify_slice(signature) + .map_err(|_| jwt_error("invalid JWT signature")) + } + } +} diff --git a/crates/identity/ras-identity-session/src/lib.rs b/crates/identity/ras-identity-session/src/lib.rs index 516be32..a3f8d45 100644 --- a/crates/identity/ras-identity-session/src/lib.rs +++ b/crates/identity/ras-identity-session/src/lib.rs @@ -1,30 +1,22 @@ //! Session management with JWT token generation and validation. - -use async_trait::async_trait; -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use chrono::{Duration, Utc}; -use hmac::{Hmac, Mac}; -use ras_auth_core::{AuthError, AuthFuture, AuthProvider, AuthenticatedUser}; -use ras_identity_core::{IdentityError, IdentityProvider, UserPermissions}; -use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; -use sha2::{Sha256, Sha384, Sha512}; -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Instant; +mod auth; +mod claims; +mod config; +mod jwt; +mod session; + +pub use auth::JwtAuthProvider; +pub use claims::JwtClaims; +pub use config::SessionConfig; +pub use jwt::JwtAlgorithm; +use ras_identity_core::IdentityError; +pub use session::SessionService; use thiserror::Error; -use tokio::sync::RwLock; -use uuid::Uuid; /// Clock-skew leeway applied to the `iat` and `nbf` claims (S4): a token whose /// `iat`/`nbf` lies further than this in the future is rejected. pub const CLOCK_SKEW_LEEWAY_SECS: i64 = 60; -/// Minimum spacing between lazy expired-session sweeps triggered from -/// `begin_session`/`verify_session` (S1). -const LAZY_CLEANUP_INTERVAL_SECS: u64 = 60; - /// Default cap on concurrently tracked sessions per user (S5). pub const DEFAULT_MAX_SESSIONS_PER_USER: usize = 32; @@ -48,1350 +40,3 @@ pub enum SessionError { #[error("Invalid session configuration: {0}")] InvalidConfig(String), } - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JwtClaims { - pub sub: String, - pub exp: i64, - pub iat: i64, - /// Not-before. Optional; when present the token is rejected until then - /// (with [`CLOCK_SKEW_LEEWAY_SECS`] leeway). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub nbf: Option, - pub jti: String, - pub provider_id: String, - pub email: Option, - pub display_name: Option, - pub permissions: HashSet, - pub metadata: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub iss: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub aud: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum JwtAlgorithm { - #[serde(rename = "HS256")] - HS256, - #[serde(rename = "HS384")] - HS384, - #[serde(rename = "HS512")] - HS512, -} - -impl JwtAlgorithm { - pub fn from_name(name: &str) -> Option { - match name { - "HS256" => Some(Self::HS256), - "HS384" => Some(Self::HS384), - "HS512" => Some(Self::HS512), - _ => None, - } - } -} - -/// Session/JWT configuration. -/// -/// Permission semantics: the permissions granted at [`SessionService::begin_session`] -/// are frozen into the JWT and are **not** reloaded on verify. With the default -/// `enforce_active_sessions: true`, revoking a session (`end_session`) takes -/// effect immediately per-`jti`; otherwise grants are fixed for `jwt_ttl` -/// (default 24h). -/// -/// `iss` and `aud` are **required by default** (S2): [`SessionConfig::new`] and -/// [`SessionService::new`] fail unless both are set (see -/// [`SessionConfig::with_issuer`] / [`SessionConfig::with_audience`]) or the -/// deployment explicitly opts out with [`SessionConfig::allow_unscoped_tokens`]. -/// This keeps tokens minted for one service from being accepted by another that -/// shares the same secret. -/// -/// When `enforce_active_sessions` is on, expired entries are only swept lazily -/// (at most once per minute, from `begin_session`/`verify_session`), so start -/// [`SessionService::start_cleanup_task`] to keep the store bounded during -/// traffic lulls. -#[derive(Clone)] -pub struct SessionConfig { - pub jwt_secret: String, - pub jwt_ttl: Duration, - pub enforce_active_sessions: bool, - pub algorithm: JwtAlgorithm, - /// Expected token issuer. Encoded into new tokens and verified on - /// `verify_session`; a mismatch is rejected. Required unless - /// `require_iss_aud` is false. - pub iss: Option, - /// Expected token audience. Encoded into new tokens and verified on - /// `verify_session`; a token for a different `aud` is rejected. This is the - /// cross-service confused-deputy guard. Required unless - /// `require_iss_aud` is false. - pub aud: Option, - /// When true (default), validation fails if `iss` or `aud` is `None`. - /// Set to false via [`SessionConfig::allow_unscoped_tokens`] only for - /// single-service deployments that never share a secret (S2). - pub require_iss_aud: bool, - /// Maximum concurrently tracked sessions per `sub` when - /// `enforce_active_sessions` is on. Once reached, the oldest session (by - /// `iat`) is evicted when a new one begins (S5). Default 32. - pub max_sessions_per_user: usize, -} - -/// Redacting `Debug` so `jwt_secret` never lands in logs. -impl std::fmt::Debug for SessionConfig { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SessionConfig") - .field("jwt_secret", &"[REDACTED]") - .field("jwt_ttl", &self.jwt_ttl) - .field("enforce_active_sessions", &self.enforce_active_sessions) - .field("algorithm", &self.algorithm) - .field("iss", &self.iss) - .field("aud", &self.aud) - .field("require_iss_aud", &self.require_iss_aud) - .field("max_sessions_per_user", &self.max_sessions_per_user) - .finish() - } -} - -impl SessionConfig { - /// Build a config with an issuer and audience. Both are required by - /// default; use [`SessionConfig::allow_unscoped_tokens`] on the result to - /// opt out for single-service deployments. - pub fn new( - jwt_secret: impl Into, - issuer: impl Into, - audience: impl Into, - ) -> Result { - let config = Self { - jwt_secret: jwt_secret.into(), - jwt_ttl: Duration::hours(24), - enforce_active_sessions: true, - algorithm: JwtAlgorithm::HS256, - iss: Some(issuer.into()), - aud: Some(audience.into()), - require_iss_aud: true, - max_sessions_per_user: DEFAULT_MAX_SESSIONS_PER_USER, - }; - config.validate()?; - Ok(config) - } - - /// Build a config **without** an issuer/audience. Only valid for - /// single-service deployments that never share `jwt_secret`; any token - /// signed with the secret is accepted regardless of which service minted - /// it. Equivalent to `new(..)` followed by [`allow_unscoped_tokens`]. - /// - /// [`allow_unscoped_tokens`]: SessionConfig::allow_unscoped_tokens - pub fn new_unscoped(jwt_secret: impl Into) -> Result { - let config = Self { - jwt_secret: jwt_secret.into(), - jwt_ttl: Duration::hours(24), - enforce_active_sessions: true, - algorithm: JwtAlgorithm::HS256, - iss: None, - aud: None, - require_iss_aud: false, - max_sessions_per_user: DEFAULT_MAX_SESSIONS_PER_USER, - }; - config.validate()?; - Ok(config) - } - - /// Explicit opt-out from the `iss`/`aud` requirement (S2). Only for - /// single-service deployments where the secret is never shared. - pub fn allow_unscoped_tokens(mut self) -> Self { - self.require_iss_aud = false; - self - } - - /// Cap concurrently tracked sessions per user (S5). Must be at least 1. - pub fn with_max_sessions_per_user(mut self, max: usize) -> Self { - self.max_sessions_per_user = max; - self - } - - /// Set the expected issuer (`iss`). Production services should set this. - pub fn with_issuer(mut self, issuer: impl Into) -> Self { - self.iss = Some(issuer.into()); - self - } - - /// Set the expected audience (`aud`). Production services should set this so - /// a token minted for another service is rejected here. - pub fn with_audience(mut self, audience: impl Into) -> Self { - self.aud = Some(audience.into()); - self - } - - pub fn validate(&self) -> Result<(), SessionError> { - validate_jwt_secret(&self.jwt_secret)?; - - if self.jwt_ttl <= Duration::zero() { - return Err(SessionError::InvalidConfig( - "jwt_ttl must be positive".to_string(), - )); - } - - if self.require_iss_aud && (self.iss.is_none() || self.aud.is_none()) { - return Err(SessionError::InvalidConfig( - "iss and aud must be set (use with_issuer/with_audience), or opt out \ - explicitly with allow_unscoped_tokens() for single-service deployments" - .to_string(), - )); - } - - if self.max_sessions_per_user == 0 { - return Err(SessionError::InvalidConfig( - "max_sessions_per_user must be at least 1".to_string(), - )); - } - - Ok(()) - } -} - -/// Minimum number of distinct byte values a secret must contain (S3). -/// -/// Ten keeps `openssl rand -hex 24` (16 possible symbols) passing in -/// practice while still rejecting repeated-pattern strings. -const MIN_DISTINCT_SECRET_BYTES: usize = 10; -/// Longest permitted run of one repeated byte in a secret (S3). -const MAX_REPEATED_SECRET_BYTES: usize = 7; - -/// Substrings (matched case-insensitively) that mark a secret as a -/// placeholder rather than random key material (S3). -const INSECURE_SECRET_SUBSTRINGS: &[&str] = &[ - "change-me", - "changeme", - "secret", - "password", - "example", - "placeholder", - "test-secret", - "dev-secret", - "insecure", - "12345678", - "abcdefgh", - "your-secret", -]; - -fn validate_jwt_secret(secret: &str) -> Result<(), SessionError> { - let trimmed = secret.trim(); - - if trimmed.len() < 32 { - return Err(SessionError::InvalidConfig( - "jwt_secret must be at least 32 bytes".to_string(), - )); - } - - let lowered = trimmed.to_ascii_lowercase(); - if INSECURE_SECRET_SUBSTRINGS - .iter() - .any(|placeholder| lowered.contains(placeholder)) - { - return Err(SessionError::InvalidConfig( - "jwt_secret must not contain a placeholder value".to_string(), - )); - } - - let distinct: HashSet = trimmed.bytes().collect(); - if distinct.len() < MIN_DISTINCT_SECRET_BYTES { - return Err(SessionError::InvalidConfig(format!( - "jwt_secret must contain at least {MIN_DISTINCT_SECRET_BYTES} distinct byte values" - ))); - } - - let mut run = 0usize; - let mut prev = None; - for byte in trimmed.bytes() { - run = if prev == Some(byte) { run + 1 } else { 1 }; - if run > MAX_REPEATED_SECRET_BYTES { - return Err(SessionError::InvalidConfig(format!( - "jwt_secret must not repeat one byte more than {MAX_REPEATED_SECRET_BYTES} times in a row" - ))); - } - prev = Some(byte); - } - - Ok(()) -} - -#[derive(Serialize)] -struct JwtHeader { - typ: &'static str, - alg: JwtAlgorithm, -} - -#[derive(Deserialize)] -struct DecodedJwtHeader { - alg: JwtAlgorithm, -} - -fn jwt_error(message: impl Into) -> SessionError { - SessionError::JwtError(message.into()) -} - -fn encode_jwt( - claims: &T, - secret: &str, - algorithm: JwtAlgorithm, -) -> Result { - let header = JwtHeader { - typ: "JWT", - alg: algorithm, - }; - let header = serde_json::to_vec(&header) - .map_err(|err| jwt_error(format!("failed to encode JWT header: {err}")))?; - let claims = serde_json::to_vec(claims) - .map_err(|err| jwt_error(format!("failed to encode JWT claims: {err}")))?; - - let signing_input = format!( - "{}.{}", - URL_SAFE_NO_PAD.encode(header), - URL_SAFE_NO_PAD.encode(claims) - ); - let signature = sign_jwt(&signing_input, secret.as_bytes(), algorithm)?; - - Ok(format!( - "{}.{}", - signing_input, - URL_SAFE_NO_PAD.encode(signature) - )) -} - -fn decode_jwt( - token: &str, - secret: &str, - expected_algorithm: JwtAlgorithm, -) -> Result { - let mut parts = token.split('.'); - let encoded_header = parts - .next() - .ok_or_else(|| jwt_error("missing JWT header"))?; - let encoded_claims = parts - .next() - .ok_or_else(|| jwt_error("missing JWT claims"))?; - let encoded_signature = parts - .next() - .ok_or_else(|| jwt_error("missing JWT signature"))?; - - if parts.next().is_some() { - return Err(jwt_error("JWT has too many segments")); - } - - let header = URL_SAFE_NO_PAD - .decode(encoded_header) - .map_err(|err| jwt_error(format!("invalid JWT header encoding: {err}")))?; - let header: DecodedJwtHeader = serde_json::from_slice(&header) - .map_err(|err| jwt_error(format!("invalid JWT header: {err}")))?; - - if header.alg != expected_algorithm { - return Err(jwt_error("unexpected JWT algorithm")); - } - - let signature = URL_SAFE_NO_PAD - .decode(encoded_signature) - .map_err(|err| jwt_error(format!("invalid JWT signature encoding: {err}")))?; - let signing_input = format!("{encoded_header}.{encoded_claims}"); - verify_jwt_signature( - &signing_input, - secret.as_bytes(), - expected_algorithm, - &signature, - )?; - - let claims = URL_SAFE_NO_PAD - .decode(encoded_claims) - .map_err(|err| jwt_error(format!("invalid JWT claims encoding: {err}")))?; - serde_json::from_slice(&claims).map_err(|err| jwt_error(format!("invalid JWT claims: {err}"))) -} - -fn sign_jwt( - signing_input: &str, - secret: &[u8], - algorithm: JwtAlgorithm, -) -> Result, SessionError> { - match algorithm { - JwtAlgorithm::HS256 => { - let mut mac = Hmac::::new_from_slice(secret) - .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; - mac.update(signing_input.as_bytes()); - Ok(mac.finalize().into_bytes().to_vec()) - } - JwtAlgorithm::HS384 => { - let mut mac = Hmac::::new_from_slice(secret) - .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; - mac.update(signing_input.as_bytes()); - Ok(mac.finalize().into_bytes().to_vec()) - } - JwtAlgorithm::HS512 => { - let mut mac = Hmac::::new_from_slice(secret) - .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; - mac.update(signing_input.as_bytes()); - Ok(mac.finalize().into_bytes().to_vec()) - } - } -} - -fn verify_jwt_signature( - signing_input: &str, - secret: &[u8], - algorithm: JwtAlgorithm, - signature: &[u8], -) -> Result<(), SessionError> { - match algorithm { - JwtAlgorithm::HS256 => { - let mut mac = Hmac::::new_from_slice(secret) - .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; - mac.update(signing_input.as_bytes()); - mac.verify_slice(signature) - .map_err(|_| jwt_error("invalid JWT signature")) - } - JwtAlgorithm::HS384 => { - let mut mac = Hmac::::new_from_slice(secret) - .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; - mac.update(signing_input.as_bytes()); - mac.verify_slice(signature) - .map_err(|_| jwt_error("invalid JWT signature")) - } - JwtAlgorithm::HS512 => { - let mut mac = Hmac::::new_from_slice(secret) - .map_err(|err| jwt_error(format!("invalid JWT secret: {err}")))?; - mac.update(signing_input.as_bytes()); - mac.verify_slice(signature) - .map_err(|_| jwt_error("invalid JWT signature")) - } - } -} - -pub struct SessionService { - config: SessionConfig, - providers: Arc>>>, - /// Keyed by `jti`. - active_sessions: Arc>>, - permissions_provider: Option>, - /// Reference point for `next_lazy_cleanup`. - created_at: Instant, - /// Seconds since `created_at` at which the next lazy sweep is due (S1). - next_lazy_cleanup: AtomicU64, -} -impl SessionService { - pub fn new(config: SessionConfig) -> Result { - config.validate()?; - Ok(Self { - config, - providers: Arc::new(RwLock::new(HashMap::new())), - active_sessions: Arc::new(RwLock::new(HashMap::new())), - permissions_provider: None, - created_at: Instant::now(), - next_lazy_cleanup: AtomicU64::new(0), - }) - } - - /// Lazy fallback sweep (S1): prunes expired sessions at most once per - /// [`LAZY_CLEANUP_INTERVAL_SECS`], so deployments that never start - /// [`start_cleanup_task`](Self::start_cleanup_task) still get bounded - /// growth without taking the write lock on every request. - async fn maybe_lazy_cleanup(&self) { - if !self.config.enforce_active_sessions { - return; - } - let now = self.created_at.elapsed().as_secs(); - let next = self.next_lazy_cleanup.load(Ordering::Relaxed); - // The first call sweeps (next == 0), then at most once per interval. - // compare_exchange ensures only one of several concurrent callers - // performs the sweep; the losers see the bumped deadline and skip. - if now >= next - && self - .next_lazy_cleanup - .compare_exchange( - next, - now + LAZY_CLEANUP_INTERVAL_SECS, - Ordering::AcqRel, - Ordering::Relaxed, - ) - .is_ok() - { - self.cleanup_expired_sessions().await; - } - } - - pub fn with_permissions(mut self, provider: Arc) -> Self { - self.permissions_provider = Some(provider); - self - } - - pub fn set_permissions_provider(&mut self, provider: Arc) { - self.permissions_provider = Some(provider); - } - - pub async fn register_provider(&self, provider: Box) { - let mut providers = self.providers.write().await; - providers.insert(provider.provider_id().to_string(), provider); - } - - pub async fn begin_session( - &self, - provider_id: &str, - auth_payload: serde_json::Value, - ) -> Result { - self.maybe_lazy_cleanup().await; - - let providers = self.providers.read().await; - let provider = providers - .get(provider_id) - .ok_or_else(|| IdentityError::ProviderNotFound(provider_id.to_string()))?; - - let identity = provider.verify(auth_payload).await?; - - let now = Utc::now(); - let exp = now + self.config.jwt_ttl; - let jti = Uuid::new_v4().to_string(); - - let permissions = if let Some(ref perm_provider) = self.permissions_provider { - perm_provider.get_permissions(&identity).await? - } else { - Vec::new() - }; - - let claims = JwtClaims { - sub: identity.subject.clone(), - exp: exp.timestamp(), - iat: now.timestamp(), - nbf: None, - jti: jti.clone(), - provider_id: identity.provider_id.clone(), - email: identity.email.clone(), - display_name: identity.display_name.clone(), - permissions: permissions.into_iter().collect(), - metadata: identity.metadata, - iss: self.config.iss.clone(), - aud: self.config.aud.clone(), - }; - - if self.config.enforce_active_sessions { - let mut sessions = self.active_sessions.write().await; - // Per-user cap (S5): evict the oldest sessions (by iat) so this - // user never holds more than `max_sessions_per_user` entries. - let max = self.config.max_sessions_per_user; - let mut owned: Vec<(i64, String)> = sessions - .iter() - .filter(|(_, c)| c.sub == claims.sub) - .map(|(jti, c)| (c.iat, jti.clone())) - .collect(); - if owned.len() >= max { - owned.sort(); - let surplus = owned.len() + 1 - max; - for (_, old_jti) in owned.into_iter().take(surplus) { - sessions.remove(&old_jti); - } - } - sessions.insert(jti.clone(), claims.clone()); - } - - let token = encode_jwt(&claims, &self.config.jwt_secret, self.config.algorithm)?; - - Ok(token) - } - - pub async fn verify_session(&self, token: &str) -> Result { - self.maybe_lazy_cleanup().await; - - let claims = - decode_jwt::(token, &self.config.jwt_secret, self.config.algorithm)?; - - let now = Utc::now().timestamp(); - if claims.exp <= now { - return Err(SessionError::TokenExpired); - } - - // Time-validity guards (S4): a token issued or valid only in the - // future (beyond clock-skew leeway) is not accepted. - if claims.iat > now + CLOCK_SKEW_LEEWAY_SECS { - return Err(SessionError::InvalidSession); - } - if let Some(nbf) = claims.nbf - && nbf > now + CLOCK_SKEW_LEEWAY_SECS - { - return Err(SessionError::InvalidSession); - } - - // Cross-service confused-deputy guard: reject tokens minted for a - // different issuer/audience when this service configures them. - if let Some(expected_iss) = &self.config.iss - && claims.iss.as_deref() != Some(expected_iss.as_str()) - { - return Err(SessionError::InvalidSession); - } - if let Some(expected_aud) = &self.config.aud - && claims.aud.as_deref() != Some(expected_aud.as_str()) - { - return Err(SessionError::InvalidSession); - } - - if self.config.enforce_active_sessions { - let sessions = self.active_sessions.read().await; - if !sessions.contains_key(&claims.jti) { - return Err(SessionError::SessionNotFound); - } - } - - Ok(claims) - } - - /// Number of sessions currently held in the in-memory store - /// (only populated when `enforce_active_sessions` is on). - pub async fn active_session_count(&self) -> usize { - self.active_sessions.read().await.len() - } - - /// Spawn a background task pruning expired sessions every `interval`. - /// - /// Start this whenever `enforce_active_sessions` is on. Without it, - /// expired sessions are only pruned lazily (at most once a minute, and - /// only when begin_session/verify_session run), so a traffic lull leaves - /// them in memory until the next request. The task holds only a weak - /// reference and stops when the service is dropped (or when the returned - /// handle is aborted). - pub fn start_cleanup_task( - self: &std::sync::Arc, - interval: std::time::Duration, - ) -> tokio::task::JoinHandle<()> { - let service = std::sync::Arc::downgrade(self); - tokio::spawn(async move { - let mut timer = tokio::time::interval(interval); - timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - loop { - timer.tick().await; - let Some(service) = service.upgrade() else { - break; - }; - service.cleanup_expired_sessions().await; - } - }) - } - - pub async fn end_session(&self, jti: &str) -> Option { - let mut sessions = self.active_sessions.write().await; - sessions.remove(jti) - } - - pub async fn cleanup_expired_sessions(&self) -> usize { - let now = Utc::now().timestamp(); - let mut sessions = self.active_sessions.write().await; - let before = sessions.len(); - sessions.retain(|_, claims| claims.exp > now); - before - sessions.len() - } -} - -#[derive(Clone)] -pub struct JwtAuthProvider { - session_service: Arc, -} - -impl JwtAuthProvider { - pub fn new(session_service: Arc) -> Self { - Self { session_service } - } -} - -#[async_trait] -impl AuthProvider for JwtAuthProvider { - fn authenticate(&self, token: String) -> AuthFuture<'_> { - Box::pin(async move { - let claims = - self.session_service - .verify_session(&token) - .await - .map_err(|e| match e { - SessionError::TokenExpired => AuthError::TokenExpired, - _ => AuthError::InvalidToken, - })?; - - Ok(AuthenticatedUser { - user_id: claims.sub, - permissions: claims.permissions, - metadata: claims.metadata, - }) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ras_identity_core::StaticPermissions; - use ras_identity_local::LocalUserProvider; - - const TEST_SECRET: &str = "f27929932dc7269b950dc1e5c064111f105c67036a7386ca"; - - fn test_config() -> SessionConfig { - SessionConfig::new(TEST_SECRET, "ras-test", "ras-test").unwrap() - } - - async fn local_provider_with_user(username: &str, password: &str) -> LocalUserProvider { - let provider = LocalUserProvider::new(); - provider - .add_user( - username.to_string(), - password.to_string(), - Some(format!("{username}@example.com")), - Some(format!("{username} User")), - ) - .await - .unwrap(); - provider - } - - #[tokio::test] - async fn test_session_lifecycle() { - let config = test_config(); - let session_service = SessionService::new(config).unwrap(); - - let local_provider = LocalUserProvider::new(); - local_provider - .add_user( - "testuser".to_string(), - "password123".to_string(), - Some("test@example.com".to_string()), - Some("Test User".to_string()), - ) - .await - .unwrap(); - - session_service - .register_provider(Box::new(local_provider)) - .await; - - let auth_payload = serde_json::json!({ - "username": "testuser", - "password": "password123" - }); - - let token = session_service - .begin_session("local", auth_payload) - .await - .unwrap(); - - let claims = session_service.verify_session(&token).await.unwrap(); - assert_eq!(claims.sub, "testuser"); - assert_eq!(claims.provider_id, "local"); - assert!(claims.permissions.is_empty()); - - session_service.end_session(&claims.jti).await; - - assert!(session_service.verify_session(&token).await.is_err()); - } - - #[tokio::test] - async fn test_session_with_permissions() { - let config = test_config(); - let permissions_provider = Arc::new(StaticPermissions::new(vec![ - "read".to_string(), - "write".to_string(), - ])); - let session_service = SessionService::new(config) - .unwrap() - .with_permissions(permissions_provider); - - let local_provider = LocalUserProvider::new(); - local_provider - .add_user( - "admin".to_string(), - "admin123".to_string(), - Some("admin@example.com".to_string()), - Some("Admin User".to_string()), - ) - .await - .unwrap(); - - session_service - .register_provider(Box::new(local_provider)) - .await; - - let auth_payload = serde_json::json!({ - "username": "admin", - "password": "admin123" - }); - - let token = session_service - .begin_session("local", auth_payload) - .await - .unwrap(); - - let claims = session_service.verify_session(&token).await.unwrap(); - assert_eq!(claims.sub, "admin"); - assert_eq!(claims.permissions.len(), 2); - assert!(claims.permissions.contains("read")); - assert!(claims.permissions.contains("write")); - } - - #[test] - fn test_rejects_placeholder_secret() { - let result = SessionConfig::new("change-me-in-production", "i", "a"); - assert!(matches!(result, Err(SessionError::InvalidConfig(_)))); - } - - #[test] - fn debug_redacts_jwt_secret() { - let config = test_config(); - let debug = format!("{config:?}"); - assert!(!debug.contains(TEST_SECRET)); - assert!(debug.contains("[REDACTED]")); - } - - #[tokio::test] - async fn token_for_one_audience_is_rejected_by_another_service() { - // Two services share a secret but configure different audiences. - let service_a = SessionService::new(test_config().with_audience("svc-a")).unwrap(); - let local = LocalUserProvider::new(); - local - .add_user("u".to_string(), "password123".to_string(), None, None) - .await - .unwrap(); - service_a.register_provider(Box::new(local)).await; - - let token = service_a - .begin_session( - "local", - serde_json::json!({"username": "u", "password": "password123"}), - ) - .await - .unwrap(); - - // A service configured for a different audience rejects the token - // (the aud check runs before the active-session check). - let service_b = SessionService::new(test_config().with_audience("svc-b")).unwrap(); - assert!(matches!( - service_b.verify_session(&token).await, - Err(SessionError::InvalidSession) - )); - - // The issuing service (correct audience) still accepts it. - assert!(service_a.verify_session(&token).await.is_ok()); - } - - #[tokio::test] - async fn permissions_are_frozen_into_the_token_snapshot() { - // Verification returns the permissions captured at session creation. - let permissions_provider = Arc::new(StaticPermissions::new(vec!["read".to_string()])); - let service = SessionService::new(test_config()) - .unwrap() - .with_permissions(permissions_provider); - let local = LocalUserProvider::new(); - local - .add_user("u".to_string(), "password123".to_string(), None, None) - .await - .unwrap(); - service.register_provider(Box::new(local)).await; - - let token = service - .begin_session( - "local", - serde_json::json!({"username": "u", "password": "password123"}), - ) - .await - .unwrap(); - let claims = service.verify_session(&token).await.unwrap(); - assert_eq!(claims.permissions.len(), 1); - assert!(claims.permissions.contains("read")); - } - - #[tokio::test] - async fn test_cleanup_expired_sessions() { - let config = test_config(); - let service = SessionService::new(config).unwrap(); - - { - let mut sessions = service.active_sessions.write().await; - sessions.insert( - "expired".to_string(), - JwtClaims { - sub: "user".to_string(), - exp: Utc::now().timestamp() - 1, - iat: Utc::now().timestamp() - 10, - nbf: None, - jti: "expired".to_string(), - provider_id: "local".to_string(), - email: None, - display_name: None, - permissions: HashSet::new(), - metadata: None, - iss: None, - aud: None, - }, - ); - } - - assert_eq!(service.cleanup_expired_sessions().await, 1); - } - - #[tokio::test] - async fn test_malformed_exp_claim_is_rejected() { - let config = test_config(); - let service = SessionService::new(config).unwrap(); - - let token = encode_jwt( - &serde_json::json!({ - "sub": "user", - "exp": "not-a-number", - "iat": Utc::now().timestamp(), - "jti": "malformed", - "provider_id": "local", - "permissions": [], - }), - TEST_SECRET, - JwtAlgorithm::HS256, - ) - .unwrap(); - - assert!(service.verify_session(&token).await.is_err()); - } - - #[test] - fn session_config_rejects_non_positive_ttl() { - let mut config = test_config(); - config.jwt_ttl = Duration::zero(); - - let error = config.validate().expect_err("zero ttl should fail"); - - assert!( - matches!(error, SessionError::InvalidConfig(message) if message == "jwt_ttl must be positive") - ); - } - - #[tokio::test] - async fn begin_session_reports_unknown_identity_provider() { - let config = test_config(); - let service = SessionService::new(config).unwrap(); - - let error = service - .begin_session("missing", serde_json::json!({})) - .await - .expect_err("unknown provider should fail"); - - assert!( - matches!(error, SessionError::IdentityError(IdentityError::ProviderNotFound(provider)) if provider == "missing") - ); - } - - #[tokio::test] - async fn verify_session_can_skip_active_session_store_when_configured() { - let mut config = test_config(); - config.enforce_active_sessions = false; - let service = SessionService::new(config).unwrap(); - service - .register_provider(Box::new( - local_provider_with_user("stateless", "password123").await, - )) - .await; - - let token = service - .begin_session( - "local", - serde_json::json!({ - "username": "stateless", - "password": "password123" - }), - ) - .await - .unwrap(); - - let claims = service.verify_session(&token).await.unwrap(); - assert_eq!(claims.sub, "stateless"); - assert!( - service - .active_sessions - .read() - .await - .get(&claims.jti) - .is_none() - ); - } - - #[tokio::test] - async fn jwt_auth_provider_maps_verified_claims_to_authenticated_user() { - let config = test_config(); - let permissions = Arc::new(StaticPermissions::new(vec!["chat:read".to_string()])); - let service = Arc::new( - SessionService::new(config) - .unwrap() - .with_permissions(permissions), - ); - service - .register_provider(Box::new( - local_provider_with_user("alice", "password123").await, - )) - .await; - - let token = service - .begin_session( - "local", - serde_json::json!({ - "username": "alice", - "password": "password123" - }), - ) - .await - .unwrap(); - let auth_provider = JwtAuthProvider::new(service); - - let user = auth_provider.authenticate(token).await.unwrap(); - - assert_eq!(user.user_id, "alice"); - assert!(user.permissions.contains("chat:read")); - assert!(user.metadata.is_none()); - } - - #[tokio::test] - async fn cleanup_task_prunes_expired_sessions_in_background() { - let config = test_config(); - let service = std::sync::Arc::new(SessionService::new(config).unwrap()); - - // Plant an already-expired session directly in the store. - let now = chrono::Utc::now().timestamp(); - service.active_sessions.write().await.insert( - "expired-jti".to_string(), - JwtClaims { - sub: "alice".to_string(), - exp: now - 10, - iat: now - 20, - nbf: None, - jti: "expired-jti".to_string(), - provider_id: "local".to_string(), - email: None, - display_name: None, - permissions: HashSet::new(), - metadata: None, - iss: None, - aud: None, - }, - ); - assert_eq!(service.active_session_count().await, 1); - - let handle = service.start_cleanup_task(std::time::Duration::from_millis(20)); - - // The sweeper prunes the expired session without any begin/verify call. - tokio::time::timeout(std::time::Duration::from_secs(5), async { - while service.active_session_count().await != 0 { - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - }) - .await - .expect("cleanup task prunes expired sessions"); - - handle.abort(); - } - - // ---- S1 ------------------------------------------------------------- - - fn planted_claims(sub: &str, jti: &str, iat: i64, exp: i64) -> JwtClaims { - JwtClaims { - sub: sub.to_string(), - exp, - iat, - nbf: None, - jti: jti.to_string(), - provider_id: "local".to_string(), - email: None, - display_name: None, - permissions: HashSet::new(), - metadata: None, - iss: Some("ras-test".to_string()), - aud: Some("ras-test".to_string()), - } - } - - #[tokio::test] - async fn s1_verify_session_does_not_take_write_lock() { - let service = SessionService::new(test_config()).unwrap(); - service - .register_provider(Box::new( - local_provider_with_user("alice", "password123").await, - )) - .await; - let token = service - .begin_session( - "local", - serde_json::json!({"username": "alice", "password": "password123"}), - ) - .await - .unwrap(); - // Warm the lazy sweep so the next verify is definitely not "due". - service.verify_session(&token).await.unwrap(); - - // Hold a read guard on the store: a verify that tried to take the - // write lock (the old inline cleanup) would deadlock here. - let _read_guard = service.active_sessions.read().await; - let verified = tokio::time::timeout( - std::time::Duration::from_secs(2), - service.verify_session(&token), - ) - .await - .expect("verify_session must not block on the write lock"); - assert!(verified.is_ok()); - } - - #[tokio::test] - async fn s1_lazy_cleanup_runs_at_most_once_per_interval() { - let service = SessionService::new(test_config()).unwrap(); - service - .register_provider(Box::new( - local_provider_with_user("alice", "password123").await, - )) - .await; - let token = service - .begin_session( - "local", - serde_json::json!({"username": "alice", "password": "password123"}), - ) - .await - .unwrap(); - assert!( - service.next_lazy_cleanup.load(Ordering::Relaxed) >= LAZY_CLEANUP_INTERVAL_SECS, - "first call performs the lazy sweep and schedules the next one" - ); - - // Plant an expired entry after the sweep; a verify within the interval - // must leave it alone (no sweep), proving the once-per-interval gate. - let now = Utc::now().timestamp(); - service.active_sessions.write().await.insert( - "expired".into(), - planted_claims("bob", "expired", now - 20, now - 10), - ); - service.verify_session(&token).await.unwrap(); - assert_eq!(service.active_session_count().await, 2); - - // Pretend the interval has elapsed: the next call sweeps. - service.next_lazy_cleanup.store(0, Ordering::Relaxed); - service.verify_session(&token).await.unwrap(); - assert_eq!(service.active_session_count().await, 1); - } - - // ---- S2 ------------------------------------------------------------- - - #[test] - fn s2_iss_and_aud_are_required_by_default() { - let mut config = test_config(); - config.aud = None; - assert!(matches!( - config.validate(), - Err(SessionError::InvalidConfig(_)) - )); - assert!(matches!( - SessionService::new(config.clone()), - Err(SessionError::InvalidConfig(_)) - )); - - let mut config = test_config(); - config.iss = None; - assert!(matches!( - config.validate(), - Err(SessionError::InvalidConfig(_)) - )); - - // Struct-literal construction goes through the same check. - let literal = SessionConfig { - jwt_secret: TEST_SECRET.to_string(), - jwt_ttl: Duration::hours(1), - enforce_active_sessions: true, - algorithm: JwtAlgorithm::HS256, - iss: None, - aud: None, - require_iss_aud: true, - max_sessions_per_user: DEFAULT_MAX_SESSIONS_PER_USER, - }; - assert!(matches!( - SessionService::new(literal), - Err(SessionError::InvalidConfig(_)) - )); - } - - #[tokio::test] - async fn s2_allow_unscoped_tokens_is_an_explicit_opt_out() { - let config = SessionConfig::new_unscoped(TEST_SECRET).unwrap(); - assert!(!config.require_iss_aud); - assert!(config.iss.is_none() && config.aud.is_none()); - - let mut config = test_config(); - config.iss = None; - config.aud = None; - let config = config.allow_unscoped_tokens(); - let service = SessionService::new(config).unwrap(); - service - .register_provider(Box::new( - local_provider_with_user("alice", "password123").await, - )) - .await; - let token = service - .begin_session( - "local", - serde_json::json!({"username": "alice", "password": "password123"}), - ) - .await - .unwrap(); - let claims = service.verify_session(&token).await.unwrap(); - assert!(claims.iss.is_none() && claims.aud.is_none()); - } - - // ---- S3 ------------------------------------------------------------- - - #[test] - fn s3_secret_entropy_and_placeholder_checks() { - // Placeholder substrings, case-insensitive, anywhere in the value. - for bad in [ - "x9k2m4p7q1w5e8r3t6y0u2i4o6p8a1s3d5f7g9h1j3-SECRET", - "MyPassword-x9k2m4p7q1w5e8r3t6y0u2i4o6p8a1s3d5f7", - "x9k2m4p7q1w5e8r3t6y0u2i4o6p8a1s3d5f7-Example-value", - "your-secret-x9k2m4p7q1w5e8r3t6y0u2i4o6p8a1s3d5f7g9h", - "x9k2m4p7q1w5e8r3t6y0u2i4o6p8a1s3d5f7g9-ChangeMe", - "x9k2m4p7q1w5e8r3t6y0u2i4o6p8a1s3d5f7g9h12345678", - "x9k2m4p7q1w5e8r3t6y0u2i4o6p8a1s3d5f7g9h-insecure", - ] { - let err = validate_jwt_secret(bad).expect_err(bad); - assert!( - matches!(err, SessionError::InvalidConfig(ref m) if m.contains("placeholder")), - "{bad}: {err}" - ); - } - - // Fewer than MIN_DISTINCT_SECRET_BYTES distinct byte values. - let low_entropy = "abababababababababababababababababababab"; - let err = validate_jwt_secret(low_entropy).unwrap_err(); - assert!(matches!(err, SessionError::InvalidConfig(ref m) if m.contains("distinct"))); - - // A run of 8+ identical bytes, even with enough distinct bytes overall. - let run = "9876543210klmnopqrstuvwxyzZZZZZZZZ"; - let err = validate_jwt_secret(run).unwrap_err(); - assert!(matches!(err, SessionError::InvalidConfig(ref m) if m.contains("in a row"))); - - // A 7-byte run is still fine, and random-looking hex is accepted. - validate_jwt_secret("9876543210klmnopqrstuvwxyzZZZZZZZ").unwrap(); - validate_jwt_secret(TEST_SECRET).unwrap(); - validate_jwt_secret("a1a61a06cb81a0908d140f62f740f3f1e1f3a5df67c5cba5").unwrap(); - } - - // ---- S4 ------------------------------------------------------------- - - fn signed_token(service: &SessionService, claims: &JwtClaims) -> String { - encode_jwt(claims, &service.config.jwt_secret, service.config.algorithm).unwrap() - } - - #[tokio::test] - async fn s4_rejects_iat_and_nbf_in_the_future() { - let mut config = test_config(); - config.enforce_active_sessions = false; - let service = SessionService::new(config).unwrap(); - let now = Utc::now().timestamp(); - - // iat well in the future -> rejected. - let future_iat = - planted_claims("alice", "j1", now + CLOCK_SKEW_LEEWAY_SECS + 30, now + 3600); - assert!(matches!( - service - .verify_session(&signed_token(&service, &future_iat)) - .await, - Err(SessionError::InvalidSession) - )); - - // iat within leeway -> accepted. - let skewed_iat = - planted_claims("alice", "j2", now + CLOCK_SKEW_LEEWAY_SECS - 5, now + 3600); - service - .verify_session(&signed_token(&service, &skewed_iat)) - .await - .unwrap(); - - // nbf well in the future -> rejected. - let mut future_nbf = planted_claims("alice", "j3", now, now + 3600); - future_nbf.nbf = Some(now + CLOCK_SKEW_LEEWAY_SECS + 30); - assert!(matches!( - service - .verify_session(&signed_token(&service, &future_nbf)) - .await, - Err(SessionError::InvalidSession) - )); - - // nbf within leeway -> accepted; absent nbf (older tokens) -> accepted. - let mut skewed_nbf = planted_claims("alice", "j4", now, now + 3600); - skewed_nbf.nbf = Some(now + CLOCK_SKEW_LEEWAY_SECS - 5); - service - .verify_session(&signed_token(&service, &skewed_nbf)) - .await - .unwrap(); - let token_without_nbf = encode_jwt( - &serde_json::json!({ - "sub": "alice", "exp": now + 3600, "iat": now, "jti": "j5", - "provider_id": "local", "permissions": [], - "iss": "ras-test", "aud": "ras-test", - }), - TEST_SECRET, - JwtAlgorithm::HS256, - ) - .unwrap(); - let claims = service.verify_session(&token_without_nbf).await.unwrap(); - assert!(claims.nbf.is_none()); - } - - // ---- S5 ------------------------------------------------------------- - - #[tokio::test] - async fn s5_evicts_oldest_session_when_user_exceeds_cap() { - let config = test_config().with_max_sessions_per_user(2); - let service = SessionService::new(config).unwrap(); - service - .register_provider(Box::new( - local_provider_with_user("alice", "password123").await, - )) - .await; - // Another user's sessions must be unaffected by alice's cap. - let now = Utc::now().timestamp(); - service.active_sessions.write().await.insert( - "bob-1".into(), - planted_claims("bob", "bob-1", now - 100, now + 3600), - ); - - let login = || { - service.begin_session( - "local", - serde_json::json!({"username": "alice", "password": "password123"}), - ) - }; - let t1 = login().await.unwrap(); - let j1 = service.verify_session(&t1).await.unwrap().jti; - // Make t1 unambiguously the oldest by iat regardless of clock granularity. - service - .active_sessions - .write() - .await - .get_mut(&j1) - .unwrap() - .iat = now - 50; - let t2 = login().await.unwrap(); - let t3 = login().await.unwrap(); - - assert!( - matches!( - service.verify_session(&t1).await, - Err(SessionError::SessionNotFound) - ), - "oldest session is evicted once the cap is reached" - ); - assert!(service.verify_session(&t2).await.is_ok()); - assert!(service.verify_session(&t3).await.is_ok()); - assert_eq!( - service.active_session_count().await, - 3, - "2 for alice + 1 for bob" - ); - assert!(service.active_sessions.read().await.contains_key("bob-1")); - } - - #[test] - fn s5_max_sessions_per_user_must_be_positive() { - let config = test_config().with_max_sessions_per_user(0); - assert!(matches!( - config.validate(), - Err(SessionError::InvalidConfig(_)) - )); - } -} diff --git a/crates/identity/ras-identity-session/src/session.rs b/crates/identity/ras-identity-session/src/session.rs new file mode 100644 index 0000000..0709e3f --- /dev/null +++ b/crates/identity/ras-identity-session/src/session.rs @@ -0,0 +1,240 @@ +use crate::jwt::{decode_jwt, encode_jwt}; +use crate::{CLOCK_SKEW_LEEWAY_SECS, JwtClaims, SessionConfig, SessionError}; +use chrono::Utc; +use ras_identity_core::{IdentityError, IdentityProvider, UserPermissions}; +use std::collections::HashMap; +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; +use std::time::Instant; +use tokio::sync::RwLock; +use uuid::Uuid; + +/// Minimum spacing between lazy expired-session sweeps triggered from +/// `begin_session`/`verify_session` (S1). +const LAZY_CLEANUP_INTERVAL_SECS: u64 = 60; + +pub struct SessionService { + config: SessionConfig, + providers: Arc>>>, + /// Keyed by `jti`. + active_sessions: Arc>>, + permissions_provider: Option>, + /// Reference point for `next_lazy_cleanup`. + created_at: Instant, + /// Seconds since `created_at` at which the next lazy sweep is due (S1). + next_lazy_cleanup: AtomicU64, +} +impl SessionService { + pub fn new(config: SessionConfig) -> Result { + config.validate()?; + Ok(Self { + config, + providers: Arc::new(RwLock::new(HashMap::new())), + active_sessions: Arc::new(RwLock::new(HashMap::new())), + permissions_provider: None, + created_at: Instant::now(), + next_lazy_cleanup: AtomicU64::new(0), + }) + } + + /// Lazy fallback sweep (S1): prunes expired sessions at most once per + /// [`LAZY_CLEANUP_INTERVAL_SECS`], so deployments that never start + /// [`start_cleanup_task`](Self::start_cleanup_task) still get bounded + /// growth without taking the write lock on every request. + async fn maybe_lazy_cleanup(&self) { + if !self.config.enforce_active_sessions { + return; + } + let now = self.created_at.elapsed().as_secs(); + let next = self.next_lazy_cleanup.load(Ordering::Relaxed); + // The first call sweeps (next == 0), then at most once per interval. + // compare_exchange ensures only one of several concurrent callers + // performs the sweep; the losers see the bumped deadline and skip. + if now >= next + && self + .next_lazy_cleanup + .compare_exchange( + next, + now + LAZY_CLEANUP_INTERVAL_SECS, + Ordering::AcqRel, + Ordering::Relaxed, + ) + .is_ok() + { + self.cleanup_expired_sessions().await; + } + } + + pub fn with_permissions(mut self, provider: Arc) -> Self { + self.permissions_provider = Some(provider); + self + } + + pub fn set_permissions_provider(&mut self, provider: Arc) { + self.permissions_provider = Some(provider); + } + + pub async fn register_provider(&self, provider: Box) { + let mut providers = self.providers.write().await; + providers.insert(provider.provider_id().to_string(), provider); + } + + pub async fn begin_session( + &self, + provider_id: &str, + auth_payload: serde_json::Value, + ) -> Result { + self.maybe_lazy_cleanup().await; + + let providers = self.providers.read().await; + let provider = providers + .get(provider_id) + .ok_or_else(|| IdentityError::ProviderNotFound(provider_id.to_string()))?; + + let identity = provider.verify(auth_payload).await?; + + let now = Utc::now(); + let exp = now + self.config.jwt_ttl; + let jti = Uuid::new_v4().to_string(); + + let permissions = if let Some(ref perm_provider) = self.permissions_provider { + perm_provider.get_permissions(&identity).await? + } else { + Vec::new() + }; + + let claims = JwtClaims { + sub: identity.subject.clone(), + exp: exp.timestamp(), + iat: now.timestamp(), + nbf: None, + jti: jti.clone(), + provider_id: identity.provider_id.clone(), + email: identity.email.clone(), + display_name: identity.display_name.clone(), + permissions: permissions.into_iter().collect(), + metadata: identity.metadata, + iss: self.config.iss.clone(), + aud: self.config.aud.clone(), + }; + + if self.config.enforce_active_sessions { + let mut sessions = self.active_sessions.write().await; + // Per-user cap (S5): evict the oldest sessions (by iat) so this + // user never holds more than `max_sessions_per_user` entries. + let max = self.config.max_sessions_per_user; + let mut owned: Vec<(i64, String)> = sessions + .iter() + .filter(|(_, c)| c.sub == claims.sub) + .map(|(jti, c)| (c.iat, jti.clone())) + .collect(); + if owned.len() >= max { + owned.sort(); + let surplus = owned.len() + 1 - max; + for (_, old_jti) in owned.into_iter().take(surplus) { + sessions.remove(&old_jti); + } + } + sessions.insert(jti.clone(), claims.clone()); + } + + let token = encode_jwt(&claims, &self.config.jwt_secret, self.config.algorithm)?; + + Ok(token) + } + + pub async fn verify_session(&self, token: &str) -> Result { + self.maybe_lazy_cleanup().await; + + let claims = + decode_jwt::(token, &self.config.jwt_secret, self.config.algorithm)?; + + let now = Utc::now().timestamp(); + if claims.exp <= now { + return Err(SessionError::TokenExpired); + } + + // Time-validity guards (S4): a token issued or valid only in the + // future (beyond clock-skew leeway) is not accepted. + if claims.iat > now + CLOCK_SKEW_LEEWAY_SECS { + return Err(SessionError::InvalidSession); + } + if let Some(nbf) = claims.nbf + && nbf > now + CLOCK_SKEW_LEEWAY_SECS + { + return Err(SessionError::InvalidSession); + } + + // Cross-service confused-deputy guard: reject tokens minted for a + // different issuer/audience when this service configures them. + if let Some(expected_iss) = &self.config.iss + && claims.iss.as_deref() != Some(expected_iss.as_str()) + { + return Err(SessionError::InvalidSession); + } + if let Some(expected_aud) = &self.config.aud + && claims.aud.as_deref() != Some(expected_aud.as_str()) + { + return Err(SessionError::InvalidSession); + } + + if self.config.enforce_active_sessions { + let sessions = self.active_sessions.read().await; + if !sessions.contains_key(&claims.jti) { + return Err(SessionError::SessionNotFound); + } + } + + Ok(claims) + } + + /// Number of sessions currently held in the in-memory store + /// (only populated when `enforce_active_sessions` is on). + pub async fn active_session_count(&self) -> usize { + self.active_sessions.read().await.len() + } + + /// Spawn a background task pruning expired sessions every `interval`. + /// + /// Start this whenever `enforce_active_sessions` is on. Without it, + /// expired sessions are only pruned lazily (at most once a minute, and + /// only when begin_session/verify_session run), so a traffic lull leaves + /// them in memory until the next request. The task holds only a weak + /// reference and stops when the service is dropped (or when the returned + /// handle is aborted). + pub fn start_cleanup_task( + self: &std::sync::Arc, + interval: std::time::Duration, + ) -> tokio::task::JoinHandle<()> { + let service = std::sync::Arc::downgrade(self); + tokio::spawn(async move { + let mut timer = tokio::time::interval(interval); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + loop { + timer.tick().await; + let Some(service) = service.upgrade() else { + break; + }; + service.cleanup_expired_sessions().await; + } + }) + } + + pub async fn end_session(&self, jti: &str) -> Option { + let mut sessions = self.active_sessions.write().await; + sessions.remove(jti) + } + + pub async fn cleanup_expired_sessions(&self) -> usize { + let now = Utc::now().timestamp(); + let mut sessions = self.active_sessions.write().await; + let before = sessions.len(); + sessions.retain(|_, claims| claims.exp > now); + before - sessions.len() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/identity/ras-identity-session/src/session/tests.rs b/crates/identity/ras-identity-session/src/session/tests.rs new file mode 100644 index 0000000..277bae4 --- /dev/null +++ b/crates/identity/ras-identity-session/src/session/tests.rs @@ -0,0 +1,680 @@ +use crate::config::validate_jwt_secret; +use crate::{DEFAULT_MAX_SESSIONS_PER_USER, JwtAlgorithm, JwtAuthProvider}; +use chrono::Duration; +use ras_auth_core::AuthProvider; +use std::collections::HashSet; + +use super::*; +use ras_identity_core::StaticPermissions; +use ras_identity_local::LocalUserProvider; + +const TEST_SECRET: &str = "f27929932dc7269b950dc1e5c064111f105c67036a7386ca"; + +fn test_config() -> SessionConfig { + SessionConfig::new(TEST_SECRET, "ras-test", "ras-test").unwrap() +} + +async fn local_provider_with_user(username: &str, password: &str) -> LocalUserProvider { + let provider = LocalUserProvider::new(); + provider + .add_user( + username.to_string(), + password.to_string(), + Some(format!("{username}@example.com")), + Some(format!("{username} User")), + ) + .await + .unwrap(); + provider +} + +#[tokio::test] +async fn test_session_lifecycle() { + let config = test_config(); + let session_service = SessionService::new(config).unwrap(); + + let local_provider = LocalUserProvider::new(); + local_provider + .add_user( + "testuser".to_string(), + "password123".to_string(), + Some("test@example.com".to_string()), + Some("Test User".to_string()), + ) + .await + .unwrap(); + + session_service + .register_provider(Box::new(local_provider)) + .await; + + let auth_payload = serde_json::json!({ + "username": "testuser", + "password": "password123" + }); + + let token = session_service + .begin_session("local", auth_payload) + .await + .unwrap(); + + let claims = session_service.verify_session(&token).await.unwrap(); + assert_eq!(claims.sub, "testuser"); + assert_eq!(claims.provider_id, "local"); + assert!(claims.permissions.is_empty()); + + session_service.end_session(&claims.jti).await; + + assert!(session_service.verify_session(&token).await.is_err()); +} + +#[tokio::test] +async fn test_session_with_permissions() { + let config = test_config(); + let permissions_provider = Arc::new(StaticPermissions::new(vec![ + "read".to_string(), + "write".to_string(), + ])); + let session_service = SessionService::new(config) + .unwrap() + .with_permissions(permissions_provider); + + let local_provider = LocalUserProvider::new(); + local_provider + .add_user( + "admin".to_string(), + "admin123".to_string(), + Some("admin@example.com".to_string()), + Some("Admin User".to_string()), + ) + .await + .unwrap(); + + session_service + .register_provider(Box::new(local_provider)) + .await; + + let auth_payload = serde_json::json!({ + "username": "admin", + "password": "admin123" + }); + + let token = session_service + .begin_session("local", auth_payload) + .await + .unwrap(); + + let claims = session_service.verify_session(&token).await.unwrap(); + assert_eq!(claims.sub, "admin"); + assert_eq!(claims.permissions.len(), 2); + assert!(claims.permissions.contains("read")); + assert!(claims.permissions.contains("write")); +} + +#[test] +fn test_rejects_placeholder_secret() { + let result = SessionConfig::new("change-me-in-production", "i", "a"); + assert!(matches!(result, Err(SessionError::InvalidConfig(_)))); +} + +#[test] +fn debug_redacts_jwt_secret() { + let config = test_config(); + let debug = format!("{config:?}"); + assert!(!debug.contains(TEST_SECRET)); + assert!(debug.contains("[REDACTED]")); +} + +#[tokio::test] +async fn token_for_one_audience_is_rejected_by_another_service() { + // Two services share a secret but configure different audiences. + let service_a = SessionService::new(test_config().with_audience("svc-a")).unwrap(); + let local = LocalUserProvider::new(); + local + .add_user("u".to_string(), "password123".to_string(), None, None) + .await + .unwrap(); + service_a.register_provider(Box::new(local)).await; + + let token = service_a + .begin_session( + "local", + serde_json::json!({"username": "u", "password": "password123"}), + ) + .await + .unwrap(); + + // A service configured for a different audience rejects the token + // (the aud check runs before the active-session check). + let service_b = SessionService::new(test_config().with_audience("svc-b")).unwrap(); + assert!(matches!( + service_b.verify_session(&token).await, + Err(SessionError::InvalidSession) + )); + + // The issuing service (correct audience) still accepts it. + assert!(service_a.verify_session(&token).await.is_ok()); +} + +#[tokio::test] +async fn permissions_are_frozen_into_the_token_snapshot() { + // Verification returns the permissions captured at session creation. + let permissions_provider = Arc::new(StaticPermissions::new(vec!["read".to_string()])); + let service = SessionService::new(test_config()) + .unwrap() + .with_permissions(permissions_provider); + let local = LocalUserProvider::new(); + local + .add_user("u".to_string(), "password123".to_string(), None, None) + .await + .unwrap(); + service.register_provider(Box::new(local)).await; + + let token = service + .begin_session( + "local", + serde_json::json!({"username": "u", "password": "password123"}), + ) + .await + .unwrap(); + let claims = service.verify_session(&token).await.unwrap(); + assert_eq!(claims.permissions.len(), 1); + assert!(claims.permissions.contains("read")); +} + +#[tokio::test] +async fn test_cleanup_expired_sessions() { + let config = test_config(); + let service = SessionService::new(config).unwrap(); + + { + let mut sessions = service.active_sessions.write().await; + sessions.insert( + "expired".to_string(), + JwtClaims { + sub: "user".to_string(), + exp: Utc::now().timestamp() - 1, + iat: Utc::now().timestamp() - 10, + nbf: None, + jti: "expired".to_string(), + provider_id: "local".to_string(), + email: None, + display_name: None, + permissions: HashSet::new(), + metadata: None, + iss: None, + aud: None, + }, + ); + } + + assert_eq!(service.cleanup_expired_sessions().await, 1); +} + +#[tokio::test] +async fn test_malformed_exp_claim_is_rejected() { + let config = test_config(); + let service = SessionService::new(config).unwrap(); + + let token = encode_jwt( + &serde_json::json!({ + "sub": "user", + "exp": "not-a-number", + "iat": Utc::now().timestamp(), + "jti": "malformed", + "provider_id": "local", + "permissions": [], + }), + TEST_SECRET, + JwtAlgorithm::HS256, + ) + .unwrap(); + + assert!(service.verify_session(&token).await.is_err()); +} + +#[test] +fn session_config_rejects_non_positive_ttl() { + let mut config = test_config(); + config.jwt_ttl = Duration::zero(); + + let error = config.validate().expect_err("zero ttl should fail"); + + assert!( + matches!(error, SessionError::InvalidConfig(message) if message == "jwt_ttl must be positive") + ); +} + +#[tokio::test] +async fn begin_session_reports_unknown_identity_provider() { + let config = test_config(); + let service = SessionService::new(config).unwrap(); + + let error = service + .begin_session("missing", serde_json::json!({})) + .await + .expect_err("unknown provider should fail"); + + assert!( + matches!(error, SessionError::IdentityError(IdentityError::ProviderNotFound(provider)) if provider == "missing") + ); +} + +#[tokio::test] +async fn verify_session_can_skip_active_session_store_when_configured() { + let mut config = test_config(); + config.enforce_active_sessions = false; + let service = SessionService::new(config).unwrap(); + service + .register_provider(Box::new( + local_provider_with_user("stateless", "password123").await, + )) + .await; + + let token = service + .begin_session( + "local", + serde_json::json!({ + "username": "stateless", + "password": "password123" + }), + ) + .await + .unwrap(); + + let claims = service.verify_session(&token).await.unwrap(); + assert_eq!(claims.sub, "stateless"); + assert!( + service + .active_sessions + .read() + .await + .get(&claims.jti) + .is_none() + ); +} + +#[tokio::test] +async fn jwt_auth_provider_maps_verified_claims_to_authenticated_user() { + let config = test_config(); + let permissions = Arc::new(StaticPermissions::new(vec!["chat:read".to_string()])); + let service = Arc::new( + SessionService::new(config) + .unwrap() + .with_permissions(permissions), + ); + service + .register_provider(Box::new( + local_provider_with_user("alice", "password123").await, + )) + .await; + + let token = service + .begin_session( + "local", + serde_json::json!({ + "username": "alice", + "password": "password123" + }), + ) + .await + .unwrap(); + let auth_provider = JwtAuthProvider::new(service); + + let user = auth_provider.authenticate(token).await.unwrap(); + + assert_eq!(user.user_id, "alice"); + assert!(user.permissions.contains("chat:read")); + assert!(user.metadata.is_none()); +} + +#[tokio::test] +async fn cleanup_task_prunes_expired_sessions_in_background() { + let config = test_config(); + let service = std::sync::Arc::new(SessionService::new(config).unwrap()); + + // Plant an already-expired session directly in the store. + let now = chrono::Utc::now().timestamp(); + service.active_sessions.write().await.insert( + "expired-jti".to_string(), + JwtClaims { + sub: "alice".to_string(), + exp: now - 10, + iat: now - 20, + nbf: None, + jti: "expired-jti".to_string(), + provider_id: "local".to_string(), + email: None, + display_name: None, + permissions: HashSet::new(), + metadata: None, + iss: None, + aud: None, + }, + ); + assert_eq!(service.active_session_count().await, 1); + + let handle = service.start_cleanup_task(std::time::Duration::from_millis(20)); + + // The sweeper prunes the expired session without any begin/verify call. + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while service.active_session_count().await != 0 { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("cleanup task prunes expired sessions"); + + handle.abort(); +} + +// ---- S1 ------------------------------------------------------------- + +fn planted_claims(sub: &str, jti: &str, iat: i64, exp: i64) -> JwtClaims { + JwtClaims { + sub: sub.to_string(), + exp, + iat, + nbf: None, + jti: jti.to_string(), + provider_id: "local".to_string(), + email: None, + display_name: None, + permissions: HashSet::new(), + metadata: None, + iss: Some("ras-test".to_string()), + aud: Some("ras-test".to_string()), + } +} + +#[tokio::test] +async fn s1_verify_session_does_not_take_write_lock() { + let service = SessionService::new(test_config()).unwrap(); + service + .register_provider(Box::new( + local_provider_with_user("alice", "password123").await, + )) + .await; + let token = service + .begin_session( + "local", + serde_json::json!({"username": "alice", "password": "password123"}), + ) + .await + .unwrap(); + // Warm the lazy sweep so the next verify is definitely not "due". + service.verify_session(&token).await.unwrap(); + + // Hold a read guard on the store: a verify that tried to take the + // write lock (the old inline cleanup) would deadlock here. + let _read_guard = service.active_sessions.read().await; + let verified = tokio::time::timeout( + std::time::Duration::from_secs(2), + service.verify_session(&token), + ) + .await + .expect("verify_session must not block on the write lock"); + assert!(verified.is_ok()); +} + +#[tokio::test] +async fn s1_lazy_cleanup_runs_at_most_once_per_interval() { + let service = SessionService::new(test_config()).unwrap(); + service + .register_provider(Box::new( + local_provider_with_user("alice", "password123").await, + )) + .await; + let token = service + .begin_session( + "local", + serde_json::json!({"username": "alice", "password": "password123"}), + ) + .await + .unwrap(); + assert!( + service.next_lazy_cleanup.load(Ordering::Relaxed) >= LAZY_CLEANUP_INTERVAL_SECS, + "first call performs the lazy sweep and schedules the next one" + ); + + // Plant an expired entry after the sweep; a verify within the interval + // must leave it alone (no sweep), proving the once-per-interval gate. + let now = Utc::now().timestamp(); + service.active_sessions.write().await.insert( + "expired".into(), + planted_claims("bob", "expired", now - 20, now - 10), + ); + service.verify_session(&token).await.unwrap(); + assert_eq!(service.active_session_count().await, 2); + + // Pretend the interval has elapsed: the next call sweeps. + service.next_lazy_cleanup.store(0, Ordering::Relaxed); + service.verify_session(&token).await.unwrap(); + assert_eq!(service.active_session_count().await, 1); +} + +// ---- S2 ------------------------------------------------------------- + +#[test] +fn s2_iss_and_aud_are_required_by_default() { + let mut config = test_config(); + config.aud = None; + assert!(matches!( + config.validate(), + Err(SessionError::InvalidConfig(_)) + )); + assert!(matches!( + SessionService::new(config.clone()), + Err(SessionError::InvalidConfig(_)) + )); + + let mut config = test_config(); + config.iss = None; + assert!(matches!( + config.validate(), + Err(SessionError::InvalidConfig(_)) + )); + + // Struct-literal construction goes through the same check. + let literal = SessionConfig { + jwt_secret: TEST_SECRET.to_string(), + jwt_ttl: Duration::hours(1), + enforce_active_sessions: true, + algorithm: JwtAlgorithm::HS256, + iss: None, + aud: None, + require_iss_aud: true, + max_sessions_per_user: DEFAULT_MAX_SESSIONS_PER_USER, + }; + assert!(matches!( + SessionService::new(literal), + Err(SessionError::InvalidConfig(_)) + )); +} + +#[tokio::test] +async fn s2_allow_unscoped_tokens_is_an_explicit_opt_out() { + let config = SessionConfig::new_unscoped(TEST_SECRET).unwrap(); + assert!(!config.require_iss_aud); + assert!(config.iss.is_none() && config.aud.is_none()); + + let mut config = test_config(); + config.iss = None; + config.aud = None; + let config = config.allow_unscoped_tokens(); + let service = SessionService::new(config).unwrap(); + service + .register_provider(Box::new( + local_provider_with_user("alice", "password123").await, + )) + .await; + let token = service + .begin_session( + "local", + serde_json::json!({"username": "alice", "password": "password123"}), + ) + .await + .unwrap(); + let claims = service.verify_session(&token).await.unwrap(); + assert!(claims.iss.is_none() && claims.aud.is_none()); +} + +// ---- S3 ------------------------------------------------------------- + +#[test] +fn s3_secret_entropy_and_placeholder_checks() { + // Placeholder substrings, case-insensitive, anywhere in the value. + for bad in [ + "x9k2m4p7q1w5e8r3t6y0u2i4o6p8a1s3d5f7g9h1j3-SECRET", + "MyPassword-x9k2m4p7q1w5e8r3t6y0u2i4o6p8a1s3d5f7", + "x9k2m4p7q1w5e8r3t6y0u2i4o6p8a1s3d5f7-Example-value", + "your-secret-x9k2m4p7q1w5e8r3t6y0u2i4o6p8a1s3d5f7g9h", + "x9k2m4p7q1w5e8r3t6y0u2i4o6p8a1s3d5f7g9-ChangeMe", + "x9k2m4p7q1w5e8r3t6y0u2i4o6p8a1s3d5f7g9h12345678", + "x9k2m4p7q1w5e8r3t6y0u2i4o6p8a1s3d5f7g9h-insecure", + ] { + let err = validate_jwt_secret(bad).expect_err(bad); + assert!( + matches!(err, SessionError::InvalidConfig(ref m) if m.contains("placeholder")), + "{bad}: {err}" + ); + } + + // Fewer than MIN_DISTINCT_SECRET_BYTES distinct byte values. + let low_entropy = "abababababababababababababababababababab"; + let err = validate_jwt_secret(low_entropy).unwrap_err(); + assert!(matches!(err, SessionError::InvalidConfig(ref m) if m.contains("distinct"))); + + // A run of 8+ identical bytes, even with enough distinct bytes overall. + let run = "9876543210klmnopqrstuvwxyzZZZZZZZZ"; + let err = validate_jwt_secret(run).unwrap_err(); + assert!(matches!(err, SessionError::InvalidConfig(ref m) if m.contains("in a row"))); + + // A 7-byte run is still fine, and random-looking hex is accepted. + validate_jwt_secret("9876543210klmnopqrstuvwxyzZZZZZZZ").unwrap(); + validate_jwt_secret(TEST_SECRET).unwrap(); + validate_jwt_secret("a1a61a06cb81a0908d140f62f740f3f1e1f3a5df67c5cba5").unwrap(); +} + +// ---- S4 ------------------------------------------------------------- + +fn signed_token(service: &SessionService, claims: &JwtClaims) -> String { + encode_jwt(claims, &service.config.jwt_secret, service.config.algorithm).unwrap() +} + +#[tokio::test] +async fn s4_rejects_iat_and_nbf_in_the_future() { + let mut config = test_config(); + config.enforce_active_sessions = false; + let service = SessionService::new(config).unwrap(); + let now = Utc::now().timestamp(); + + // iat well in the future -> rejected. + let future_iat = planted_claims("alice", "j1", now + CLOCK_SKEW_LEEWAY_SECS + 30, now + 3600); + assert!(matches!( + service + .verify_session(&signed_token(&service, &future_iat)) + .await, + Err(SessionError::InvalidSession) + )); + + // iat within leeway -> accepted. + let skewed_iat = planted_claims("alice", "j2", now + CLOCK_SKEW_LEEWAY_SECS - 5, now + 3600); + service + .verify_session(&signed_token(&service, &skewed_iat)) + .await + .unwrap(); + + // nbf well in the future -> rejected. + let mut future_nbf = planted_claims("alice", "j3", now, now + 3600); + future_nbf.nbf = Some(now + CLOCK_SKEW_LEEWAY_SECS + 30); + assert!(matches!( + service + .verify_session(&signed_token(&service, &future_nbf)) + .await, + Err(SessionError::InvalidSession) + )); + + // nbf within leeway -> accepted; absent nbf (older tokens) -> accepted. + let mut skewed_nbf = planted_claims("alice", "j4", now, now + 3600); + skewed_nbf.nbf = Some(now + CLOCK_SKEW_LEEWAY_SECS - 5); + service + .verify_session(&signed_token(&service, &skewed_nbf)) + .await + .unwrap(); + let token_without_nbf = encode_jwt( + &serde_json::json!({ + "sub": "alice", "exp": now + 3600, "iat": now, "jti": "j5", + "provider_id": "local", "permissions": [], + "iss": "ras-test", "aud": "ras-test", + }), + TEST_SECRET, + JwtAlgorithm::HS256, + ) + .unwrap(); + let claims = service.verify_session(&token_without_nbf).await.unwrap(); + assert!(claims.nbf.is_none()); +} + +// ---- S5 ------------------------------------------------------------- + +#[tokio::test] +async fn s5_evicts_oldest_session_when_user_exceeds_cap() { + let config = test_config().with_max_sessions_per_user(2); + let service = SessionService::new(config).unwrap(); + service + .register_provider(Box::new( + local_provider_with_user("alice", "password123").await, + )) + .await; + // Another user's sessions must be unaffected by alice's cap. + let now = Utc::now().timestamp(); + service.active_sessions.write().await.insert( + "bob-1".into(), + planted_claims("bob", "bob-1", now - 100, now + 3600), + ); + + let login = || { + service.begin_session( + "local", + serde_json::json!({"username": "alice", "password": "password123"}), + ) + }; + let t1 = login().await.unwrap(); + let j1 = service.verify_session(&t1).await.unwrap().jti; + // Make t1 unambiguously the oldest by iat regardless of clock granularity. + service + .active_sessions + .write() + .await + .get_mut(&j1) + .unwrap() + .iat = now - 50; + let t2 = login().await.unwrap(); + let t3 = login().await.unwrap(); + + assert!( + matches!( + service.verify_session(&t1).await, + Err(SessionError::SessionNotFound) + ), + "oldest session is evicted once the cap is reached" + ); + assert!(service.verify_session(&t2).await.is_ok()); + assert!(service.verify_session(&t3).await.is_ok()); + assert_eq!( + service.active_session_count().await, + 3, + "2 for alice + 1 for bob" + ); + assert!(service.active_sessions.read().await.contains_key("bob-1")); +} + +#[test] +fn s5_max_sessions_per_user_must_be_positive() { + let config = test_config().with_max_sessions_per_user(0); + assert!(matches!( + config.validate(), + Err(SessionError::InvalidConfig(_)) + )); +} diff --git a/documentation/reviews/refactor-progress.md b/documentation/reviews/refactor-progress.md index 9d3b70c..accbdad 100644 --- a/documentation/reviews/refactor-progress.md +++ b/documentation/reviews/refactor-progress.md @@ -26,3 +26,4 @@ REST baseline: 61/61 passed. | 10 | OpenAPI schema collection/normalization and operation emission | 61 tests, doctest, Clippy/features, 11 browser tests. 64 original and extracted document samples produce the same four JSON variants: schema titles already vary with HashMap insertion order. No output policy changed. | | 11 | OpenRPC schemas/references, examples, and methods | 59 tests, docs/Clippy/features, 11 browser tests; three baseline JSON documents equal all 64 extracted samples each. | | 12 | HTTP credential, cookie, CSRF, and redaction policy behind the transport facade | Auth baseline 39 tests; result 208 auth/HTTP macro tests; all 24 transport tests retained under scenario owners; docs/Clippy/macro feature matrix. | +| 13 | Session config, claims, JWT signing/verification, lifecycle, and auth adapter | Before and after: 40 identity tests pass (1 existing skipped), including both original crashing cases; docs/Clippy and chat consumer build. Original full-workspace SIGSEGV cause remains unreproduced. | From 852e414242f01e3b631500921c57a9b9b5131fff Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 11:35:55 +0200 Subject: [PATCH 14/35] refactor(oauth2): separate authorization and token validation policy --- .../ras-identity-oauth2/src/client.rs | 1280 ----------------- .../src/client/authorization.rs | 149 ++ .../src/client/id_token.rs | 113 ++ .../ras-identity-oauth2/src/client/mod.rs | 190 +++ .../ras-identity-oauth2/src/client/pkce.rs | 48 + .../src/client/tests/authorization.rs | 151 ++ .../src/client/tests/callback.rs | 256 ++++ .../src/client/tests/id_token.rs | 110 ++ .../src/client/tests/mod.rs | 114 ++ .../src/client/tests/pkce.rs | 20 + .../src/client/tests/userinfo.rs | 45 + .../src/client/transport.rs | 111 ++ .../ras-identity-oauth2/src/provider.rs | 429 +----- .../ras-identity-oauth2/src/provider/tests.rs | 425 ++++++ documentation/reviews/refactor-progress.md | 1 + 15 files changed, 1734 insertions(+), 1708 deletions(-) delete mode 100644 crates/identity/ras-identity-oauth2/src/client.rs create mode 100644 crates/identity/ras-identity-oauth2/src/client/authorization.rs create mode 100644 crates/identity/ras-identity-oauth2/src/client/id_token.rs create mode 100644 crates/identity/ras-identity-oauth2/src/client/mod.rs create mode 100644 crates/identity/ras-identity-oauth2/src/client/pkce.rs create mode 100644 crates/identity/ras-identity-oauth2/src/client/tests/authorization.rs create mode 100644 crates/identity/ras-identity-oauth2/src/client/tests/callback.rs create mode 100644 crates/identity/ras-identity-oauth2/src/client/tests/id_token.rs create mode 100644 crates/identity/ras-identity-oauth2/src/client/tests/mod.rs create mode 100644 crates/identity/ras-identity-oauth2/src/client/tests/pkce.rs create mode 100644 crates/identity/ras-identity-oauth2/src/client/tests/userinfo.rs create mode 100644 crates/identity/ras-identity-oauth2/src/client/transport.rs create mode 100644 crates/identity/ras-identity-oauth2/src/provider/tests.rs diff --git a/crates/identity/ras-identity-oauth2/src/client.rs b/crates/identity/ras-identity-oauth2/src/client.rs deleted file mode 100644 index e5d43ec..0000000 --- a/crates/identity/ras-identity-oauth2/src/client.rs +++ /dev/null @@ -1,1280 +0,0 @@ -//! OAuth2 client implementation with PKCE support. - -use crate::config::OAuth2ProviderConfig; -use crate::error::{OAuth2Error, OAuth2Result}; -use crate::state::{OAuth2State, OAuth2StateStore}; -use crate::types::{AuthorizationResponse, TokenResponse, UserInfoResponse}; -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use rand::{Rng, thread_rng}; -use reqwest::Client; -use sha2::{Digest, Sha256}; -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; -use subtle::ConstantTimeEq; -use tracing::{debug, error, info, warn}; -use url::Url; - -#[async_trait::async_trait] -pub(crate) trait OAuth2HttpTransport: Send + Sync { - async fn exchange_code( - &self, - token_endpoint: &str, - params: &HashMap, - ) -> OAuth2Result; - - async fn get_user_info( - &self, - userinfo_endpoint: &str, - access_token: &str, - ) -> OAuth2Result; -} - -#[derive(Clone)] -struct ReqwestOAuth2HttpTransport { - client: Client, -} - -#[async_trait::async_trait] -impl OAuth2HttpTransport for ReqwestOAuth2HttpTransport { - async fn exchange_code( - &self, - token_endpoint: &str, - params: &HashMap, - ) -> OAuth2Result { - let response = self - .client - .post(token_endpoint) - .form(params) - .send() - .await - .map_err(log_upstream_error)?; - - if !response.status().is_success() { - // Never log or propagate the raw provider response body — it can - // contain tokens or other sensitive material. Status only. - let status = response.status(); - error!("Token exchange failed with status {}", status); - return Err(OAuth2Error::TokenExchangeFailed(format!( - "token endpoint returned status {status}" - ))); - } - - let token_response: TokenResponse = response.json().await.map_err(|e| { - // reqwest decode errors embed the request URL; keep that in the log only (I6). - warn!(error = %e, "token endpoint returned an undecodable response"); - OAuth2Error::InvalidTokenResponse("undecodable token response".to_string()) - })?; - - info!("Successfully exchanged code for tokens"); - Ok(token_response) - } - - async fn get_user_info( - &self, - userinfo_endpoint: &str, - access_token: &str, - ) -> OAuth2Result { - let response = self - .client - .get(userinfo_endpoint) - .bearer_auth(access_token) - .send() - .await - .map_err(log_upstream_error)?; - - if !response.status().is_success() { - // Status only; the raw body may echo the bearer token. - let status = response.status(); - error!("User info request failed with status {}", status); - return Err(OAuth2Error::UserInfoFailed(format!( - "userinfo endpoint returned status {status}" - ))); - } - - let user_info: UserInfoResponse = response.json().await.map_err(|e| { - warn!(error = %e, "userinfo endpoint returned an undecodable response"); - OAuth2Error::InvalidUserInfoResponse("undecodable userinfo response".to_string()) - })?; - - debug!( - "Successfully retrieved user info for subject: {}", - user_info.sub - ); - Ok(user_info) - } -} - -/// PKCE code challenge and verifier -#[derive(Debug, Clone)] -pub struct PkceChallenge { - pub code_verifier: String, - pub code_challenge: String, - pub code_challenge_method: String, -} - -impl Default for PkceChallenge { - fn default() -> Self { - Self::new() - } -} - -impl PkceChallenge { - /// Generate a new PKCE challenge - pub fn new() -> Self { - let code_verifier = Self::generate_code_verifier(); - let code_challenge = Self::generate_code_challenge(&code_verifier); - - Self { - code_verifier, - code_challenge, - code_challenge_method: "S256".to_string(), - } - } - - fn generate_code_verifier() -> String { - let mut rng = thread_rng(); - let bytes: Vec = (0..64).map(|_| rng.r#gen::()).collect(); - URL_SAFE_NO_PAD.encode(bytes) - } - - fn generate_code_challenge(verifier: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(verifier.as_bytes()); - let result = hasher.finalize(); - URL_SAFE_NO_PAD.encode(result) - } -} - -/// Reserved OAuth/OIDC query parameters that the library sets itself. Neither -/// `provider_config.auth_params` nor caller-supplied `additional_params` may -/// override them — many providers honour the last occurrence of a duplicated -/// query parameter, so an injected second `redirect_uri` / `state` / PKCE value -/// would be an authorization-code-theft or CSRF vector. -const RESERVED_AUTH_PARAMS: &[&str] = &[ - "response_type", - "client_id", - "client_secret", - "redirect_uri", - "state", - "nonce", - "scope", - "code_challenge", - "code_challenge_method", - "grant_type", - "code", - "code_verifier", - // OIDC request objects (Core §6): parameters inside a `request` / - // `request_uri` JWT take precedence over the query parameters we set, so - // permitting them would re-establish the exact override primitive this - // denylist removes (e.g. silently dropping PKCE or overriding state/nonce). - "request", - "request_uri", - // Response delivery / audience controls a caller must not influence. - "response_mode", - "resource", - "audience", - "id_token_hint", -]; - -/// Reject any key that collides (case-insensitively) with a reserved parameter. -fn reject_reserved_params<'a, I>(keys: I, source: &str) -> OAuth2Result<()> -where - I: IntoIterator, -{ - for key in keys { - if RESERVED_AUTH_PARAMS - .iter() - .any(|reserved| reserved.eq_ignore_ascii_case(key)) - { - return Err(OAuth2Error::InvalidAuthorizationParam(format!( - "{source} may not set the reserved parameter `{key}`" - ))); - } - } - Ok(()) -} - -/// OAuth2 client for handling authorization flows -#[derive(Clone)] -pub struct OAuth2Client { - http_transport: Arc, - state_store: Arc, - state_ttl_seconds: u64, -} - -impl OAuth2Client { - /// Create a client with bounded HTTP timeouts. - /// - /// # Panics - /// Panics if the HTTP client cannot be built. Use [`Self::try_new`] to - /// handle construction errors. - pub fn new( - state_store: Arc, - state_ttl_seconds: u64, - http_timeout_seconds: u64, - ) -> Self { - Self::try_new(state_store, state_ttl_seconds, http_timeout_seconds) - .expect("failed to build OAuth2 HTTP client") - } - - pub fn try_new( - state_store: Arc, - state_ttl_seconds: u64, - http_timeout_seconds: u64, - ) -> OAuth2Result { - let http_client = Client::builder() - .timeout(Duration::from_secs(http_timeout_seconds)) - .build()?; - - Ok(Self { - http_transport: Arc::new(ReqwestOAuth2HttpTransport { - client: http_client, - }), - state_store, - state_ttl_seconds, - }) - } - - #[cfg(test)] - pub(crate) fn with_http_transport( - state_store: Arc, - state_ttl_seconds: u64, - http_transport: Arc, - ) -> Self { - Self { - http_transport, - state_store, - state_ttl_seconds, - } - } - - #[cfg(test)] - pub fn state_store(&self) -> &Arc { - &self.state_store - } - - /// Generate authorization URL for a provider - pub async fn generate_authorization_url( - &self, - provider_config: &OAuth2ProviderConfig, - additional_params: HashMap, - ) -> OAuth2Result<(String, String)> { - self.generate_authorization_url_bound(provider_config, additional_params, None) - .await - } - - /// Generate an authorization URL bound to the initiating browser session. - /// - /// `binding` should be an unguessable value the integrator can recover on - /// callback (e.g. a random cookie value); the callback must then present - /// the identical value, preventing login CSRF where an attacker tricks a - /// victim into completing the attacker's flow. - pub async fn generate_authorization_url_bound( - &self, - provider_config: &OAuth2ProviderConfig, - additional_params: HashMap, - binding: Option, - ) -> OAuth2Result<(String, String)> { - // Reject reserved-parameter overrides before doing any work. - reject_reserved_params(provider_config.auth_params.keys(), "provider auth_params")?; - reject_reserved_params(additional_params.keys(), "additional_params")?; - - let mut url = Url::parse(&provider_config.authorization_endpoint)?; - - // Generate PKCE if enabled - let pkce = if provider_config.use_pkce { - Some(PkceChallenge::new()) - } else { - None - }; - - // OIDC nonce: echoed back inside the id_token and verified on - // callback, binding the token to this authorization request. - let nonce = uuid::Uuid::new_v4().to_string(); - - // Create and store state - let state = OAuth2State::new( - provider_config.provider_id.clone(), - provider_config.redirect_uri.clone(), - pkce.as_ref().map(|p| p.code_verifier.clone()), - self.state_ttl_seconds, - ) - .with_nonce(nonce.clone()) - .with_binding(binding); - - let state_param = state.state.clone(); - self.state_store.store(state).await?; - - // Build query parameters - let mut params = url.query_pairs_mut(); - params.append_pair("response_type", "code"); - params.append_pair("client_id", &provider_config.client_id); - params.append_pair("redirect_uri", &provider_config.redirect_uri); - params.append_pair("state", &state_param); - params.append_pair("nonce", &nonce); - - // Add scopes - if !provider_config.scopes.is_empty() { - params.append_pair("scope", &provider_config.scopes.join(" ")); - } - - // Add PKCE parameters - if let Some(pkce) = &pkce { - params.append_pair("code_challenge", &pkce.code_challenge); - params.append_pair("code_challenge_method", &pkce.code_challenge_method); - } - - // Add provider-specific parameters - for (key, value) in &provider_config.auth_params { - params.append_pair(key, value); - } - - // Add additional parameters from the request - for (key, value) in &additional_params { - params.append_pair(key, value); - } - - drop(params); - - let auth_url = url.to_string(); - debug!( - "Generated authorization URL for provider {}", - provider_config.provider_id - ); - - Ok((auth_url, state_param)) - } - - /// Handle OAuth2 callback and exchange code for tokens - pub async fn handle_callback( - &self, - provider_config: &OAuth2ProviderConfig, - callback_response: AuthorizationResponse, - ) -> OAuth2Result { - // Verify state - let state = self.state_store.retrieve(&callback_response.state).await?; - - if state.provider_id != provider_config.provider_id { - return Err(OAuth2Error::InvalidState); - } - - // When the flow was bound to a browser session, the callback must - // present the identical binding value (login-CSRF guard). Compared in - // constant time so the binding cannot be recovered byte-by-byte (I7). - if let Some(expected) = &state.binding - && !binding_matches(expected, callback_response.binding.as_deref()) - { - return Err(OAuth2Error::InvalidState); - } - - // Check for errors in callback. Only the standardized error code is - // surfaced; the free-text description stays in the server log (I5). - if let Some(error) = &callback_response.error { - warn!( - provider = %provider_config.provider_id, - error = %error, - error_description = callback_response.error_description.as_deref().unwrap_or(""), - "OAuth2 provider returned an error on callback" - ); - return Err(OAuth2Error::ProviderDenied { - error: error.clone(), - }); - } - - let Some(code) = callback_response.code.as_deref() else { - return Err(OAuth2Error::InvalidCallback); - }; - - // Exchange authorization code for tokens - let token_response = self - .exchange_code(provider_config, code, state.code_verifier.as_deref()) - .await?; - - // Validate id_token claims when the provider returned one. The token - // arrived directly from the token endpoint over TLS, which OIDC Core - // §3.1.3.7 permits in place of signature validation for the code - // flow — but iss / aud / exp / nonce are still mandatory checks. - if let Some(id_token) = &token_response.id_token { - validate_id_token_claims(provider_config, id_token, state.nonce.as_deref())?; - } - - Ok(token_response) - } - - /// Exchange authorization code for tokens - async fn exchange_code( - &self, - provider_config: &OAuth2ProviderConfig, - code: &str, - code_verifier: Option<&str>, - ) -> OAuth2Result { - let mut params = HashMap::new(); - params.insert("grant_type".to_string(), "authorization_code".to_string()); - params.insert("code".to_string(), code.to_string()); - params.insert("client_id".to_string(), provider_config.client_id.clone()); - params.insert( - "client_secret".to_string(), - provider_config.client_secret.clone(), - ); - params.insert( - "redirect_uri".to_string(), - provider_config.redirect_uri.clone(), - ); - - // Add PKCE verifier if present - if let Some(verifier) = code_verifier { - params.insert("code_verifier".to_string(), verifier.to_string()); - } - - self.http_transport - .exchange_code(&provider_config.token_endpoint, ¶ms) - .await - } - - /// Get user info using access token - pub async fn get_user_info( - &self, - provider_config: &OAuth2ProviderConfig, - access_token: &str, - ) -> OAuth2Result { - let userinfo_endpoint = provider_config.userinfo_endpoint.as_ref().ok_or_else(|| { - OAuth2Error::ConfigError("User info endpoint not configured".to_string()) - })?; - - self.http_transport - .get_user_info(userinfo_endpoint, access_token) - .await - } -} - -/// Claims checked on an id_token returned by the token endpoint. -#[derive(serde::Deserialize)] -struct IdTokenClaims { - iss: Option, - sub: Option, - aud: Option, - /// Authorized party — required to equal `client_id` when `aud` has multiple - /// entries (OIDC Core §3.1.3.7 / §2). - azp: Option, - exp: Option, - nonce: Option, -} - -/// Subject (`sub`) claim of an id_token, used to bind it to the userinfo -/// response so a confused-deputy userinfo cannot change the account. -pub(crate) fn id_token_subject(id_token: &str) -> OAuth2Result> { - Ok(decode_id_token_claims(id_token)?.sub) -} - -fn decode_id_token_claims(id_token: &str) -> OAuth2Result { - let payload = id_token - .split('.') - .nth(1) - .ok_or_else(|| OAuth2Error::InvalidIdToken("malformed JWT".to_string()))?; - let bytes = URL_SAFE_NO_PAD - .decode(payload) - .map_err(|_| OAuth2Error::InvalidIdToken("invalid base64 payload".to_string()))?; - serde_json::from_slice(&bytes) - .map_err(|_| OAuth2Error::InvalidIdToken("invalid JSON payload".to_string())) -} - -/// Validate the id_token issuer, audience, expiry, subject, and expected nonce. -/// Accepting an id_token requires a configured provider issuer. -/// -/// The signature is not verified: the token was received directly from the -/// token endpoint over TLS, which OIDC Core §3.1.3.7 permits as a substitute -/// for signature validation in the authorization-code flow. -/// Log a transport-level failure at `warn` (the `reqwest::Error` carries the -/// request URL) and hand back the fixed-message error variant (I6). -fn log_upstream_error(error: reqwest::Error) -> OAuth2Error { - warn!(error = %error, "upstream OAuth2 request failed"); - OAuth2Error::HttpError(error) -} - -/// Constant-time comparison of the stored session binding against the value -/// presented on callback (I7). A missing callback value never matches. -fn binding_matches(expected: &str, presented: Option<&str>) -> bool { - match presented { - Some(presented) => { - // `ct_eq` on slices short-circuits on length, but the length of the - // binding is not secret (it is a UUID or caller-chosen value). - expected.as_bytes().ct_eq(presented.as_bytes()).into() - } - None => false, - } -} - -pub(crate) fn validate_id_token_claims( - provider_config: &OAuth2ProviderConfig, - id_token: &str, - expected_nonce: Option<&str>, -) -> OAuth2Result<()> { - let claims = decode_id_token_claims(id_token)?; - - // Issuer is fail-closed: an id_token whose issuer is unverified cannot be - // trusted to identify the account, so accepting one without a configured - // `issuer` is refused rather than silently skipped. - let Some(expected_issuer) = &provider_config.issuer else { - return Err(OAuth2Error::InvalidIdToken( - "provider `issuer` must be configured to accept id_tokens".to_string(), - )); - }; - if claims.iss.as_deref() != Some(expected_issuer.as_str()) { - return Err(OAuth2Error::InvalidIdToken(format!( - "issuer mismatch: expected {expected_issuer}" - ))); - } - - let client_id = provider_config.client_id.as_str(); - let audience_matches = match &claims.aud { - Some(serde_json::Value::String(aud)) => aud == client_id, - Some(serde_json::Value::Array(auds)) => { - let contains = auds.iter().any(|aud| aud.as_str() == Some(client_id)); - if !contains { - false - } else if auds.len() > 1 { - // Multiple audiences: `azp` must be present and equal client_id. - claims.azp.as_deref() == Some(client_id) - } else { - true - } - } - _ => false, - }; - if !audience_matches { - return Err(OAuth2Error::InvalidIdToken( - "audience does not include this client (or azp mismatch for multi-audience token)" - .to_string(), - )); - } - - match claims.exp { - Some(exp) if exp > chrono::Utc::now().timestamp() => {} - _ => { - return Err(OAuth2Error::InvalidIdToken( - "token expired or missing exp".to_string(), - )); - } - } - - if let Some(expected) = expected_nonce - && claims.nonce.as_deref() != Some(expected) - { - return Err(OAuth2Error::InvalidIdToken("nonce mismatch".to_string())); - } - - // `sub` is REQUIRED by OIDC Core §2. Refuse an id_token without it so the - // userinfo <-> id_token subject binding cannot silently no-op on a - // token that carries no subject. - match claims.sub.as_deref() { - Some(sub) if !sub.trim().is_empty() => {} - _ => { - return Err(OAuth2Error::InvalidIdToken( - "id_token is missing the required `sub` claim".to_string(), - )); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::state::{InMemoryStateStore, OAuth2StateStore}; - use std::sync::Mutex; - - struct RecordingTransport { - token_requests: Mutex)>>, - userinfo_requests: Mutex>, - } - - impl RecordingTransport { - fn new() -> Self { - Self { - token_requests: Mutex::new(Vec::new()), - userinfo_requests: Mutex::new(Vec::new()), - } - } - - fn token_requests(&self) -> Vec<(String, HashMap)> { - self.token_requests - .lock() - .expect("token request lock") - .clone() - } - - fn userinfo_requests(&self) -> Vec<(String, String)> { - self.userinfo_requests - .lock() - .expect("userinfo request lock") - .clone() - } - } - - #[async_trait::async_trait] - impl OAuth2HttpTransport for RecordingTransport { - async fn exchange_code( - &self, - token_endpoint: &str, - params: &HashMap, - ) -> OAuth2Result { - self.token_requests - .lock() - .expect("token request lock") - .push((token_endpoint.to_string(), params.clone())); - Ok(TokenResponse { - access_token: "access-token".to_string(), - token_type: "Bearer".to_string(), - expires_in: Some(3600), - refresh_token: None, - scope: None, - id_token: None, - }) - } - - async fn get_user_info( - &self, - userinfo_endpoint: &str, - access_token: &str, - ) -> OAuth2Result { - self.userinfo_requests - .lock() - .expect("userinfo request lock") - .push((userinfo_endpoint.to_string(), access_token.to_string())); - Ok(UserInfoResponse { - sub: "user-1".to_string(), - email: Some("user@example.com".to_string()), - email_verified: Some(true), - name: Some("Test User".to_string()), - given_name: None, - family_name: None, - picture: None, - locale: None, - additional_claims: HashMap::new(), - }) - } - } - - fn provider_config() -> OAuth2ProviderConfig { - OAuth2ProviderConfig { - provider_id: "test_provider".to_string(), - client_id: "test_client_id".to_string(), - client_secret: "test_secret".to_string(), - authorization_endpoint: "https://example.com/auth".to_string(), - token_endpoint: "https://example.com/token".to_string(), - userinfo_endpoint: Some("https://example.com/userinfo".to_string()), - issuer: None, - redirect_uri: "http://localhost:3000/callback".to_string(), - scopes: vec!["openid".to_string(), "email".to_string()], - auth_params: HashMap::new(), - use_pkce: true, - user_info_mapping: None, - metadata_claims: Vec::new(), - allow_insecure_endpoints: false, - } - } - - fn client_with_transport( - state_store: Arc, - transport: Arc, - ) -> OAuth2Client { - OAuth2Client::with_http_transport(state_store, 600, transport) - } - - #[test] - fn test_pkce_generation() { - let pkce1 = PkceChallenge::new(); - let pkce2 = PkceChallenge::new(); - - // Verifiers should be different - assert_ne!(pkce1.code_verifier, pkce2.code_verifier); - - // Challenges should be different - assert_ne!(pkce1.code_challenge, pkce2.code_challenge); - - // Method should be S256 - assert_eq!(pkce1.code_challenge_method, "S256"); - - // Verify the challenge is correctly generated - let expected_challenge = PkceChallenge::generate_code_challenge(&pkce1.code_verifier); - assert_eq!(pkce1.code_challenge, expected_challenge); - } - - #[tokio::test] - async fn test_authorization_url_generation() { - let state_store = Arc::new(InMemoryStateStore::new()); - let client = OAuth2Client::new(state_store, 600, 30); - - let provider_config = provider_config(); - - let (auth_url, state) = client - .generate_authorization_url(&provider_config, HashMap::new()) - .await - .unwrap(); - - // Verify URL structure - let url = Url::parse(&auth_url).unwrap(); - assert_eq!(url.host_str(), Some("example.com")); - assert_eq!(url.path(), "/auth"); - - // Verify query parameters - let params: HashMap<_, _> = url.query_pairs().collect(); - assert_eq!(params.get("response_type"), Some(&"code".into())); - assert_eq!(params.get("client_id"), Some(&"test_client_id".into())); - assert_eq!( - params.get("redirect_uri"), - Some(&"http://localhost:3000/callback".into()) - ); - assert_eq!(params.get("state"), Some(&state.into())); - assert_eq!(params.get("scope"), Some(&"openid email".into())); - assert!(params.contains_key("code_challenge")); - assert_eq!(params.get("code_challenge_method"), Some(&"S256".into())); - } - - #[tokio::test] - async fn authorization_url_merges_provider_and_request_params_without_pkce() { - let state_store = Arc::new(InMemoryStateStore::new()); - let client = OAuth2Client::new(state_store.clone(), 600, 30); - let mut provider_config = provider_config(); - provider_config.use_pkce = false; - provider_config - .auth_params - .insert("prompt".to_string(), "consent".to_string()); - - let mut additional_params = HashMap::new(); - additional_params.insert("login_hint".to_string(), "user@example.com".to_string()); - - let (auth_url, state_param) = client - .generate_authorization_url(&provider_config, additional_params) - .await - .unwrap(); - - let url = Url::parse(&auth_url).unwrap(); - let params: HashMap<_, _> = url.query_pairs().collect(); - assert_eq!(params.get("prompt"), Some(&"consent".into())); - assert_eq!(params.get("login_hint"), Some(&"user@example.com".into())); - assert!(!params.contains_key("code_challenge")); - assert!(!params.contains_key("code_challenge_method")); - - let stored_state = state_store.retrieve(&state_param).await.unwrap(); - assert_eq!(stored_state.provider_id, "test_provider"); - assert!(stored_state.code_verifier.is_none()); - } - - #[tokio::test] - async fn handle_callback_rejects_state_for_wrong_provider_without_transport_call() { - let state_store = Arc::new(InMemoryStateStore::new()); - let transport = Arc::new(RecordingTransport::new()); - let client = client_with_transport(state_store, transport.clone()); - let provider_config = provider_config(); - - let (_, state) = client - .generate_authorization_url(&provider_config, HashMap::new()) - .await - .unwrap(); - - let mut wrong_provider = provider_config.clone(); - wrong_provider.provider_id = "other_provider".to_string(); - - let error = client - .handle_callback( - &wrong_provider, - AuthorizationResponse { - code: Some("auth-code".to_string()), - state, - error: None, - error_description: None, - binding: None, - }, - ) - .await - .expect_err("provider mismatch should reject callback"); - - assert!(matches!(error, OAuth2Error::InvalidState)); - assert!(transport.token_requests().is_empty()); - } - - #[tokio::test] - async fn i5_handle_callback_maps_provider_error_to_fixed_variant_without_description() { - let state_store = Arc::new(InMemoryStateStore::new()); - let transport = Arc::new(RecordingTransport::new()); - let client = client_with_transport(state_store, transport.clone()); - let provider_config = provider_config(); - - let (_, state) = client - .generate_authorization_url(&provider_config, HashMap::new()) - .await - .unwrap(); - - // A legitimate denial carries no code at all. - let error = client - .handle_callback( - &provider_config, - AuthorizationResponse { - code: None, - state, - error: Some("access_denied".to_string()), - error_description: Some("user denied consent - diff --git a/crates/specs/ras-api-explorer-assets/src/lib.rs b/crates/specs/ras-api-explorer-assets/src/lib.rs index aba0496..e4cbccf 100644 --- a/crates/specs/ras-api-explorer-assets/src/lib.rs +++ b/crates/specs/ras-api-explorer-assets/src/lib.rs @@ -1,12 +1,25 @@ //! Embedded API explorer shared by REST and JSON-RPC service macros. -/// Self-contained explorer HTML. Replace `{EXPLORER_CONFIG_JSON}` with JSON -/// whose `<` characters are escaped to keep it inside the configuration script. -/// Source order preserves one script scope; event binding runs after all helpers. +/// Placeholder in [`TEMPLATE`] that consumers replace with the explorer +/// configuration JSON. The JSON must have its `<` characters escaped +/// (for example as `\u003c`) so it cannot terminate the configuration script. +pub const CONFIG_PLACEHOLDER: &str = "{EXPLORER_CONFIG_JSON}"; + +/// Self-contained explorer HTML with a single [`CONFIG_PLACEHOLDER`]. +/// +/// Each asset file is a standalone document fragment: `head.html`, `body.html` +/// and `tail.html` are markup, `explorer.css` is a stylesheet, and the `*.js` +/// files are scripts. The wrapper tags live here so the fragments stay valid on +/// their own and editors can normalize their whitespace freely. Script order +/// matters: the files share one scope, and `events.js` binds handlers to +/// functions defined by the earlier files. pub const TEMPLATE: &str = concat!( include_str!("assets/head.html"), + " \n", include_str!("assets/body.html"), + " \n", include_str!("assets/tail.html"), ); + +#[cfg(test)] +mod tests { + use super::*; + + const SCRIPTS: [&str; 11] = [ + include_str!("assets/bootstrap.js"), + include_str!("assets/storage.js"), + include_str!("assets/schema-model.js"), + include_str!("assets/markdown.js"), + include_str!("assets/schema-render.js"), + include_str!("assets/specs.js"), + include_str!("assets/state.js"), + include_str!("assets/navigation.js"), + include_str!("assets/forms.js"), + include_str!("assets/requests.js"), + include_str!("assets/events.js"), + ]; + + #[test] + fn placeholder_appears_exactly_once_inside_the_config_script() { + assert_eq!(TEMPLATE.matches(CONFIG_PLACEHOLDER).count(), 1); + let expected = format!( + "" + ); + assert!(TEMPLATE.contains(&expected)); + } + + #[test] + fn template_is_a_complete_html_document() { + assert!(TEMPLATE.starts_with("\n")); + assert!(TEMPLATE.ends_with("\n")); + for (open, close) in [ + (""), + ("", ""), + ("", ""), + ] { + assert_eq!(TEMPLATE.matches(open).count(), 1, "{open}"); + assert_eq!(TEMPLATE.matches(close).count(), 1, "{close}"); + } + assert_eq!(TEMPLATE.matches("").count(), 1); + // The configuration script plus the single explorer script. + assert_eq!(TEMPLATE.matches("").count(), 2); + } + + #[test] + fn stylesheet_and_scripts_cannot_break_out_of_their_wrapper_tags() { + let css = include_str!("assets/explorer.css"); + assert!( + !css.contains(" Date: Sat, 5 Sep 2026 13:13:43 +0200 Subject: [PATCH 33/35] ci: run the WASM UI browser test in the WASM UI example job The spec was ignored by the default Playwright config and its CI step had been dropped, leaving the CSS-token regression test manual-only. Run it after the bundle build and upload results on failure. Open and close the task detail panel explicitly in the spec instead of relying on the checkbox click bubbling to the card. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hgg3oKtpBsZ6DC6T1KEnsT --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ tests/playwright/README.md | 2 +- tests/playwright/tests/wasm-ui.spec.ts | 12 ++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa052f6..9a7a67a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -333,6 +333,27 @@ jobs: npm ci npm run build + - name: Install Playwright package + working-directory: tests/playwright + run: npm ci + + - name: Install Playwright browsers + working-directory: tests/playwright + run: npx playwright install --with-deps chromium + + - name: Run WASM UI browser tests + working-directory: tests/playwright + run: npm test -- --config wasm-ui.config.ts + + - name: Upload WASM UI test results + if: failure() + uses: actions/upload-artifact@v7.0.1 + with: + name: wasm-ui-test-results + path: tests/playwright/test-results + if-no-files-found: ignore + retention-days: 7 + coverage: name: Coverage report runs-on: ubuntu-latest diff --git a/tests/playwright/README.md b/tests/playwright/README.md index 8dd13fc..73e4a06 100644 --- a/tests/playwright/README.md +++ b/tests/playwright/README.md @@ -48,4 +48,4 @@ npm --prefix tests/playwright test -- --config wasm-ui.config.ts Run these commands from the repository root after installing the Playwright browser. The suite serves the compiled bundle and supplies deterministic JSON-RPC responses. It checks login failure/success, task list/create/complete/delete, failed-create state, -and browser panics. Run it after building the WASM UI example. +and browser panics. CI runs it in the WASM UI example job after building the bundle. diff --git a/tests/playwright/tests/wasm-ui.spec.ts b/tests/playwright/tests/wasm-ui.spec.ts index 094401a..49580db 100644 --- a/tests/playwright/tests/wasm-ui.spec.ts +++ b/tests/playwright/tests/wasm-ui.spec.ts @@ -63,6 +63,18 @@ test('login, task actions, and failures preserve reactive UI state', async ({ pa await page.getByRole('button', { name: 'Create Task', exact: true }).click(); await expect(page.getByText('Review module boundaries', { exact: true })).toBeVisible(); await expect(page.getByPlaceholder('What needs to be done?')).toHaveValue(''); + + // Selecting a task renders the detail panel, which once panicked on a + // space-separated class token. Open it explicitly rather than relying on + // the checkbox click bubbling up to the card. + await page.getByText('Review module boundaries', { exact: true }).click(); + await expect(page.getByText('Task Details', { exact: true })).toBeVisible(); + // The description is shown in both the card and the detail panel. + await expect(page.getByText('Verify browser interactions', { exact: true })).toHaveCount(2); + await page.getByRole('button', { name: '×', exact: true }).click(); + await expect(page.getByText('Task Details', { exact: true })).toHaveCount(0); + await expect(page.getByText('Verify browser interactions', { exact: true })).toHaveCount(1); + await page.getByRole('checkbox').check(); await expect.poll(() => tasks[0]?.completed).toBe(true); await expect(page.getByRole('checkbox')).toBeChecked(); From 9baa9dc21760f87457cda26a9970294a36b446ee Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 13:13:43 +0200 Subject: [PATCH 34/35] test(chat): drive the router WebSocket test through the generated client Use ChatServiceClientBuilder from the chat API crate instead of the axum-test ws feature, which had pulled a third tungstenite version into the lockfile. The test now exercises the production client handshake, header auth, and typed dispatch against the real router. Update the refactor record for the explorer and CI follow-ups. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hgg3oKtpBsZ6DC6T1KEnsT --- Cargo.lock | 33 --------- documentation/reviews/refactor-progress.md | 18 +++-- examples/bidirectional-chat/server/Cargo.toml | 3 +- .../server/tests/server_tests.rs | 68 ++++++++++--------- 4 files changed, 48 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 884c126..e342876 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,12 +186,10 @@ checksum = "0ce2a8627e8d8851f894696b39f2b67807d6375c177361d376173ace306a21e2" dependencies = [ "anyhow", "axum", - "base64 0.22.1", "bytes", "bytesize", "cookie", "expect-json", - "futures-util", "http", "http-body-util", "hyper", @@ -205,10 +203,8 @@ dependencies = [ "serde_urlencoded", "smallvec", "tokio", - "tokio-tungstenite 0.28.0", "tower", "url", - "uuid", ] [[package]] @@ -3937,18 +3933,6 @@ dependencies = [ "tungstenite 0.26.2", ] -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.28.0", -] - [[package]] name = "tokio-tungstenite" version = "0.29.0" @@ -4156,23 +4140,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.4", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.29.0" diff --git a/documentation/reviews/refactor-progress.md b/documentation/reviews/refactor-progress.md index 9254358..40dd70e 100644 --- a/documentation/reviews/refactor-progress.md +++ b/documentation/reviews/refactor-progress.md @@ -57,10 +57,11 @@ Final verification investigation: - All 927 tests pass without retries at four-worker concurrency, with one existing ignored test. Workspace doctests also pass. No skips or production crypto changes were introduced to address the local failures. -- GitHub rejected optional workflow edits because the configured OAuth token - lacks `workflow` scope. Those edits were removed. MR #28 targets `master` to - run existing CI and depends on comment-cleanup MR #27; the comment-only diff - disappears once #27 merges. The browser regression command remains available locally. +- GitHub rejected the workflow edits during the refactor because the configured OAuth + token lacked `workflow` scope, so the WASM UI browser test was initially local-only. + The post-review follow-up re-added it to the `wasm-ui-example` CI job, and the spec + now opens and closes the task detail panel explicitly instead of relying on click + bubbling from the checkbox. - The finish skill's `/simplify` slash command is unavailable in this runtime; no matching installed skill or callable slash-command tool was found. @@ -75,11 +76,14 @@ Final project gates: package README/Markdown links, and cargo-deny policy checks pass. - All 13 CI feature combinations and generated-client specification checks pass. - Final browser checks pass: 11 explorer tests and one WASM task-flow test. -- Explorer HTML remains byte-identical to the original 60,172-byte response. +- Explorer HTML was byte-identical to the original 60,172-byte response at checkpoint 8. + The post-review follow-up moved the style and script wrapper tags out of the fragments + into the assembler so each asset is a standalone file; the served page now differs + from the original only by one blank line before the closing script tag, and unit + tests in the assets crate guard the placeholder and document structure. The asset and both macro archives package successfully; both unpacked macros compile against the packaged asset using local dependency patches. -- Diff review retains original whitespace in embedded HTML/CSS fragments and the UI's - raw stylesheet string; trimming fragment boundaries would change the assembled bytes. +- Diff review retains original whitespace in the UI's raw stylesheet string. - The application constructor documents its validated-configuration precondition. No unresolved implementation TODOs or temporary debugging artifacts were added. diff --git a/examples/bidirectional-chat/server/Cargo.toml b/examples/bidirectional-chat/server/Cargo.toml index 1f26d69..3bf6abc 100644 --- a/examples/bidirectional-chat/server/Cargo.toml +++ b/examples/bidirectional-chat/server/Cargo.toml @@ -42,7 +42,8 @@ config = { workspace = true } [dev-dependencies] tempfile = { workspace = true } -axum-test = { workspace = true, features = ["ws"] } +axum-test = { workspace = true } +bidirectional-chat-api = { path = "../api", version = "0.1.0", features = ["client"] } [features] default = ["server"] diff --git a/examples/bidirectional-chat/server/tests/server_tests.rs b/examples/bidirectional-chat/server/tests/server_tests.rs index 1c33a36..181406b 100644 --- a/examples/bidirectional-chat/server/tests/server_tests.rs +++ b/examples/bidirectional-chat/server/tests/server_tests.rs @@ -382,8 +382,10 @@ async fn test_message_persistence() -> Result<()> { #[tokio::test] async fn application_router_authenticates_websocket_and_persists_messages() -> Result<()> { + use bidirectional_chat_api::{ + ChatServiceClientBuilder, JoinRoomRequest, ListRoomsRequest, SendMessageRequest, + }; use bidirectional_chat_server::persistence::PersistenceManager; - use ras_jsonrpc_bidirectional_types::BidirectionalMessage; use serde_json::json; use std::time::Duration; @@ -410,42 +412,42 @@ async fn application_router_authenticates_websocket_and_persists_messages() -> R .json(&json!({"username": "socket-user", "password": "socket-password"})) .await .json(); - let mut socket = server - .get_websocket("/ws") - .add_header( - "authorization", - format!("Bearer {}", login["token"].as_str().unwrap()), - ) - .await - .into_websocket() - .await; + let token = login["token"].as_str().unwrap().to_string(); + + // Drive the router through the generated client, the same one the TUI uses. + let http_url = server.server_address().expect("http transport address"); + let ws_url = format!( + "ws://{}:{}/ws", + http_url.host_str().unwrap(), + http_url.port().unwrap() + ); + let client = ChatServiceClientBuilder::new(ws_url) + .with_jwt_token(token) + .build() + .await?; + tokio::time::timeout(Duration::from_secs(5), async { - let first: BidirectionalMessage = socket.receive_json().await; - assert!(matches!(first, BidirectionalMessage::ConnectionEstablished { .. })); - for (id, method, params) in [ - ("join", "join_room", json!({"room_name": "general"})), - ("send", "send_message", json!({"text": "persisted through application router"})), - ("list", "list_rooms", json!({})), - ] { - socket.send_json(&json!({"type": "request", "jsonrpc": "2.0", "id": id, "method": method, "params": params})).await; - loop { - let message: BidirectionalMessage = socket.receive_json().await; - if let BidirectionalMessage::Response(response) = message - && response.id == Some(json!(id)) - { - assert!(response.error.is_none(), "{response:?}"); - if id == "list" { - assert!(response.result.unwrap()["rooms"].as_array().unwrap().iter().any(|room| room["room_id"] == "general")); - } - break; - } - } - } - socket.close().await; + client.connect().await?; + client + .join_room(JoinRoomRequest { + room_name: "general".to_string(), + }) + .await?; + client + .send_message(SendMessageRequest { + text: "persisted through application router".to_string(), + }) + .await?; + let rooms = client.list_rooms(ListRoomsRequest {}).await?; + assert!(rooms.rooms.iter().any(|room| room.room_id == "general")); + client.disconnect().await?; while manager.connection_count() != 0 { tokio::task::yield_now().await; } - }).await?; + anyhow::Ok(()) + }) + .await??; + let messages = PersistenceManager::new(&config.chat.data_dir) .load_room_messages("general", None) .await?; From 2cb5ff60cf331a5d5b472d0d297e1621b68a567a Mon Sep 17 00:00:00 2001 From: Mathias Myrland Date: Sat, 5 Sep 2026 13:15:18 +0200 Subject: [PATCH 35/35] deps: align tokio-tungstenite with the version Axum uses The workspace pinned 0.26 while Axum 0.8 pulled 0.29, so every build carried two copies of tungstenite and tokio-tungstenite. Move the pin to 0.29. No source changes were needed; bidirectional, chat, and WASM client builds and tests pass. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hgg3oKtpBsZ6DC6T1KEnsT --- CHANGELOG.md | 1 + Cargo.lock | 72 ++++++++++------------------------------------------ Cargo.toml | 2 +- 3 files changed, 16 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a284799..155f7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] ### Changed - 2026-09-05 (responsibility boundaries refactor) +- **`tokio-tungstenite` aligned with Axum** (`ras-jsonrpc-bidirectional-client`, `-server`, `-types`, `-macro`). The workspace pin moved from 0.26 to 0.29, the version Axum 0.8 already used, so the lockfile carries a single copy of `tungstenite` and `tokio-tungstenite`. No source changes were needed. - **New `ras-api-explorer-assets` crate** (`crates/specs`). The browser API explorer that `ras-rest-macro` and `ras-jsonrpc-macro` embed now lives in its own dependency-free crate as standalone HTML, CSS and JavaScript files assembled at compile time into `TEMPLATE`, with the configuration placeholder exported as `CONFIG_PLACEHOLDER`. Previously the JSON-RPC macro read the template from the REST macro's source tree with a relative `include_str!`, which broke outside the workspace. Publish this crate before either macro. The served explorer differs from the previous one only in whitespace around the style and script wrapper tags. - Large modules were split by responsibility across the macro, auth, identity, transport and bidirectional crates without changing public paths, generated code, or behavior; moved types are re-exported from their previous locations. Integration suites were regrouped into scenario modules under the same test targets, and the `xm_feedback_*` macro test targets were renamed to `http_service_contracts`. diff --git a/Cargo.lock b/Cargo.lock index e342876..86e38eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -127,7 +127,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite 0.29.0", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -1053,7 +1053,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1770,7 +1770,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2001,7 +2001,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2576,7 +2576,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2829,7 +2829,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite", "tracing", "tracing-subscriber", "url", @@ -2863,7 +2863,7 @@ dependencies = [ "syn", "thiserror 2.0.18", "tokio", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite", "url", "uuid", ] @@ -2885,7 +2885,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite", "tracing", ] @@ -2902,7 +2902,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite", "tracing", "uuid", ] @@ -3337,7 +3337,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3350,7 +3350,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3745,7 +3745,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3921,18 +3921,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.26.2", -] - [[package]] name = "tokio-tungstenite" version = "0.29.0" @@ -3942,7 +3930,7 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.29.0", + "tungstenite", ] [[package]] @@ -4123,23 +4111,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.4", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.29.0" @@ -4263,12 +4234,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4511,7 +4476,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4588,15 +4553,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 9480738..b261d8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ sha2 = "0.10" subtle = "2.6" tempfile = "3.13" thiserror = "2.0" -tokio-tungstenite = "0.26" +tokio-tungstenite = "0.29" tower-http = "0.6" tracing = "0.1" url = "2.5"