diff --git a/.gitignore b/.gitignore index 070cef3..6d9b6ad 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ secrets/ **/pkg/ tests/playwright/test-results/ +# mdBook build output +documentation/book/ + # Local config and test files examples/bidirectional-chat/server/config.toml test_login.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a752f..603cbeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added - 2026-06-29 +- New `OPTIONAL_AUTH` route level for `rest_service!`, `file_service!`, `jsonrpc_service!`, and `jsonrpc_bidirectional_service!`. An `OPTIONAL_AUTH` route is public — never rejected for auth reasons — but opportunistically identifies its caller: the handler receives a `ras_auth_core::Caller` (`Anonymous` / `Authenticated(user)`) as its first argument (the file service surfaces it through `FileRequestContext`). Resolution is fully lenient: a missing, invalid, or expired credential, or a cookie that fails CSRF on an unsafe method, resolves to `Caller::Anonymous` rather than a 401/403. +- `ras-auth-core`: new `Caller` enum (`#[must_use]`) and non-rejecting `resolve_caller` resolver alongside `authorize_request`. +- `ras-permission-manifest`: new `AuthRequirementInfo::Optional` variant so manifests distinguish `OPTIONAL_AUTH` from public/authenticated operations; `SCHEMA_VERSION` bumped to `2` (older pinned consumers will fail to deserialize a manifest containing `"type":"optional"`). +- OpenAPI emits an optional security requirement (`[{}, {"bearerAuth": []}]`) and OpenRPC emits `x-authentication: { required: false }` for `OPTIONAL_AUTH` operations. +- Existing `UNAUTHORIZED` and `WITH_PERMISSIONS` behavior is unchanged. + ### Changed - 2026-06-06 - REST, JSON-RPC, and file generated-client APIs are now consistent: builders take the URL at construction, auth state is cloned, `build_with_transport(...)` is always available for generated clients, public timeout variants take `Duration`, and default reqwest-backed `build()` is emitted only when the macro crate's `reqwest` feature is enabled. - Macro client features now distinguish transport-injected clients from default reqwest clients: `client` emits generated clients using `ras-transport-core`, while `reqwest` enables the default `ReqwestTransport` constructor. diff --git a/Cargo.lock b/Cargo.lock index f97130b..63726f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,9 +52,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "argon2" diff --git a/README.md b/README.md index 3417029..c7c6547 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ jsonrpc_service!({ openrpc: true, // Generate OpenRPC docs methods: [ UNAUTHORIZED sign_in(SignInRequest) -> SignInResponse, + OPTIONAL_AUTH feed(FeedRequest) -> FeedResponse, // public, but identifies the caller WITH_PERMISSIONS(["user"]) create_task(CreateTaskRequest) -> Task, WITH_PERMISSIONS(["admin"]) delete_all_tasks(()) -> (), ] @@ -133,6 +134,7 @@ rest_service!({ serve_docs: true, // Serve the built-in API explorer at /api/v1/docs endpoints: [ GET UNAUTHORIZED users() -> UsersResponse, + GET OPTIONAL_AUTH feed() -> UsersResponse, // public, caller as `Caller` arg POST WITH_PERMISSIONS(["admin"]) users(CreateUserRequest) -> UserResponse, GET WITH_PERMISSIONS(["user"]) users/{id: String}() -> User, DELETE WITH_PERMISSIONS(["admin"]) users/{id: String}() -> (), diff --git a/crates/core/ras-auth-core/README.md b/crates/core/ras-auth-core/README.md index 41de2ee..0620088 100644 --- a/crates/core/ras-auth-core/README.md +++ b/crates/core/ras-auth-core/README.md @@ -63,6 +63,41 @@ pub enum AuthError { } ``` +### Caller and `resolve_caller` (OPTIONAL_AUTH) + +Service macros support three auth levels: `UNAUTHORIZED`, `OPTIONAL_AUTH`, and +`WITH_PERMISSIONS([...])`. An `OPTIONAL_AUTH` route is **public but opportunistically +identified** — it is never rejected for authentication reasons, and the handler +receives a `Caller`: + +```rust +pub enum Caller { + Anonymous, + Authenticated(AuthenticatedUser), +} +``` + +`resolve_caller` is the non-rejecting counterpart to `authorize_request`. It reuses +the same credential extraction, CSRF validation, and `AuthProvider::authenticate` +steps, but maps every failure to `Caller::Anonymous` instead of an error: + +```rust +pub async fn resolve_caller( + method: &str, + headers: &HeaderMap, + auth_transport: &AuthTransportConfig, + auth_provider: Option<&P>, +) -> Caller; +``` + +It is **fully lenient**: a missing credential, an invalid/expired token, a missing +auth provider, or a cookie credential that fails CSRF on an unsafe method all +resolve to `Caller::Anonymous`. A forged or stale credential therefore gains +nothing — it simply executes as the public path. No permission check is performed +(an `OPTIONAL_AUTH` route has no required groups). The generated REST/JSON-RPC/ +bidirectional handlers take `Caller` as their first argument; the file service +surfaces the same optional caller through `FileRequestContext`. + ### HTTP Credential Transport `AuthProvider` still validates a token string. HTTP services can choose how the diff --git a/crates/core/ras-auth-core/src/authorize.rs b/crates/core/ras-auth-core/src/authorize.rs index e245f52..e380cc6 100644 --- a/crates/core/ras-auth-core/src/authorize.rs +++ b/crates/core/ras-auth-core/src/authorize.rs @@ -7,8 +7,8 @@ //! response shape. use crate::{ - AuthError, AuthProvider, AuthTransportConfig, AuthenticatedUser, extract_auth_credential, - validate_csrf_for_credential, + AuthError, AuthProvider, AuthTransportConfig, AuthenticatedUser, Caller, + extract_auth_credential, validate_csrf_for_credential, }; use http::HeaderMap; @@ -111,6 +111,47 @@ where Ok(user) } +/// Best-effort authentication for `OPTIONAL_AUTH` routes — the non-rejecting +/// counterpart to [`authorize_request`]. +/// +/// An `OPTIONAL_AUTH` route is public, so this **never** rejects: it resolves to +/// [`Caller::Anonymous`] for a missing credential, an unauthenticatable +/// credential (invalid/expired token), a cookie credential that fails CSRF on an +/// unsafe method, or a missing auth provider; and to [`Caller::Authenticated`] +/// only when a presented credential authenticates. It performs **no** permission +/// check (an `OPTIONAL_AUTH` route has no required groups). +/// +/// CSRF mirrors [`authorize_request`]: bearer credentials are exempt, GET/HEAD +/// are exempt, and a cookie credential on an unsafe method must pass CSRF — but +/// here a CSRF failure downgrades to anonymous rather than producing a 403, so a +/// forged/stale ambient credential simply executes as the public path. +pub async fn resolve_caller

( + method: &str, + headers: &HeaderMap, + auth_transport: &AuthTransportConfig, + auth_provider: Option<&P>, +) -> Caller +where + P: AuthProvider + ?Sized, +{ + let Ok(credential) = extract_auth_credential(headers, auth_transport) else { + return Caller::Anonymous; + }; + + if validate_csrf_for_credential(method, headers, &credential, auth_transport).is_err() { + return Caller::Anonymous; + } + + let Some(provider) = auth_provider else { + return Caller::Anonymous; + }; + + match provider.authenticate(credential.token().to_string()).await { + Ok(user) => Caller::Authenticated(user), + Err(_) => Caller::Anonymous, + } +} + #[cfg(test)] mod tests { use super::*; @@ -250,4 +291,33 @@ mod tests { .unwrap(); assert_eq!(user.user_id, "u"); } + + #[tokio::test] + async fn resolve_caller_is_lenient() { + let transport = AuthTransportConfig::default(); + + // No credential -> anonymous. + let caller = + resolve_caller("GET", &HeaderMap::new(), &transport, Some(&StaticProvider)).await; + assert!(matches!(caller, Caller::Anonymous)); + + // Present but unauthenticatable credential -> anonymous (lenient). + let mut headers = HeaderMap::new(); + headers.insert("authorization", "Bearer bad".parse().unwrap()); + let caller = resolve_caller("GET", &headers, &transport, Some(&StaticProvider)).await; + assert!(matches!(caller, Caller::Anonymous)); + + // No auth provider configured -> anonymous, never panics. + let caller = resolve_caller("GET", &headers, &transport, None::<&StaticProvider>).await; + assert!(matches!(caller, Caller::Anonymous)); + + // Valid credential -> authenticated. + let mut headers = HeaderMap::new(); + headers.insert("authorization", "Bearer good".parse().unwrap()); + let caller = resolve_caller("POST", &headers, &transport, Some(&StaticProvider)).await; + let Caller::Authenticated(user) = caller else { + panic!("expected authenticated caller"); + }; + assert_eq!(user.user_id, "u"); + } } diff --git a/crates/core/ras-auth-core/src/lib.rs b/crates/core/ras-auth-core/src/lib.rs index 516bfa4..18f24bb 100644 --- a/crates/core/ras-auth-core/src/lib.rs +++ b/crates/core/ras-auth-core/src/lib.rs @@ -53,6 +53,67 @@ pub struct AuthenticatedUser { pub metadata: Option, } +/// The caller of an `OPTIONAL_AUTH` route. +/// +/// An `OPTIONAL_AUTH` route is public — it is never rejected for authentication +/// reasons — but it opportunistically identifies its caller. The handler receives +/// this value as its first argument and decides how much to reveal: +/// +/// * [`Caller::Anonymous`] — no credential, or a credential that failed to +/// authenticate (lenient: invalid/expired tokens and cookies that fail CSRF on +/// an unsafe method all resolve to anonymous). +/// * [`Caller::Authenticated`] — a valid credential was presented. +/// +/// Deliberately **not** `Serialize`/`Deserialize`: a `Caller` represents a +/// *resolved* identity and must only be produced by [`resolve_caller`], never +/// reconstructed from request input. The `#[must_use]` attribute flags a +/// discarded [`resolve_caller`] result; note it cannot catch a handler that +/// receives `caller` as a parameter and never reads it (Rust applies `must_use` +/// to discarded expression results, not to unused bindings). +#[must_use] +#[derive(Debug, Clone)] +pub enum Caller { + /// No authenticated caller — treat the request as public/anonymous. + Anonymous, + /// A caller whose credential authenticated successfully. + Authenticated(AuthenticatedUser), +} + +impl From> for Caller { + /// Maps a best-effort authentication result to a caller: `Some(user)` ⇒ + /// [`Caller::Authenticated`], `None` ⇒ [`Caller::Anonymous`]. Used by the + /// generated services that already hold an `Option`. + fn from(user: Option) -> Self { + match user { + Some(user) => Caller::Authenticated(user), + None => Caller::Anonymous, + } + } +} + +impl Caller { + /// Borrows the authenticated user, or `None` when anonymous. + pub fn authenticated(&self) -> Option<&AuthenticatedUser> { + match self { + Caller::Authenticated(user) => Some(user), + Caller::Anonymous => None, + } + } + + /// Returns `true` when a caller authenticated. + pub fn is_authenticated(&self) -> bool { + matches!(self, Caller::Authenticated(_)) + } + + /// Consumes the caller, yielding the authenticated user when present. + pub fn into_authenticated(self) -> Option { + match self { + Caller::Authenticated(user) => Some(user), + Caller::Anonymous => None, + } + } +} + /// Result type for authentication operations. pub type AuthResult = Result; diff --git a/crates/rest/ras-file-macro/README.md b/crates/rest/ras-file-macro/README.md index 728d681..32f08eb 100644 --- a/crates/rest/ras-file-macro/README.md +++ b/crates/rest/ras-file-macro/README.md @@ -43,6 +43,12 @@ file_service!({ content_types: ["application/octet-stream"], ranges: true, }, + // Public download that still recognises a signed-in caller via + // `FileRequestContext` (ctx.user is Some(..) when authenticated). + DOWNLOAD OPTIONAL_AUTH preview/{file_id: String} { + content_types: ["application/octet-stream"], + ranges: false, + }, ] }); ``` diff --git a/crates/rest/ras-file-macro/src/lib.rs b/crates/rest/ras-file-macro/src/lib.rs index ef5d9a4..f42dc97 100644 --- a/crates/rest/ras-file-macro/src/lib.rs +++ b/crates/rest/ras-file-macro/src/lib.rs @@ -10,6 +10,13 @@ mod server; use parser::FileServiceDefinition; +/// Generates a multipart file upload/download service (axum server + typed client). +/// +/// Each endpoint declares one of three auth levels — `UNAUTHORIZED`, +/// `OPTIONAL_AUTH`, or `WITH_PERMISSIONS([...])`. For `OPTIONAL_AUTH` the route +/// is public (never rejected for auth reasons) and the best-effort caller is +/// surfaced through `FileRequestContext` (`ctx.user` is `Some(user)` when a valid +/// credential is present, `None` otherwise) rather than a `Caller` parameter. #[proc_macro] pub fn file_service(input: TokenStream) -> TokenStream { let definition = parse_macro_input!(input as FileServiceDefinition); diff --git a/crates/rest/ras-file-macro/src/openapi.rs b/crates/rest/ras-file-macro/src/openapi.rs index 0677c03..ff5a143 100644 --- a/crates/rest/ras-file-macro/src/openapi.rs +++ b/crates/rest/ras-file-macro/src/openapi.rs @@ -45,6 +45,8 @@ pub fn generate_openapi_code( }; let path = endpoint.path.value(); 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 = permissions_for_openapi(&endpoint.auth); let permission_groups = permission_groups_for_openapi(&endpoint.auth); let permission_groups_tokens = permission_groups_tokens(&permission_groups); @@ -77,6 +79,7 @@ pub fn generate_openapi_code( operation: #operation.to_string(), path: #path.to_string(), auth_required: #auth_required, + auth_optional: #auth_optional, permissions: vec![#(#permissions.to_string()),*], permission_groups: #permission_groups_tokens, path_params: vec![#(#path_params),*], @@ -98,6 +101,7 @@ pub fn generate_openapi_code( operation: #operation.to_string(), path: #path.to_string(), auth_required: #auth_required, + auth_optional: #auth_optional, permissions: vec![#(#permissions.to_string()),*], permission_groups: #permission_groups_tokens, path_params: vec![#(#path_params),*], @@ -131,6 +135,7 @@ pub fn generate_openapi_code( operation: String, path: String, auth_required: bool, + auth_optional: bool, permissions: Vec, permission_groups: Vec>, path_params: Vec<(String, String)>, @@ -323,6 +328,9 @@ pub fn generate_openapi_code( 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; @@ -495,14 +503,14 @@ fn part_info_tokens(part: &UploadPart, part_info_name: &syn::Ident) -> TokenStre fn permissions_for_openapi(auth: &AuthRequirement) -> Vec { match auth { - AuthRequirement::Unauthorized => vec![], + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], AuthRequirement::WithPermissions(groups) => groups.iter().flatten().cloned().collect(), } } fn permission_groups_for_openapi(auth: &AuthRequirement) -> Vec> { match auth { - AuthRequirement::Unauthorized => vec![], + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], AuthRequirement::WithPermissions(groups) => groups.clone(), } } diff --git a/crates/rest/ras-file-macro/src/parser.rs b/crates/rest/ras-file-macro/src/parser.rs index 5b06482..21d61a1 100644 --- a/crates/rest/ras-file-macro/src/parser.rs +++ b/crates/rest/ras-file-macro/src/parser.rs @@ -42,6 +42,9 @@ pub enum Operation { #[derive(Debug)] pub enum AuthRequirement { Unauthorized, + /// Public route that opportunistically identifies its caller. Never rejected + /// for auth reasons; the caller is surfaced through `FileRequestContext`. + OptionalAuth, WithPermissions(Vec>), } @@ -460,6 +463,7 @@ fn parse_auth(input: ParseStream) -> Result { let auth_ident: Ident = input.parse()?; match auth_ident.to_string().as_str() { "UNAUTHORIZED" => Ok(AuthRequirement::Unauthorized), + "OPTIONAL_AUTH" => Ok(AuthRequirement::OptionalAuth), "WITH_PERMISSIONS" => { let content; syn::parenthesized!(content in input); @@ -487,7 +491,7 @@ fn parse_auth(input: ParseStream) -> Result { } _ => Err(Error::new( auth_ident.span(), - "Expected UNAUTHORIZED or WITH_PERMISSIONS", + "Expected UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS", )), } } diff --git a/crates/rest/ras-file-macro/src/permissions.rs b/crates/rest/ras-file-macro/src/permissions.rs index 61b3674..876ba0a 100644 --- a/crates/rest/ras-file-macro/src/permissions.rs +++ b/crates/rest/ras-file-macro/src/permissions.rs @@ -128,7 +128,11 @@ fn operation_entries(definition: &FileServiceDefinition) -> Vec TokenStream { AuthRequirement::Unauthorized => { quote! { ras_permission_manifest::AuthRequirementInfo::Public } } + AuthRequirement::OptionalAuth => { + quote! { ras_permission_manifest::AuthRequirementInfo::Optional } + } AuthRequirement::WithPermissions(groups) => { if groups.is_empty() || groups.iter().any(Vec::is_empty) { quote! { ras_permission_manifest::AuthRequirementInfo::Authenticated } @@ -182,7 +189,9 @@ fn auth_tokens(auth: &AuthRequirement) -> TokenStream { fn static_requirement_tokens(auth: &AuthRequirement) -> TokenStream { match auth { - AuthRequirement::Unauthorized => { + // Public routes (Unauthorized / OptionalAuth) are not protected, so this + // placeholder is never emitted — it only keeps the match exhaustive. + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => { quote! { ras_permission_manifest::StaticPermissionRequirement::authenticated_only() } } AuthRequirement::WithPermissions(groups) => { diff --git a/crates/rest/ras-file-macro/src/server.rs b/crates/rest/ras-file-macro/src/server.rs index b16c977..689dae9 100644 --- a/crates/rest/ras-file-macro/src/server.rs +++ b/crates/rest/ras-file-macro/src/server.rs @@ -896,6 +896,16 @@ fn generate_auth_check(auth: &AuthRequirement) -> TokenStream { 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, @@ -921,7 +931,8 @@ fn generate_auth_check(auth: &AuthRequirement) -> TokenStream { fn generate_permission_check(auth: &AuthRequirement) -> TokenStream { match auth { - AuthRequirement::Unauthorized => quote! {}, + // 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(); diff --git a/crates/rest/ras-file-macro/tests/e2e.rs b/crates/rest/ras-file-macro/tests/e2e.rs index a7570d6..3dd4c78 100644 --- a/crates/rest/ras-file-macro/tests/e2e.rs +++ b/crates/rest/ras-file-macro/tests/e2e.rs @@ -59,6 +59,10 @@ file_service!({ content_types: ["application/octet-stream"], ranges: true, }, + DOWNLOAD OPTIONAL_AUTH peek/{file_id: String} { + content_types: ["application/octet-stream"], + ranges: false, + }, ] }); @@ -180,6 +184,21 @@ impl DemoTrait for DemoImpl { .content_type("application/octet-stream")? .attachment(format!("{}.bin", path.file_id)) } + + async fn peek_by_file_id( + &self, + ctx: &FileRequestContext<'_>, + path: DemoPeekByFileIdPath, + ) -> ras_file_core::FileResult { + // OPTIONAL_AUTH: the caller is surfaced through the context, never rejected. + let caller = match ctx.user { + Some(user) => user.user_id.clone(), + None => "anonymous".to_string(), + }; + DownloadResponse::bytes(Bytes::from(format!("{caller}:{}", path.file_id))) + .content_type("application/octet-stream")? + .attachment(format!("{}.txt", path.file_id)) + } } async fn read_all(file: &mut IncomingFile<'_>, out: &mut Vec) -> ras_file_core::FileResult<()> { @@ -371,6 +390,43 @@ async fn download_returns_not_found_for_missing_file() { response.assert_status(StatusCode::NOT_FOUND); } +#[tokio::test] +async fn optional_auth_download_without_token_is_anonymous() { + let server = demo_server(DemoImpl::new()); + + let response = server.get("/files/peek/x1").await; + + response.assert_status(StatusCode::OK); + assert_eq!(response.text(), "anonymous:x1"); +} + +#[tokio::test] +async fn optional_auth_download_with_valid_token_sees_user() { + let server = demo_server(DemoImpl::new()); + + let response = server + .get("/files/peek/x2") + .authorization_bearer("user-token") + .await; + + response.assert_status(StatusCode::OK); + assert_eq!(response.text(), "user-1:x2"); +} + +#[tokio::test] +async fn optional_auth_download_with_invalid_token_is_lenient() { + let server = demo_server(DemoImpl::new()); + + let response = server + .get("/files/peek/x3") + .authorization_bearer("not-a-real-token") + .await; + + // Lenient: a bad credential downgrades to anonymous rather than 401. + response.assert_status(StatusCode::OK); + assert_eq!(response.text(), "anonymous:x3"); +} + #[test] fn generated_client_multipart_builder_covers_declared_parts() { let metadata = UploadMetadata { @@ -743,4 +799,11 @@ fn generated_openapi_documents_v2_multipart_contract() { "#/components/schemas/BinaryFileResponse" ); assert_eq!(download["x-ras-file"]["ranges"], true); + + // OPTIONAL_AUTH route advertises an optional security requirement. + let peek = &doc["paths"]["/peek/{file_id}"]["get"]; + assert_eq!( + peek["security"], + serde_json::json!([{}, { "bearerAuth": [] }]) + ); } diff --git a/crates/rest/ras-rest-macro/README.md b/crates/rest/ras-rest-macro/README.md index 6f094f6..f311714 100644 --- a/crates/rest/ras-rest-macro/README.md +++ b/crates/rest/ras-rest-macro/README.md @@ -45,6 +45,7 @@ rest_service!({ openapi: true, endpoints: [ GET UNAUTHORIZED users() -> Vec, + GET OPTIONAL_AUTH feed() -> Vec, // public; handler receives a `Caller` POST WITH_PERMISSIONS(["admin"]) users(CreateUserRequest) -> User, GET WITH_PERMISSIONS(["user"]) users/{id: i32}() -> User, PUT WITH_PERMISSIONS(["admin"]) users/{id: i32}(CreateUserRequest) -> User, diff --git a/crates/rest/ras-rest-macro/src/lib.rs b/crates/rest/ras-rest-macro/src/lib.rs index e1a64af..8fd98fe 100644 --- a/crates/rest/ras-rest-macro/src/lib.rs +++ b/crates/rest/ras-rest-macro/src/lib.rs @@ -16,6 +16,20 @@ mod static_hosting; /// Supports path parameters and request bodies /// Generates OpenAPI 3.0 documents using schemars /// +/// # Auth levels +/// +/// Each endpoint declares one of three auth levels: +/// +/// * `UNAUTHORIZED` — public; the handler receives no caller. +/// * `OPTIONAL_AUTH` — public, but opportunistically identified: the route is +/// never rejected for auth reasons and the handler receives a +/// [`ras_auth_core::Caller`] (`Anonymous`, or `Authenticated(user)` when a +/// valid credential is present). A present-but-bad credential (invalid/expired +/// token, or a cookie that fails CSRF on an unsafe method) resolves to +/// `Anonymous` rather than rejecting. +/// * `WITH_PERMISSIONS([...])` — authenticated and gated; a missing or +/// insufficient credential is rejected before the handler runs. +/// /// # Example /// /// ```rust @@ -53,6 +67,7 @@ mod static_hosting; /// ui_theme: "default", /// endpoints: [ /// GET UNAUTHORIZED users() -> UsersResponse, +/// GET OPTIONAL_AUTH feed() -> UsersResponse, /// POST WITH_PERMISSIONS(["admin"]) users(CreateUserRequest) -> UserResponse, /// GET WITH_PERMISSIONS(["user"]) users/{id: String}() -> UserResponse, /// PUT WITH_PERMISSIONS(["admin"]) users/{id: String}(UpdateUserRequest) -> UserResponse, @@ -187,6 +202,9 @@ struct QueryParam { #[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 } @@ -451,11 +469,12 @@ impl Parse for EndpointDefinition { } }; - // Parse auth requirement (UNAUTHORIZED or WITH_PERMISSIONS([...])) + // Parse auth requirement (UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS([...])) 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" => { // Parse ([...] | [...] | ...) let perms_content; @@ -502,7 +521,7 @@ impl Parse for EndpointDefinition { _ => { return Err(syn::Error::new( auth_ident.span(), - "Expected UNAUTHORIZED or WITH_PERMISSIONS", + "Expected UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS", )); } } @@ -717,6 +736,9 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result {} + AuthRequirement::OptionalAuth => { + params.push(quote! { caller: ras_auth_core::Caller }); + } AuthRequirement::WithPermissions(_) => { params.push(quote! { user: &ras_auth_core::AuthenticatedUser }); } @@ -1003,7 +1025,7 @@ fn generate_service_code(service_def: ServiceDefinition) -> syn::Result proc_macro2::TokenStream { let permission_groups = match auth { - AuthRequirement::Unauthorized => Vec::new(), + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => Vec::new(), AuthRequirement::WithPermissions(groups) => groups.clone(), }; @@ -1426,6 +1448,97 @@ fn generate_legacy_handler_body( 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) => { + 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(); + }, + }; + ( + status_code, + axum::Json(body) + ).into_response() + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + 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 }); @@ -1687,6 +1800,97 @@ fn generate_handler_body( result } } + AuthRequirement::OptionalAuth => { + // Build argument list; the caller is passed by value as the first arg. + let mut args = vec![quote! { caller }]; + + // Add path parameters + 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 }); + } + } + + // Add query parameters + for query_param in &endpoint.query_params { + let param_name = &query_param.name; + args.push(quote! { query_params.#param_name }); + } + + // Handle JSON body extraction with error handling + let json_handling = if endpoint.request_type.is_some() { + args.push(quote! { body }); + generate_body_extraction() + } 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 + + // Call usage tracker if configured + 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; + } + + // Track duration + let start_time = std::time::Instant::now(); + + let result = match service.#handler_name(#(#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); + ( + status_code, + axum::Json(rest_response.body) + ).into_response() + }, + Err(rest_error) => { + use axum::response::IntoResponse; + + if let Some(internal) = &rest_error.internal_error { + 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() + }, + }; + + // Call duration tracker if configured + 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(_) => { // Build argument list for authenticated endpoint let mut args = vec![quote! { &user }]; diff --git a/crates/rest/ras-rest-macro/src/openapi.rs b/crates/rest/ras-rest-macro/src/openapi.rs index 02716a1..e92fae6 100644 --- a/crates/rest/ras-rest-macro/src/openapi.rs +++ b/crates/rest/ras-rest-macro/src/openapi.rs @@ -187,9 +187,11 @@ pub fn generate_openapi_code( 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); // Flatten permission groups for OpenAPI documentation let permissions = match &endpoint.auth { - AuthRequirement::Unauthorized => vec![], + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], AuthRequirement::WithPermissions(groups) => { // For OpenAPI docs, flatten all permission groups into a single list groups.iter().flatten().cloned().collect() @@ -243,6 +245,7 @@ pub fn generate_openapi_code( 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(), @@ -309,6 +312,7 @@ pub fn generate_openapi_code( 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(), @@ -334,6 +338,7 @@ pub fn generate_openapi_code( summary: Option, description: Option, auth_required: bool, + auth_optional: bool, permissions: Vec, permission_groups: Vec>, request_type_name: String, @@ -688,6 +693,9 @@ pub fn generate_openapi_code( 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": [] }]); } // Add the operation to the path item @@ -738,7 +746,7 @@ pub fn generate_openapi_code( fn permission_groups_for_spec(auth: &AuthRequirement) -> Vec> { match auth { - AuthRequirement::Unauthorized => vec![], + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], AuthRequirement::WithPermissions(groups) => groups.clone(), } } diff --git a/crates/rest/ras-rest-macro/src/permissions.rs b/crates/rest/ras-rest-macro/src/permissions.rs index 9d4965d..517a22c 100644 --- a/crates/rest/ras-rest-macro/src/permissions.rs +++ b/crates/rest/ras-rest-macro/src/permissions.rs @@ -120,7 +120,11 @@ fn operation_entries(service_def: &ServiceDefinition) -> Vec> for endpoint in &service_def.endpoints { let canonical_operation_id = format!("{}.{}", service_def.service_name, endpoint.handler_name); - let is_protected = !matches!(endpoint.auth, AuthRequirement::Unauthorized); + // OPTIONAL_AUTH is a public route (no guard) — like Unauthorized, it is not protected. + let is_protected = !matches!( + endpoint.auth, + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth + ); entries.push(OperationEntry { operation_id: canonical_operation_id.clone(), @@ -186,6 +190,9 @@ fn auth_tokens(auth: &AuthRequirement) -> TokenStream { AuthRequirement::Unauthorized => { quote! { ras_permission_manifest::AuthRequirementInfo::Public } } + AuthRequirement::OptionalAuth => { + quote! { ras_permission_manifest::AuthRequirementInfo::Optional } + } AuthRequirement::WithPermissions(groups) => { if groups.is_empty() || groups.iter().any(Vec::is_empty) { quote! { ras_permission_manifest::AuthRequirementInfo::Authenticated } @@ -209,7 +216,9 @@ fn auth_tokens(auth: &AuthRequirement) -> TokenStream { fn static_requirement_tokens(auth: &AuthRequirement) -> TokenStream { match auth { - AuthRequirement::Unauthorized => { + // Public routes (Unauthorized / OptionalAuth) are not protected, so this + // placeholder is never emitted — it only keeps the match exhaustive. + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => { quote! { ras_permission_manifest::StaticPermissionRequirement::authenticated_only() } } AuthRequirement::WithPermissions(groups) => { diff --git a/crates/rest/ras-rest-macro/tests/e2e.rs b/crates/rest/ras-rest-macro/tests/e2e.rs index fa45107..d8db7e6 100644 --- a/crates/rest/ras-rest-macro/tests/e2e.rs +++ b/crates/rest/ras-rest-macro/tests/e2e.rs @@ -27,6 +27,11 @@ struct ItemsResponse { items: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] +struct WhoamiResponse { + caller: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] struct RenameItemV1 { name: String, @@ -66,6 +71,8 @@ rest_service!({ endpoints: [ /// List all items. GET UNAUTHORIZED items() -> ItemsResponse, + GET OPTIONAL_AUTH whoami() -> WhoamiResponse, + POST OPTIONAL_AUTH whoami/echo(CreateItem) -> WhoamiResponse, GET WITH_PERMISSIONS(["user"]) items/{id: u32}() -> Item, POST WITH_PERMISSIONS(["admin"]) items(CreateItem) -> Item, GET UNAUTHORIZED search ? q: String & limit: Option & exact: bool () -> ItemsResponse, @@ -85,6 +92,20 @@ rest_service!({ }, ], }, + // Versioned OPTIONAL_AUTH endpoint — exercises the legacy/migration handler + // arm with caller wiring (otherwise instantiated by nothing in the workspace). + POST OPTIONAL_AUTH v2/items/{id: u32}/touch ? notify: bool (RenameItemV2) -> RenamedItemV2 { + version: v2, + versions: [ + v1 { + path: v1/items/{id: u32}/touch, + query: [notify: Option], + body: RenameItemV1, + response: RenamedItemV1, + migration: TouchCompat, + }, + ], + }, ] }); @@ -124,10 +145,69 @@ impl ras_rest_core::VersionMigration for RenameIte } } +struct TouchCompat; + +impl + ras_rest_core::VersionMigration< + DemoPostV2ItemsByIdTouchV1Request, + DemoPostV2ItemsByIdTouchV2Request, + > for TouchCompat +{ + type Error = std::convert::Infallible; + + fn migrate( + value: DemoPostV2ItemsByIdTouchV1Request, + ) -> Result { + Ok(DemoPostV2ItemsByIdTouchV2Request { + path: DemoPostV2ItemsByIdTouchV2Path { id: value.path.id }, + query: DemoPostV2ItemsByIdTouchV2Query { + notify: value.query.notify.unwrap_or(false), + }, + body: RenameItemV2 { + display_name: value.body.name, + notify: value.query.notify.unwrap_or(false), + }, + }) + } +} + +impl ras_rest_core::VersionMigration for TouchCompat { + type Error = std::convert::Infallible; + + fn migrate(value: RenamedItemV2) -> Result { + Ok(RenamedItemV1 { + name: value.display_name, + }) + } +} + +fn caller_label(caller: &ras_auth_core::Caller) -> String { + match caller { + ras_auth_core::Caller::Authenticated(user) => user.user_id.clone(), + ras_auth_core::Caller::Anonymous => "anonymous".to_string(), + } +} + struct DemoImpl; #[async_trait::async_trait] impl DemoTrait for DemoImpl { + async fn get_whoami(&self, caller: ras_auth_core::Caller) -> RestResult { + Ok(RestResponse::ok(WhoamiResponse { + caller: caller_label(&caller), + })) + } + + async fn post_whoami_echo( + &self, + caller: ras_auth_core::Caller, + body: CreateItem, + ) -> RestResult { + Ok(RestResponse::ok(WhoamiResponse { + caller: format!("{}:{}", caller_label(&caller), body.name), + })) + } + async fn get_items(&self) -> RestResult { Ok(RestResponse::ok(ItemsResponse { items: vec![Item { @@ -257,6 +337,21 @@ impl DemoTrait for DemoImpl { notified: notify || request.notify, })) } + + async fn post_v2_items_by_id_touch( + &self, + caller: ras_auth_core::Caller, + id: u32, + notify: bool, + request: RenameItemV2, + ) -> RestResult { + // Encode the resolved caller so the legacy/migration arm's caller wiring is asserted. + Ok(RestResponse::ok(RenamedItemV2 { + id, + display_name: format!("{}:{}", caller_label(&caller), request.display_name), + notified: notify || request.notify, + })) + } } fn router() -> axum::Router { @@ -309,6 +404,38 @@ async fn legacy_rest_version_round_trips_through_canonical_handler() { ); } +#[tokio::test] +async fn optional_auth_versioned_legacy_path_threads_caller() { + // v1 (legacy) path with a valid token: exercises the legacy/migration arm AND + // caller resolution. The migrated v1 response carries display_name only. + let response = server() + .post("/api/v1/items/7/touch?notify=true") + .authorization_bearer("user-token") + .json(&RenameItemV1 { + name: "hello".to_string(), + }) + .await; + response.assert_status_ok(); + let resp: RenamedItemV1 = response.json(); + // RenamedItemV1.name == migrated display_name == ":". + assert_eq!(resp.name, "user-1:hello"); +} + +#[tokio::test] +async fn optional_auth_versioned_canonical_path_is_anonymous_without_token() { + let response = server() + .post("/api/v2/items/9/touch?notify=false") + .json(&RenameItemV2 { + display_name: "world".to_string(), + notify: false, + }) + .await; + response.assert_status_ok(); + let resp: RenamedItemV2 = response.json(); + assert_eq!(resp.id, 9); + assert_eq!(resp.display_name, "anonymous:world"); +} + #[tokio::test] async fn canonical_rest_version_uses_v2_path_and_types() { let response = server() @@ -377,6 +504,58 @@ async fn auth_post_with_admin_succeeds_and_user_id_propagates() { assert_eq!(item.id, 7); } +#[tokio::test] +async fn optional_auth_without_token_sees_anonymous_caller() { + let response = server().get("/api/whoami").await; + response.assert_status_ok(); + let resp: WhoamiResponse = response.json(); + assert_eq!(resp.caller, "anonymous"); +} + +#[tokio::test] +async fn optional_auth_with_valid_token_sees_authenticated_caller() { + let response = server() + .get("/api/whoami") + .authorization_bearer("user-token") + .await; + response.assert_status_ok(); + let resp: WhoamiResponse = response.json(); + assert_eq!(resp.caller, "user-1"); +} + +#[tokio::test] +async fn optional_auth_with_invalid_token_is_lenient_and_anonymous() { + // A present-but-bad credential must NOT reject an OPTIONAL_AUTH route; it + // downgrades to anonymous. + let response = server() + .get("/api/whoami") + .authorization_bearer("not-a-real-token") + .await; + response.assert_status_ok(); + let resp: WhoamiResponse = response.json(); + assert_eq!(resp.caller, "anonymous"); +} + +#[tokio::test] +async fn optional_auth_post_threads_caller_and_body() { + // Anonymous POST with a body still reaches the handler. + let anon = server() + .post("/api/whoami/echo") + .json(&CreateItem { name: "hi".into() }) + .await; + anon.assert_status_ok(); + assert_eq!(anon.json::().caller, "anonymous:hi"); + + // Authenticated POST sees the caller and the body. + let authed = server() + .post("/api/whoami/echo") + .authorization_bearer("user-token") + .json(&CreateItem { name: "hi".into() }) + .await; + authed.assert_status_ok(); + assert_eq!(authed.json::().caller, "user-1:hi"); +} + #[tokio::test] async fn query_params_required_and_optional_serialize_correctly() { // Drive the generated client over the in-process transport so the diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md index 6fab1ad..7a45175 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/README.md @@ -76,6 +76,7 @@ jsonrpc_bidirectional_service!({ service_name: UserService, client_to_server: [ UNAUTHORIZED get_user(UserRequest) -> UserResponse, + OPTIONAL_AUTH feed(FeedRequest) -> FeedResponse, // public; handler receives a `Caller` WITH_PERMISSIONS(["admin"]) delete_user(UserRequest) -> bool, WITH_PERMISSIONS(["write"] | ["admin"]) update_user(UserRequest) -> UserResponse, ], diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs index de7454f..d1d4e62 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/lib.rs @@ -17,6 +17,13 @@ mod tests; /// - Client struct with type-safe method calls and notification handlers /// - Type-safe message enums for both directions /// +/// `client_to_server` methods declare one of three auth levels — `UNAUTHORIZED`, +/// `OPTIONAL_AUTH`, or `WITH_PERMISSIONS([...])`. An `OPTIONAL_AUTH` handler +/// receives a `ras_auth_core::Caller` built from the connection's optional user +/// (the server must allow anonymous connections for anonymous callers to reach +/// it). `OPTIONAL_AUTH` is rejected on `server_to_client` calls (outbound — no +/// inbound caller to identify). +/// /// See the tests for usage examples. #[proc_macro] pub fn jsonrpc_bidirectional_service(input: TokenStream) -> TokenStream { @@ -54,6 +61,10 @@ struct NotificationDefinition { #[derive(Debug)] enum AuthRequirement { Unauthorized, + /// Public method that opportunistically identifies its caller. Never rejected + /// for auth reasons; the handler receives a `ras_auth_core::Caller` built from + /// the connection's (optional) authenticated user. + OptionalAuth, WithPermissions(Vec>), // Vec of permission groups - OR between groups, AND within groups } @@ -156,7 +167,20 @@ fn parse_server_to_client_call(input: syn::parse::ParseStream) -> syn::Result()?; match ident.to_string().as_str() { - "UNAUTHORIZED" | "WITH_PERMISSIONS" => return input.parse::(), + // A server_to_client call is outbound — there is no inbound caller to + // identify, and the generated call/handler signatures carry no `Caller`. + // Reject OPTIONAL_AUTH here rather than silently treating it as public. + "OPTIONAL_AUTH" => { + return Err(syn::Error::new( + ident.span(), + "OPTIONAL_AUTH is not supported on server_to_client calls: there is \ + no inbound caller to identify. Use UNAUTHORIZED (it is only meaningful \ + on client_to_server methods).", + )); + } + "UNAUTHORIZED" | "WITH_PERMISSIONS" => { + return input.parse::(); + } _ => {} } } @@ -180,11 +204,12 @@ fn parse_server_to_client_call(input: syn::parse::ParseStream) -> syn::Result syn::Result { - // Parse auth requirement (UNAUTHORIZED or WITH_PERMISSIONS([...])) + // Parse auth requirement (UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS([...])) 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" => { // Parse ([...] | [...] | ...) let perms_content; @@ -231,7 +256,7 @@ impl Parse for MethodDefinition { _ => { return Err(syn::Error::new( auth_ident.span(), - "Expected UNAUTHORIZED or WITH_PERMISSIONS", + "Expected UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS", )); } } diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/permissions.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/permissions.rs index bc4db94..34e91b3 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/permissions.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/permissions.rs @@ -120,7 +120,11 @@ impl OperationEntry<'_> { fn operation_entries(service_def: &BidirectionalServiceDefinition) -> Vec> { let mut entries = Vec::new(); for method in &service_def.client_to_server { - let is_protected = !matches!(method.auth, AuthRequirement::Unauthorized); + // OPTIONAL_AUTH is a public method (no guard) — like Unauthorized, it is not protected. + let is_protected = !matches!( + method.auth, + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth + ); entries.push(OperationEntry { operation_id: format!( "{}.client_to_server.{}", @@ -136,7 +140,11 @@ fn operation_entries(service_def: &BidirectionalServiceDefinition) -> Vec TokenStream { AuthRequirement::Unauthorized => { quote! { ras_permission_manifest::AuthRequirementInfo::Public } } + AuthRequirement::OptionalAuth => { + quote! { ras_permission_manifest::AuthRequirementInfo::Optional } + } AuthRequirement::WithPermissions(groups) => { if groups.is_empty() || groups.iter().any(Vec::is_empty) { quote! { ras_permission_manifest::AuthRequirementInfo::Authenticated } @@ -182,7 +193,9 @@ fn auth_tokens(auth: &AuthRequirement) -> TokenStream { fn static_requirement_tokens(auth: &AuthRequirement) -> TokenStream { match auth { - AuthRequirement::Unauthorized => { + // Public methods (Unauthorized / OptionalAuth) are not protected, so this + // placeholder is never emitted — it only keeps the match exhaustive. + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => { quote! { ras_permission_manifest::StaticPermissionRequirement::authenticated_only() } } AuthRequirement::WithPermissions(groups) => { diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/server.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/server.rs index df0cb71..10644e1 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/server.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/server.rs @@ -26,6 +26,11 @@ pub fn generate_server_code( async fn #method_name(&self, client_id: ras_jsonrpc_bidirectional_types::ConnectionId, connection_manager: &dyn ras_jsonrpc_bidirectional_types::ConnectionManager, request: #request_type) -> Result<#response_type, Box>; } } + AuthRequirement::OptionalAuth => { + quote! { + async fn #method_name(&self, client_id: ras_jsonrpc_bidirectional_types::ConnectionId, connection_manager: &dyn ras_jsonrpc_bidirectional_types::ConnectionManager, caller: ras_auth_core::Caller, request: #request_type) -> Result<#response_type, Box>; + } + } AuthRequirement::WithPermissions(_) => { quote! { async fn #method_name(&self, client_id: ras_jsonrpc_bidirectional_types::ConnectionId, connection_manager: &dyn ras_jsonrpc_bidirectional_types::ConnectionManager, user: &ras_auth_core::AuthenticatedUser, request: #request_type) -> Result<#response_type, Box>; @@ -51,7 +56,7 @@ pub fn generate_server_code( let method_str = method_name.to_string(); let request_type = &method.request_type; let permission_groups = match &method.auth { - AuthRequirement::Unauthorized => Vec::new(), + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => Vec::new(), AuthRequirement::WithPermissions(groups) => groups.clone(), }; @@ -81,6 +86,38 @@ pub fn generate_server_code( } } } + AuthRequirement::OptionalAuth => { + quote! { + #method_str => { + // OPTIONAL_AUTH: surface the connection's (optional) user as + // a Caller. Never rejected; no permission check. (The user is + // cloned out of the connection's Arc into the owned Caller.) + let caller = ras_auth_core::Caller::from( + context.get_user().await.map(|user| (*user).clone()), + ); + + // Parse parameters + let params: #request_type = if let Some(params) = request.params { + serde_json::from_value(params) + .map_err(|e| ras_jsonrpc_bidirectional_server::ServerError::InvalidRequest(format!("Invalid params: {}", e)))? + } else { + // For unit type (), we can deserialize from null + serde_json::from_value(serde_json::Value::Null) + .map_err(|e| ras_jsonrpc_bidirectional_server::ServerError::InvalidRequest(format!("Invalid params: {}", e)))? + }; + + // Call handler with client ID, connection manager reference, and caller + match self.service.#method_name(context.id, self.connection_manager.as_ref(), caller, params).await { + Ok(result) => { + let result_value = serde_json::to_value(result) + .map_err(|e| ras_jsonrpc_bidirectional_server::ServerError::Internal(e.to_string()))?; + Ok(Some(ras_jsonrpc_types::JsonRpcResponse::success(result_value, request.id.clone()))) + } + Err(e) => Err(ras_jsonrpc_bidirectional_server::ServerError::Internal(e.to_string())), + } + } + } + } AuthRequirement::WithPermissions(_) => { // Generate permission groups code for quote let permission_groups_code = if permission_groups.is_empty() { diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/tests.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/tests.rs index eed6705..a36d987 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/tests.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/src/tests.rs @@ -98,3 +98,42 @@ fn test_server_to_client_calls_parse_without_auth_prefix() { AuthRequirement::Unauthorized )); } + +#[test] +fn test_optional_auth_parses_on_client_to_server() { + let input = r#"{ + service_name: Demo, + client_to_server: [ + OPTIONAL_AUTH whoami(String) -> String, + ], + server_to_client: [], + server_to_client_calls: [] + }"#; + + let parsed: BidirectionalServiceDefinition = syn::parse_str(input).unwrap(); + assert!(matches!( + parsed.client_to_server[0].auth, + AuthRequirement::OptionalAuth + )); +} + +#[test] +fn test_optional_auth_rejected_on_server_to_client_calls() { + // OPTIONAL_AUTH has no inbound caller on an outbound call, so it must be a + // hard parse error rather than silently behaving like UNAUTHORIZED. + let input = r#"{ + service_name: Demo, + client_to_server: [], + server_to_client: [], + server_to_client_calls: [ + OPTIONAL_AUTH get_status(String) -> bool, + ] + }"#; + + let err = syn::parse_str::(input) + .expect_err("OPTIONAL_AUTH must be rejected on server_to_client calls"); + assert!( + err.to_string().contains("OPTIONAL_AUTH is not supported"), + "unexpected error: {err}" + ); +} diff --git a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/e2e.rs b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/e2e.rs index d42a988..643c2cb 100644 --- a/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/e2e.rs +++ b/crates/rpc/bidirectional/ras-jsonrpc-bidirectional-macro/tests/e2e.rs @@ -42,6 +42,7 @@ jsonrpc_bidirectional_service!({ service_name: Demo, client_to_server: [ UNAUTHORIZED hello(String) -> String, + OPTIONAL_AUTH whoami(String) -> String, WITH_PERMISSIONS(["user"]) echo(EchoIn) -> EchoOut, ], server_to_client: [ @@ -94,6 +95,20 @@ impl DemoService for DemoImpl { }) } + async fn whoami( + &self, + _client: ConnectionId, + _conns: &dyn ras_jsonrpc_bidirectional_types::ConnectionManager, + caller: ras_auth_core::Caller, + _req: String, + ) -> Result> { + // OPTIONAL_AUTH: report the connection's user when present, anonymous otherwise. + Ok(match caller { + ras_auth_core::Caller::Authenticated(user) => user.user_id, + ras_auth_core::Caller::Anonymous => "anonymous".to_string(), + }) + } + async fn notify_ping( &self, _connection_id: ConnectionId, @@ -261,6 +276,42 @@ async fn generated_handler_round_trips_without_socket() { )); } +#[tokio::test] +async fn optional_auth_handler_is_anonymous_without_user() { + let messages = run_generated_handler( + JsonRpcRequest::new( + "whoami".into(), + Some(serde_json::json!("ignored")), + Some(5.into()), + ), + None, + None, + ) + .await; + + let response = response_from(&messages); + assert!(response.error.is_none()); + assert_eq!(response.result, Some(serde_json::json!("anonymous"))); +} + +#[tokio::test] +async fn optional_auth_handler_identifies_connection_user() { + let messages = run_generated_handler( + JsonRpcRequest::new( + "whoami".into(), + Some(serde_json::json!("ignored")), + Some(6.into()), + ), + Some(test_user("user-1", &[])), + None, + ) + .await; + + let response = response_from(&messages); + assert!(response.error.is_none()); + assert_eq!(response.result, Some(serde_json::json!("user-1"))); +} + #[tokio::test] async fn generated_handler_enforces_permissions_without_socket() { let messages = run_generated_handler( diff --git a/crates/rpc/ras-jsonrpc-core/README.md b/crates/rpc/ras-jsonrpc-core/README.md index 85d6693..ed90fb0 100644 --- a/crates/rpc/ras-jsonrpc-core/README.md +++ b/crates/rpc/ras-jsonrpc-core/README.md @@ -169,6 +169,7 @@ jsonrpc_service!({ service_name: MyService, methods: [ UNAUTHORIZED sign_in(SignInRequest) -> SignInResponse, + OPTIONAL_AUTH feed(FeedRequest) -> FeedResponse, WITH_PERMISSIONS(["user"]) get_profile(()) -> UserProfile, WITH_PERMISSIONS(["admin"]) delete_user(UserId) -> (), ] diff --git a/crates/rpc/ras-jsonrpc-macro/README.md b/crates/rpc/ras-jsonrpc-macro/README.md index 3c58a3b..3337092 100644 --- a/crates/rpc/ras-jsonrpc-macro/README.md +++ b/crates/rpc/ras-jsonrpc-macro/README.md @@ -13,7 +13,7 @@ This crate provides the `jsonrpc_service!` procedural macro that generates type- ## Features - **Declarative service definition**: Clean, readable syntax for defining JSON-RPC methods -- **Authentication integration**: Built-in support for `UNAUTHORIZED` and `WITH_PERMISSIONS` methods +- **Authentication integration**: Built-in support for `UNAUTHORIZED`, `OPTIONAL_AUTH` (public, but hands the handler a `Caller`), and `WITH_PERMISSIONS` methods - **Type safety**: Compile-time validation of request/response types - **Axum integration**: Generates standard axum `Router` for easy composition - **Trait-based service wiring**: Implement one generated trait and pass it to the service builder @@ -82,6 +82,7 @@ jsonrpc_service!({ service_name: MyService, methods: [ UNAUTHORIZED sign_in(SignInRequest) -> SignInResponse, + OPTIONAL_AUTH feed(FeedRequest) -> FeedResponse, // public; handler receives a `Caller` WITH_PERMISSIONS(["user"]) get_profile(()) -> UserProfile, WITH_PERMISSIONS(["admin"]) delete_user(UserId) -> (), ] @@ -188,6 +189,7 @@ jsonrpc_service!({ openrpc: true, // Optional: Enable OpenRPC generation methods: [ UNAUTHORIZED sign_in(SignInRequest) -> SignInResponse, + OPTIONAL_AUTH feed(FeedRequest) -> FeedResponse, // public; handler receives a `Caller` WITH_PERMISSIONS(["user"]) get_profile(()) -> UserProfile, WITH_PERMISSIONS(["admin"]) delete_user(UserId) -> (), ] diff --git a/crates/rpc/ras-jsonrpc-macro/src/lib.rs b/crates/rpc/ras-jsonrpc-macro/src/lib.rs index 2370fd2..3297085 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/lib.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/lib.rs @@ -12,7 +12,27 @@ mod static_hosting; /// This macro generates a service trait and builder that integrates with axum /// for handling JSON-RPC requests with authentication and authorization. /// -/// See the tests for usage examples. +/// Each method declares one of three auth levels: +/// +/// * `UNAUTHORIZED` — public; the handler receives no caller. +/// * `OPTIONAL_AUTH` — public, but opportunistically identified: never rejected +/// for auth reasons, the handler receives a `ras_jsonrpc_core::Caller` +/// (`Anonymous`, or `Authenticated(user)` for a valid credential). A +/// present-but-bad credential downgrades to `Anonymous`. +/// * `WITH_PERMISSIONS([...])` — authenticated and gated. +/// +/// ```ignore +/// jsonrpc_service!({ +/// service_name: ApiService, +/// methods: [ +/// UNAUTHORIZED register(UserRequest) -> UserResponse, +/// OPTIONAL_AUTH feed(FeedRequest) -> FeedResponse, +/// WITH_PERMISSIONS(["user.read"]) get_profile(()) -> UserResponse, +/// ] +/// }); +/// ``` +/// +/// See the tests for further usage examples. #[proc_macro] pub fn jsonrpc_service(input: TokenStream) -> TokenStream { let service_definition = parse_macro_input!(input as ServiceDefinition); @@ -91,6 +111,9 @@ impl DocComment { #[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 } @@ -235,11 +258,12 @@ 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")?; - // Parse auth requirement (UNAUTHORIZED or WITH_PERMISSIONS([...])) + // Parse auth requirement (UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS([...])) 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" => { // Parse ([...] | [...] | ...) let perms_content; @@ -286,7 +310,7 @@ impl Parse for MethodDefinition { _ => { return Err(syn::Error::new( auth_ident.span(), - "Expected UNAUTHORIZED or WITH_PERMISSIONS", + "Expected UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS", )); } } @@ -562,6 +586,11 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt 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; @@ -570,6 +599,28 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt } }); + // 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)|*) } + }; + // Generate method dispatch logic for the JSON-RPC handler let method_dispatch = service_def .methods @@ -726,41 +777,58 @@ fn generate_server_code(service_def: &ServiceDefinition) -> proc_macro2::TokenSt return ras_jsonrpc_types::JsonRpcResponse::error(ras_jsonrpc_types::JsonRpcError::invalid_request(), request_id); } - // Try to authenticate user if auth provider is available - let auth_result = if let Some(auth_provider) = &self.auth_provider { + // 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 let Err(_) = ras_jsonrpc_core::validate_csrf_for_credential("POST", &headers, &credential, &self.auth_transport) { - return ras_jsonrpc_types::JsonRpcResponse::error( + 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 - ); + 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(), + )), + } } - - Some(auth_provider.authenticate(credential.token().to_string()).await) - }, - Err(ras_jsonrpc_core::AuthTransportError::MissingCredentials) => None, - Err(_) => Some(Err(ras_jsonrpc_core::AuthError::AuthenticationRequired)), + } + 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 { - None + Ok(None) }; - let authenticated_user = match auth_result { - Some(Ok(user)) => Some(user), - Some(Err(ras_jsonrpc_core::AuthError::TokenExpired)) => { - return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::token_expired(), - request_id - ); - } - Some(Err(_)) => { - return ras_jsonrpc_types::JsonRpcResponse::error( - ras_jsonrpc_types::JsonRpcError::authentication_required(), - request_id - ); + let authenticated_user = match auth_outcome { + Ok(user) => user, + Err(error_response) => { + if __ras_method_is_optional { + None + } else { + return error_response; + } } - None => None, }; // Call usage tracker if configured @@ -793,7 +861,7 @@ fn jsonrpc_method_wire_name(method: &MethodDefinition) -> String { fn jsonrpc_permission_groups_code(auth: &AuthRequirement) -> proc_macro2::TokenStream { let permission_groups = match auth { - AuthRequirement::Unauthorized => Vec::new(), + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => Vec::new(), AuthRequirement::WithPermissions(groups) => groups.clone(), }; @@ -813,6 +881,16 @@ fn jsonrpc_auth_check_code( ) -> (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); ( @@ -890,6 +968,9 @@ fn generate_jsonrpc_canonical_dispatch(method: &MethodDefinition) -> proc_macro2 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 } } @@ -945,6 +1026,9 @@ fn generate_jsonrpc_legacy_dispatch( 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 } } diff --git a/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs b/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs index 130c164..fd71564 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/openrpc.rs @@ -164,9 +164,10 @@ pub fn generate_openrpc_code( None => quote! { None }, }; let auth_required = matches!(method.auth, AuthRequirement::WithPermissions(_)); + let auth_optional = matches!(method.auth, AuthRequirement::OptionalAuth); // Flatten permission groups for OpenRPC documentation let permissions = match &method.auth { - AuthRequirement::Unauthorized => vec![], + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], AuthRequirement::WithPermissions(groups) => { // For OpenRPC docs, flatten all permission groups into a single list groups.iter().flatten().cloned().collect() @@ -195,6 +196,7 @@ pub fn generate_openrpc_code( 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(), @@ -225,6 +227,7 @@ pub fn generate_openrpc_code( 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(), @@ -247,6 +250,7 @@ pub fn generate_openrpc_code( summary: Option, description: Option, auth_required: bool, + auth_optional: bool, permissions: Vec, permission_groups: Vec>, request_type_name: String, @@ -430,6 +434,12 @@ pub fn generate_openrpc_code( 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 { @@ -580,7 +590,7 @@ pub fn generate_openrpc_code( fn permission_groups_for_spec(auth: &AuthRequirement) -> Vec> { match auth { - AuthRequirement::Unauthorized => vec![], + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![], AuthRequirement::WithPermissions(groups) => groups.clone(), } } diff --git a/crates/rpc/ras-jsonrpc-macro/src/permissions.rs b/crates/rpc/ras-jsonrpc-macro/src/permissions.rs index 69d4527..8099359 100644 --- a/crates/rpc/ras-jsonrpc-macro/src/permissions.rs +++ b/crates/rpc/ras-jsonrpc-macro/src/permissions.rs @@ -120,7 +120,11 @@ fn operation_entries(service_def: &ServiceDefinition) -> Vec> .wire_name .clone() .unwrap_or_else(|| method.name.to_string()); - let is_protected = !matches!(method.auth, AuthRequirement::Unauthorized); + // OPTIONAL_AUTH is a public method (no guard) — like Unauthorized, it is not protected. + let is_protected = !matches!( + method.auth, + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth + ); entries.push(OperationEntry { operation_id: canonical_operation_id.clone(), @@ -158,6 +162,9 @@ fn auth_tokens(auth: &AuthRequirement) -> TokenStream { AuthRequirement::Unauthorized => { quote! { ras_permission_manifest::AuthRequirementInfo::Public } } + AuthRequirement::OptionalAuth => { + quote! { ras_permission_manifest::AuthRequirementInfo::Optional } + } AuthRequirement::WithPermissions(groups) => { if groups.is_empty() || groups.iter().any(Vec::is_empty) { quote! { ras_permission_manifest::AuthRequirementInfo::Authenticated } @@ -181,7 +188,9 @@ fn auth_tokens(auth: &AuthRequirement) -> TokenStream { fn static_requirement_tokens(auth: &AuthRequirement) -> TokenStream { match auth { - AuthRequirement::Unauthorized => { + // Public methods (Unauthorized / OptionalAuth) are not protected, so this + // placeholder is never emitted — it only keeps the match exhaustive. + AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => { quote! { ras_permission_manifest::StaticPermissionRequirement::authenticated_only() } } AuthRequirement::WithPermissions(groups) => { diff --git a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs index 1bf5f05..e9a4308 100644 --- a/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs +++ b/crates/rpc/ras-jsonrpc-macro/tests/e2e.rs @@ -99,6 +99,7 @@ jsonrpc_service!({ }, ], }, + OPTIONAL_AUTH whoami(EchoRequest) -> EchoResponse, WITH_PERMISSIONS(["user"]) add(AddRequest) -> AddResponse, WITH_PERMISSIONS(["admin"]) admin_only(EchoRequest) -> EchoResponse, ] @@ -145,6 +146,18 @@ impl DemoTrait for DemoImpl { user_id: Some(user.user_id.clone()), }) } + + async fn whoami( + &self, + caller: ras_jsonrpc_core::Caller, + req: EchoRequest, + ) -> Result> { + // OPTIONAL_AUTH: report the caller when present, anonymous otherwise. + Ok(EchoResponse { + msg: req.msg, + user_id: caller.authenticated().map(|user| user.user_id.clone()), + }) + } } fn router() -> axum::Router { @@ -368,6 +381,62 @@ async fn unauth_method_round_trips() { assert_eq!(resp.user_id, None); } +#[tokio::test] +async fn optional_auth_method_anonymous_without_token() { + let server = server(); + + let resp: EchoResponse = call_rpc( + &server, + "whoami", + json!(EchoRequest { + msg: "hi".to_string() + }), + None, + ) + .await + .expect("whoami ok for anonymous"); + + assert_eq!(resp.msg, "hi"); + assert_eq!(resp.user_id, None); +} + +#[tokio::test] +async fn optional_auth_method_identifies_valid_token() { + let server = server(); + + let resp: EchoResponse = call_rpc( + &server, + "whoami", + json!(EchoRequest { + msg: "hi".to_string() + }), + Some("user-token"), + ) + .await + .expect("whoami ok for authenticated"); + + assert_eq!(resp.user_id.as_deref(), Some("user-1")); +} + +#[tokio::test] +async fn optional_auth_method_is_lenient_with_bad_token() { + let server = server(); + + // A present-but-invalid token must NOT reject an OPTIONAL_AUTH method. + let resp: EchoResponse = call_rpc( + &server, + "whoami", + json!(EchoRequest { + msg: "hi".to_string() + }), + Some("not-a-real-token"), + ) + .await + .expect("whoami stays lenient for a bad token"); + + assert_eq!(resp.user_id, None); +} + #[tokio::test] async fn permission_required_method_rejects_anonymous() { let server = server(); diff --git a/crates/specs/ras-permission-manifest/src/lib.rs b/crates/specs/ras-permission-manifest/src/lib.rs index 5615f54..b94b553 100644 --- a/crates/specs/ras-permission-manifest/src/lib.rs +++ b/crates/specs/ras-permission-manifest/src/lib.rs @@ -9,7 +9,12 @@ use std::collections::{BTreeSet, HashSet}; use std::path::Path; /// Current permission manifest schema version. -pub const SCHEMA_VERSION: u32 = 1; +/// +/// v2 added the `AuthRequirementInfo::Optional` variant (the `OPTIONAL_AUTH` +/// route level). A consumer pinned to an older crate version will fail to +/// deserialize a manifest containing `"type":"optional"`; the bumped +/// `schema_version` lets such tooling detect the mismatch up front. +pub const SCHEMA_VERSION: u32 = 2; /// A combined permission manifest for one or more services. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -138,8 +143,14 @@ pub struct OperationPermissions { #[serde(tag = "type", rename_all = "snake_case")] pub enum AuthRequirementInfo { Public, + /// Public route that opportunistically identifies its caller (`OPTIONAL_AUTH`): + /// anonymous callers are allowed, but a valid credential is authenticated and + /// surfaced to the handler. No permission is required. + Optional, Authenticated, - Permissions { any_of: Vec }, + Permissions { + any_of: Vec, + }, } impl AuthRequirementInfo { diff --git a/documentation/src/auth-in-api-contract.md b/documentation/src/auth-in-api-contract.md index f07ebd8..1b74c5b 100644 --- a/documentation/src/auth-in-api-contract.md +++ b/documentation/src/auth-in-api-contract.md @@ -4,6 +4,7 @@ RAS puts auth requirements next to the endpoint or method declaration: ```rust,ignore UNAUTHORIZED health(()) -> HealthStatus, +OPTIONAL_AUTH feed(()) -> Feed, WITH_PERMISSIONS(["user"]) get_profile(()) -> UserProfile, WITH_PERMISSIONS(["admin"] | ["owner", "editor"]) update_project(UpdateProject) -> Project, ``` @@ -40,6 +41,27 @@ override that method for custom policy. `UNAUTHORIZED` means the generated server does not require a credential for the operation. +`OPTIONAL_AUTH` means the operation is **public, but opportunistically +identified**. The route is never rejected for auth reasons; instead the handler +receives a `ras_auth_core::Caller` as its first argument: + +```rust,ignore +pub enum Caller { + Anonymous, + Authenticated(AuthenticatedUser), +} +``` + +Resolution is **fully lenient**: a missing credential, an invalid or expired +token, a missing auth provider, or a cookie credential that fails CSRF on an +unsafe method (`POST`/`PUT`/`PATCH`/`DELETE`) all resolve to `Caller::Anonymous` +— a forged or stale credential simply executes as the public path. A valid +credential resolves to `Caller::Authenticated(user)`. No permission check is +performed. Reach for it on "public, but richer when signed in" endpoints +(per-document ACLs, personalization, author previews). The `file_service!` +macro surfaces the same optional caller through `FileRequestContext` rather than +a `Caller` parameter. + `WITH_PERMISSIONS(["a", "b"])` means the generated server requires a valid credential and a permission group containing both `a` and `b`. @@ -71,3 +93,8 @@ Permission names are also emitted as extension metadata so explorer UIs and client-generation workflows can show what a call requires. `x-permissions` contains a flattened compatibility list, while `x-permission-groups` preserves the real OR/AND grouping. + +`OPTIONAL_AUTH` operations advertise an **optional** security requirement: REST +and file services emit `security: [{}, { "bearerAuth": [] }]` (anonymous is +acceptable, and a bearer token is honoured), and JSON-RPC methods emit +`x-authentication` with `"required": false`. diff --git a/documentation/src/macros/bidirectional-jsonrpc-service.md b/documentation/src/macros/bidirectional-jsonrpc-service.md index 22d4bd2..20c3202 100644 --- a/documentation/src/macros/bidirectional-jsonrpc-service.md +++ b/documentation/src/macros/bidirectional-jsonrpc-service.md @@ -76,13 +76,19 @@ jsonrpc_bidirectional_service!({ }); ``` -`client_to_server` methods support the same `UNAUTHORIZED` and -`WITH_PERMISSIONS(["a"] | ["b", "c"])` style as the HTTP JSON-RPC macro. +`client_to_server` methods support the same `UNAUTHORIZED`, `OPTIONAL_AUTH`, and +`WITH_PERMISSIONS(["a"] | ["b", "c"])` style as the HTTP JSON-RPC macro (see +[Auth In The API Contract](../auth-in-api-contract.md)). An `OPTIONAL_AUTH` +method is built from the connection's optional user, so the server must allow +anonymous connections (`require_auth(false)`) for anonymous callers to reach it. +`OPTIONAL_AUTH` is **not** allowed on `server_to_client` calls — those are +outbound, so there is no inbound caller to identify. ## Implement And Mount The Server -Server handlers receive the connection id and connection manager. Protected -methods also receive `&AuthenticatedUser`. +Server handlers receive the connection id and connection manager. `WITH_PERMISSIONS` +methods also receive `&AuthenticatedUser`; `OPTIONAL_AUTH` methods receive a +`ras_auth_core::Caller` (in the same position) instead. ```rust,ignore #[async_trait::async_trait] diff --git a/documentation/src/macros/file-service.md b/documentation/src/macros/file-service.md index 21c683d..63ed605 100644 --- a/documentation/src/macros/file-service.md +++ b/documentation/src/macros/file-service.md @@ -188,16 +188,25 @@ Path parameters become `by_*` method name segments. For example, ## Auth Syntax -File services use the same auth syntax as the other service macros: +File services use the same auth syntax as the other service macros — +`UNAUTHORIZED`, `OPTIONAL_AUTH`, and `WITH_PERMISSIONS([...])` (see +[Auth In The API Contract](../auth-in-api-contract.md)): ```rust,ignore +UNAUTHORIZED +OPTIONAL_AUTH WITH_PERMISSIONS(["files:write"]) WITH_PERMISSIONS(["files:write", "tenant:active"]) WITH_PERMISSIONS(["admin"] | ["files:write", "tenant:active"]) WITH_PERMISSIONS([]) ``` -Use `WITH_PERMISSIONS([])` for authenticated-only file operations. +Use `WITH_PERMISSIONS([])` for authenticated-only file operations. Use +`OPTIONAL_AUTH` for a public download/upload that should still recognise a +signed-in caller: the route is never rejected for auth reasons, and the +(optional) caller is surfaced through `FileRequestContext` — `ctx.user` is +`Some(user)` for a valid credential and `None` otherwise — rather than as a +separate `Caller` parameter. ## Use The Generated Rust Client diff --git a/documentation/src/macros/jsonrpc-service.md b/documentation/src/macros/jsonrpc-service.md index d5b187a..22a33b5 100644 --- a/documentation/src/macros/jsonrpc-service.md +++ b/documentation/src/macros/jsonrpc-service.md @@ -70,6 +70,7 @@ jsonrpc_service!({ openrpc: true, methods: [ UNAUTHORIZED sign_in(SignInRequest) -> SignInResponse, + OPTIONAL_AUTH feed(FeedRequest) -> FeedResponse, WITH_PERMISSIONS(["user"]) get_profile(()) -> UserProfile, WITH_PERMISSIONS(["admin"] | ["support", "users:write"]) disable_user(String) -> (), ] @@ -77,11 +78,15 @@ jsonrpc_service!({ ``` The Rust method name is the JSON-RPC wire method unless a versioned method block -sets an explicit `wire` name. +sets an explicit `wire` name. The auth requirement is one of `UNAUTHORIZED`, +`OPTIONAL_AUTH`, or `WITH_PERMISSIONS([...])`; see +[Auth In The API Contract](../auth-in-api-contract.md). ## Implement The Generated Trait -Protected methods receive `&AuthenticatedUser` before their request payload: +Protected (`WITH_PERMISSIONS`) methods receive `&AuthenticatedUser` before their +request payload; `OPTIONAL_AUTH` methods receive a `ras_jsonrpc_core::Caller` +instead (the request is never rejected for auth reasons): ```rust,ignore struct UserServiceImpl; diff --git a/documentation/src/macros/rest-service.md b/documentation/src/macros/rest-service.md index ba3bf50..3327f73 100644 --- a/documentation/src/macros/rest-service.md +++ b/documentation/src/macros/rest-service.md @@ -79,6 +79,7 @@ rest_service!({ docs_path: "/docs", endpoints: [ GET UNAUTHORIZED users() -> Vec, + GET OPTIONAL_AUTH feed() -> Vec, GET WITH_PERMISSIONS(["user"]) users/{id: String}() -> User, POST WITH_PERMISSIONS(["admin"]) users(CreateUserRequest) -> User, DELETE WITH_PERMISSIONS(["admin"] | ["support", "users:delete"]) users/{id: String}() -> (), @@ -93,6 +94,11 @@ METHOD AUTH_REQUIREMENT path/{param: Type}/segments(RequestType) -> ResponseType ``` Supported methods are `GET`, `POST`, `PUT`, `DELETE`, and `PATCH`. +`AUTH_REQUIREMENT` is one of `UNAUTHORIZED`, `OPTIONAL_AUTH`, or +`WITH_PERMISSIONS([...])` — see +[Auth In The API Contract](../auth-in-api-contract.md). An `OPTIONAL_AUTH` +handler receives a `ras_auth_core::Caller` as its first argument: the route is +public, but identifies the caller when a valid credential is present. ## Implement The Generated Trait diff --git a/documentation/src/tutorial/design-the-contract.md b/documentation/src/tutorial/design-the-contract.md index 252cdb0..ffd9436 100644 --- a/documentation/src/tutorial/design-the-contract.md +++ b/documentation/src/tutorial/design-the-contract.md @@ -37,7 +37,10 @@ DELETE WITH_PERMISSIONS(["admin"] | ["project:owner"]) projects/{project_id: Str `WITH_PERMISSIONS(["a", "b"])` means the authenticated user needs both permissions. `WITH_PERMISSIONS(["a"] | ["b", "c"])` means either the first group or the second group is enough. `WITH_PERMISSIONS([])` means authenticated, with -no extra permission requirement. +no extra permission requirement. `UNAUTHORIZED` is fully public, and +`OPTIONAL_AUTH` is public but hands the handler a `Caller` so it can tailor the +response when a valid credential is present (see +[Auth In The API Contract](../auth-in-api-contract.md)). ## Keep DTOs Boring