Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(()) -> (),
]
Expand Down Expand Up @@ -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}() -> (),
Expand Down
35 changes: 35 additions & 0 deletions crates/core/ras-auth-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<P: AuthProvider + ?Sized>(
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
Expand Down
74 changes: 72 additions & 2 deletions crates/core/ras-auth-core/src/authorize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<P>(
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::*;
Expand Down Expand Up @@ -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");
}
}
61 changes: 61 additions & 0 deletions crates/core/ras-auth-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,67 @@ pub struct AuthenticatedUser {
pub metadata: Option<serde_json::Value>,
}

/// 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<Option<AuthenticatedUser>> 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<AuthenticatedUser>`.
fn from(user: Option<AuthenticatedUser>) -> 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<AuthenticatedUser> {
match self {
Caller::Authenticated(user) => Some(user),
Caller::Anonymous => None,
}
}
}

/// Result type for authentication operations.
pub type AuthResult<T = AuthenticatedUser> = Result<T, AuthError>;

Expand Down
6 changes: 6 additions & 0 deletions crates/rest/ras-file-macro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
]
});
```
Expand Down
7 changes: 7 additions & 0 deletions crates/rest/ras-file-macro/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions crates/rest/ras-file-macro/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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),*],
Expand All @@ -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),*],
Expand Down Expand Up @@ -131,6 +135,7 @@ pub fn generate_openapi_code(
operation: String,
path: String,
auth_required: bool,
auth_optional: bool,
permissions: Vec<String>,
permission_groups: Vec<Vec<String>>,
path_params: Vec<(String, String)>,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -495,14 +503,14 @@ fn part_info_tokens(part: &UploadPart, part_info_name: &syn::Ident) -> TokenStre

fn permissions_for_openapi(auth: &AuthRequirement) -> Vec<String> {
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<Vec<String>> {
match auth {
AuthRequirement::Unauthorized => vec![],
AuthRequirement::Unauthorized | AuthRequirement::OptionalAuth => vec![],
AuthRequirement::WithPermissions(groups) => groups.clone(),
}
}
Expand Down
6 changes: 5 additions & 1 deletion crates/rest/ras-file-macro/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>>),
}

Expand Down Expand Up @@ -460,6 +463,7 @@ fn parse_auth(input: ParseStream) -> Result<AuthRequirement> {
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);
Expand Down Expand Up @@ -487,7 +491,7 @@ fn parse_auth(input: ParseStream) -> Result<AuthRequirement> {
}
_ => Err(Error::new(
auth_ident.span(),
"Expected UNAUTHORIZED or WITH_PERMISSIONS",
"Expected UNAUTHORIZED, OPTIONAL_AUTH, or WITH_PERMISSIONS",
)),
}
}
Expand Down
Loading
Loading