Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

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

3 changes: 3 additions & 0 deletions crates/core/ras-auth-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ http = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

[dev-dependencies]
tokio = { workspace = true }
253 changes: 253 additions & 0 deletions crates/core/ras-auth-core/src/authorize.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
//! Shared request-authorization pipeline for generated services.
//!
//! Every service macro (REST, file, JSON-RPC, bidirectional WebSocket) used
//! to inline its own copy of the credential → CSRF → authenticate →
//! permission-group sequence. These helpers are the single implementation;
//! generated code maps the returned [`AuthorizeError`] to its own protocol's
//! response shape.

use crate::{
AuthError, AuthProvider, AuthTransportConfig, AuthenticatedUser, extract_auth_credential,
validate_csrf_for_credential,
};
use http::HeaderMap;

/// Why [`authorize_request`] rejected a request.
#[derive(Debug)]
pub enum AuthorizeError {
/// No usable credential was found in the request
MissingCredential,
/// Double-submit CSRF validation failed for a cookie credential
CsrfValidationFailed,
/// The credential did not authenticate
AuthenticationFailed(AuthError),
/// The service was built without an auth provider
NoAuthProvider,
/// Authenticated, but no required permission group was satisfied
InsufficientPermissions(AuthError),
}

/// OR-of-AND permission check shared by all generated services.
///
/// `groups` is a disjunction of conjunctions: access is granted when the user
/// holds every permission of at least one group (verified through the
/// provider's `check_permissions`, which custom providers may override). A
/// group list with no non-empty groups — `WITH_PERMISSIONS([])` or any empty
/// inner group — grants access to any authenticated user.
pub fn check_permission_groups<P>(
provider: &P,
user: &AuthenticatedUser,
groups: &[Vec<String>],
) -> Result<(), AuthError>
where
P: AuthProvider + ?Sized,
{
if !groups.iter().any(|group| !group.is_empty()) {
return Ok(());
}

for group in groups {
if group.is_empty() || provider.check_permissions(user, group).is_ok() {
return Ok(());
}
}

Err(AuthError::InsufficientPermissions {
required: groups
.iter()
.find(|group| !group.is_empty())
.cloned()
.unwrap_or_default(),
has: user.permissions.iter().cloned().collect(),
})
}

/// Set-membership variant of [`check_permission_groups`] for contexts without
/// an auth provider (e.g. the bidirectional WebSocket handler, which
/// authorizes against the cached connection user).
pub fn user_satisfies_permission_groups(user: &AuthenticatedUser, groups: &[Vec<String>]) -> bool {
if !groups.iter().any(|group| !group.is_empty()) {
return true;
}

groups
.iter()
.any(|group| !group.is_empty() && group.iter().all(|perm| user.permissions.contains(perm)))
|| groups.iter().any(|group| group.is_empty())
}

/// The credential → CSRF → authenticate → permission pipeline shared by the
/// generated REST and file-service servers.
///
/// `method` is the HTTP method, used to scope CSRF validation to unsafe
/// requests. Errors are ordered so no work happens for unauthenticated
/// callers: the request body has not been touched when this returns `Err`.
pub async fn authorize_request<P>(
method: &str,
headers: &HeaderMap,
auth_transport: &AuthTransportConfig,
auth_provider: Option<&P>,
required_permission_groups: &[Vec<String>],
) -> Result<AuthenticatedUser, AuthorizeError>
where
P: AuthProvider + ?Sized,
{
let credential = extract_auth_credential(headers, auth_transport)
.map_err(|_| AuthorizeError::MissingCredential)?;

validate_csrf_for_credential(method, headers, &credential, auth_transport)
.map_err(|_| AuthorizeError::CsrfValidationFailed)?;

let provider = auth_provider.ok_or(AuthorizeError::NoAuthProvider)?;

let user = provider
.authenticate(credential.token().to_string())
.await
.map_err(AuthorizeError::AuthenticationFailed)?;

check_permission_groups(provider, &user, required_permission_groups)
.map_err(AuthorizeError::InsufficientPermissions)?;

Ok(user)
}

#[cfg(test)]
mod tests {
use super::*;
use crate::AuthFuture;
use std::collections::HashSet;

struct StaticProvider;

impl AuthProvider for StaticProvider {
fn authenticate(&self, token: String) -> AuthFuture<'_> {
Box::pin(async move {
if token == "good" {
Ok(user(&["read", "write"]))
} else {
Err(AuthError::InvalidToken)
}
})
}
}

fn user(perms: &[&str]) -> AuthenticatedUser {
AuthenticatedUser {
user_id: "u".into(),
permissions: perms.iter().map(|p| p.to_string()).collect::<HashSet<_>>(),
metadata: None,
}
}

fn groups(groups: &[&[&str]]) -> Vec<Vec<String>> {
groups
.iter()
.map(|g| g.iter().map(|p| p.to_string()).collect())
.collect()
}

#[test]
fn empty_group_list_is_authenticated_only() {
assert!(check_permission_groups(&StaticProvider, &user(&[]), &[]).is_ok());
assert!(user_satisfies_permission_groups(&user(&[]), &[]));
}

#[test]
fn empty_inner_group_grants_any_authenticated_user() {
let g = groups(&[&["admin"], &[]]);
assert!(check_permission_groups(&StaticProvider, &user(&[]), &g).is_ok());
assert!(user_satisfies_permission_groups(&user(&[]), &g));
}

#[test]
fn and_within_group_or_between_groups() {
let g = groups(&[&["read", "write"], &["admin"]]);

// Satisfies the first group (all permissions present).
assert!(check_permission_groups(&StaticProvider, &user(&["read", "write"]), &g).is_ok());
assert!(user_satisfies_permission_groups(
&user(&["read", "write"]),
&g
));

// Satisfies the second group.
assert!(check_permission_groups(&StaticProvider, &user(&["admin"]), &g).is_ok());
assert!(user_satisfies_permission_groups(&user(&["admin"]), &g));

// Partial match on the first group, none on the second: denied.
let denied = check_permission_groups(&StaticProvider, &user(&["read"]), &g).unwrap_err();
assert!(matches!(
denied,
AuthError::InsufficientPermissions { required, .. } if required == vec!["read", "write"]
));
assert!(!user_satisfies_permission_groups(&user(&["read"]), &g));
}

#[tokio::test]
async fn authorize_request_full_pipeline() {
let transport = AuthTransportConfig::default();
let mut headers = HeaderMap::new();

// No credential
let err = authorize_request(
"POST",
&headers,
&transport,
Some(&StaticProvider),
&groups(&[&["read"]]),
)
.await
.unwrap_err();
assert!(matches!(err, AuthorizeError::MissingCredential));

headers.insert("authorization", "Bearer bad".parse().unwrap());
let err = authorize_request(
"POST",
&headers,
&transport,
Some(&StaticProvider),
&groups(&[&["read"]]),
)
.await
.unwrap_err();
assert!(matches!(err, AuthorizeError::AuthenticationFailed(_)));

headers.insert("authorization", "Bearer good".parse().unwrap());

// Missing provider
let err = authorize_request(
"POST",
&headers,
&transport,
None::<&StaticProvider>,
&groups(&[&["read"]]),
)
.await
.unwrap_err();
assert!(matches!(err, AuthorizeError::NoAuthProvider));

// Insufficient permissions
let err = authorize_request(
"POST",
&headers,
&transport,
Some(&StaticProvider),
&groups(&[&["admin"]]),
)
.await
.unwrap_err();
assert!(matches!(err, AuthorizeError::InsufficientPermissions(_)));

// Success
let user = authorize_request(
"POST",
&headers,
&transport,
Some(&StaticProvider),
&groups(&[&["read", "write"]]),
)
.await
.unwrap();
assert_eq!(user.user_id, "u");
}
}
2 changes: 2 additions & 0 deletions crates/core/ras-auth-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Authentication and authorization traits for JSON-RPC services.

mod authorize;
mod transport;

use std::collections::HashSet;
Expand All @@ -9,6 +10,7 @@ use std::pin::Pin;
use serde::{Deserialize, Serialize};
use thiserror::Error;

pub use authorize::*;
pub use transport::*;

/// Errors that can occur during authentication or authorization.
Expand Down
40 changes: 16 additions & 24 deletions crates/identity/ras-identity-oauth2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ OAuth2 identity provider implementation with PKCE support for Rust Agent Stack.

- **PKCE Support**: Mitigates authorization code interception attacks
- **State Parameter**: CSRF protection using cryptographically random UUIDs
- **OIDC Nonce**: Sent on every authorization request and verified against the id_token
- **id_token Claim Validation**: `iss` (when `issuer` is configured), `aud`, `exp` and `nonce` are checked on callback. The signature is not verified because the token arrives directly from the token endpoint over TLS, which OIDC Core §3.1.3.7 permits for the code flow
- **Session Binding (login-CSRF guard)**: `start_flow_bound` accepts an unguessable per-browser-session value (e.g. a random cookie); the callback payload must carry the identical `binding` or it is rejected, so an attacker cannot trick a victim into completing the attacker's flow
- **Input Validation**: Robust handling of malformed responses
- **Single-Use State**: Callback state is removed after successful retrieval

Expand Down Expand Up @@ -58,38 +61,26 @@ let oauth2_provider = OAuth2Provider::new(config, state_store);
### Integration with Session Service

```rust
use ras_identity_core::{IdentityError, IdentityProvider};
use ras_identity_core::IdentityProvider;
use ras_identity_oauth2::OAuth2Response;
use ras_identity_session::{SessionConfig, SessionError, SessionService};
use ras_identity_session::{SessionConfig, SessionService};

// Register with session service
// Register with session service. The provider is cheap to clone; keep one
// handle for flow initiation and register the other for verification.
let session_config = SessionConfig::new("use-at-least-32-bytes-of-random-secret")?;
let session_service = SessionService::new(session_config)?;

session_service.register_provider(Box::new(oauth2_provider)).await;
session_service.register_provider(Box::new(oauth2_provider.clone())).await;

// Start OAuth2 flow
let start_payload = serde_json::json!({
"type": "StartFlow",
"provider_id": "google"
});

// This will return an error containing the authorization URL
match session_service.begin_session("oauth2", start_payload).await {
Err(SessionError::IdentityError(IdentityError::ProviderError(json))) => {
let response: OAuth2Response = serde_json::from_str(&json)?;
match response {
OAuth2Response::AuthorizationUrl { url, state } => {
// Redirect user to `url`
println!("Redirect to: {}", url);
}
OAuth2Response::Error { message } => {
eprintln!("OAuth2 start-flow failed: {message}");
}
}
match oauth2_provider.start_flow("google", None).await? {
OAuth2Response::AuthorizationUrl { url, state } => {
// Redirect user to `url`
println!("Redirect to: {}", url);
}
OAuth2Response::Error { message } => {
eprintln!("OAuth2 start-flow failed: {message}");
}
Ok(_) => eprintln!("OAuth2 start flow completed without a redirect"),
Err(err) => eprintln!("OAuth2 start flow failed: {err}"),
}

// Handle callback
Expand Down Expand Up @@ -123,6 +114,7 @@ let jwt_token = session_service.begin_session("oauth2", callback_payload).await?
- `authorization_endpoint`: Provider's authorization URL
- `token_endpoint`: Provider's token exchange URL
- `userinfo_endpoint`: Provider's user info URL (optional)
- `issuer`: Expected `iss` claim of id_tokens (e.g. `https://accounts.google.com`); when set, id_tokens with a different issuer are rejected
- `redirect_uri`: Your application's callback URL
- `scopes`: Requested OAuth2 scopes
- `auth_params`: Additional authorization parameters
Expand Down
Loading
Loading