From 0fcd9241052489c9bb4e38d157df97c0967481b9 Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Tue, 6 Jan 2026 16:49:49 +0100 Subject: [PATCH] Implement consent workflow Signed-off-by: Till Wegmueller --- .claude/settings.local.json | 4 +- migration/src/lib.rs | 2 + .../src/m20250108_000001_add_consent_table.rs | 77 +++++ src/entities/consent.rs | 19 + src/entities/mod.rs | 2 + src/storage.rs | 130 +++++++ src/web.rs | 324 ++++++++++++++++++ static/consent.html | 311 +++++++++++++++++ tests/integration_test.rs | 131 ++++++- 9 files changed, 981 insertions(+), 19 deletions(-) create mode 100644 migration/src/m20250108_000001_add_consent_table.rs create mode 100644 src/entities/consent.rs create mode 100644 static/consent.html diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b62dcca..24e4570 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -36,7 +36,9 @@ "mcp__context7__query-docs", "Bash(cargo expand:*)", "Bash(cargo tree:*)", - "Bash(cargo metadata:*)" + "Bash(cargo metadata:*)", + "Bash(ls:*)", + "Bash(sqlite3:*)" ], "deny": [], "ask": [] diff --git a/migration/src/lib.rs b/migration/src/lib.rs index d400adf..a6713eb 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -3,6 +3,7 @@ pub use sea_orm_migration::prelude::*; mod m20250101_000001_initial_schema; mod m20250107_000001_add_passkeys; mod m20250107_000002_extend_sessions_users; +mod m20250108_000001_add_consent_table; pub struct Migrator; @@ -13,6 +14,7 @@ impl MigratorTrait for Migrator { Box::new(m20250101_000001_initial_schema::Migration), Box::new(m20250107_000001_add_passkeys::Migration), Box::new(m20250107_000002_extend_sessions_users::Migration), + Box::new(m20250108_000001_add_consent_table::Migration), ] } } diff --git a/migration/src/m20250108_000001_add_consent_table.rs b/migration/src/m20250108_000001_add_consent_table.rs new file mode 100644 index 0000000..c5ffc49 --- /dev/null +++ b/migration/src/m20250108_000001_add_consent_table.rs @@ -0,0 +1,77 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Create consents table + manager + .create_table( + Table::create() + .table(Consent::Table) + .if_not_exists() + .col( + ColumnDef::new(Consent::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col(ColumnDef::new(Consent::ClientId).string().not_null()) + .col(ColumnDef::new(Consent::Subject).string().not_null()) + .col(ColumnDef::new(Consent::Scope).string().not_null()) + .col(ColumnDef::new(Consent::GrantedAt).big_integer().not_null()) + .col(ColumnDef::new(Consent::ExpiresAt).big_integer()) + .col(ColumnDef::new(Consent::Revoked).integer().not_null().default(0)) + .to_owned(), + ) + .await?; + + // Create index on client_id + subject for fast lookups + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_consent_client_subject") + .table(Consent::Table) + .col(Consent::ClientId) + .col(Consent::Subject) + .to_owned(), + ) + .await?; + + // Create index on subject for user consent lookups + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_consent_subject") + .table(Consent::Table) + .col(Consent::Subject) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Consent::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Consent { + Table, + Id, + ClientId, + Subject, + Scope, + GrantedAt, + ExpiresAt, + Revoked, +} diff --git a/src/entities/consent.rs b/src/entities/consent.rs new file mode 100644 index 0000000..4a7a264 --- /dev/null +++ b/src/entities/consent.rs @@ -0,0 +1,19 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "consents")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub client_id: String, + pub subject: String, + pub scope: String, + pub granted_at: i64, + pub expires_at: Option, + pub revoked: i64, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 61175d2..de3d2d9 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -1,6 +1,7 @@ pub mod access_token; pub mod auth_code; pub mod client; +pub mod consent; pub mod job_execution; pub mod passkey; pub mod property; @@ -12,6 +13,7 @@ pub mod webauthn_challenge; pub use access_token::Entity as AccessToken; pub use auth_code::Entity as AuthCode; pub use client::Entity as Client; +pub use consent::Entity as Consent; pub use job_execution::Entity as JobExecution; pub use passkey::Entity as Passkey; pub use property::Entity as Property; diff --git a/src/storage.rs b/src/storage.rs index df289ac..819654b 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1056,6 +1056,136 @@ pub async fn revoke_access_token(db: &DatabaseConnection, token: &str) -> Result Ok(()) } +// ============================================================================ +// Consent Operations +// ============================================================================ + +/// Grant consent for a client to access a user's resources with specific scopes +pub async fn grant_consent( + db: &DatabaseConnection, + client_id: &str, + subject: &str, + scope: &str, + ttl_secs: Option, +) -> Result { + use entities::consent; + + let now = Utc::now().timestamp(); + let expires_at = ttl_secs.map(|ttl| now + ttl); + + let consent = consent::ActiveModel { + id: Default::default(), + client_id: Set(client_id.to_string()), + subject: Set(subject.to_string()), + scope: Set(scope.to_string()), + granted_at: Set(now), + expires_at: Set(expires_at), + revoked: Set(0), + }; + + Ok(consent.insert(db).await?) +} + +/// Check if consent exists for a client and subject with the requested scopes +/// +/// Returns true if: +/// - A non-revoked consent exists +/// - The consent covers all requested scopes +/// - The consent hasn't expired +pub async fn has_consent( + db: &DatabaseConnection, + client_id: &str, + subject: &str, + requested_scopes: &str, +) -> Result { + use entities::consent::{Column, Entity}; + + let now = Utc::now().timestamp(); + + // Get all active consents for this client and subject + let consents = Entity::find() + .filter(Column::ClientId.eq(client_id)) + .filter(Column::Subject.eq(subject)) + .filter(Column::Revoked.eq(0)) + .all(db) + .await?; + + let requested: std::collections::HashSet<_> = requested_scopes.split_whitespace().collect(); + + for consent in consents { + // Check if expired + if let Some(expires_at) = consent.expires_at { + if expires_at < now { + continue; + } + } + + // Check if this consent covers all requested scopes + let granted: std::collections::HashSet<_> = consent.scope.split_whitespace().collect(); + + if requested.is_subset(&granted) { + return Ok(true); + } + } + + Ok(false) +} + +/// Get all consents for a subject +pub async fn get_consents_by_subject( + db: &DatabaseConnection, + subject: &str, +) -> Result, CrabError> { + use entities::consent::{Column, Entity}; + + Ok(Entity::find() + .filter(Column::Subject.eq(subject)) + .filter(Column::Revoked.eq(0)) + .all(db) + .await?) +} + +/// Revoke a specific consent +pub async fn revoke_consent(db: &DatabaseConnection, consent_id: i32) -> Result<(), CrabError> { + use entities::consent::{Column, Entity}; + + if let Some(consent) = Entity::find() + .filter(Column::Id.eq(consent_id)) + .one(db) + .await? + { + let mut active: entities::consent::ActiveModel = consent.into(); + active.revoked = Set(1); + active.update(db).await?; + } + + Ok(()) +} + +/// Revoke all consents for a client and subject +pub async fn revoke_consents_for_client( + db: &DatabaseConnection, + client_id: &str, + subject: &str, +) -> Result<(), CrabError> { + use entities::consent::{Column, Entity}; + + let consents = Entity::find() + .filter(Column::ClientId.eq(client_id)) + .filter(Column::Subject.eq(subject)) + .filter(Column::Revoked.eq(0)) + .all(db) + .await?; + + for consent in consents { + let mut active: entities::consent::ActiveModel = consent.into(); + active.revoked = Set(1); + active.update(db).await?; + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/web.rs b/src/web.rs index 0c47c00..050376c 100644 --- a/src/web.rs +++ b/src/web.rs @@ -118,6 +118,7 @@ pub async fn serve( .route("/login/2fa", get(login_2fa_page)) .route("/logout", get(logout)) .route("/authorize", get(authorize)) + .route("/consent", get(consent_page).post(consent_submit)) .route("/token", post(token)) .route("/revoke", post(token_revoke)) .route("/userinfo", get(userinfo)) @@ -554,6 +555,86 @@ async fn authorize( let scope = q.scope.clone(); let nonce = q.nonce.clone(); + // Check if user has consented to this client/scope combination + // Skip consent check if: + // 1. BARYCENTER_SKIP_CONSENT env var is set (for testing) + // 2. prompt=consent is set (force re-consent) + let skip_consent = std::env::var("BARYCENTER_SKIP_CONSENT").is_ok(); + let prompt_values: Vec<&str> = q + .prompt + .as_ref() + .map(|p| p.split_whitespace().collect()) + .unwrap_or_default(); + let force_consent = prompt_values.contains(&"consent"); + + if !skip_consent + && (force_consent + || !storage::has_consent(&state.db, &q.client_id, &subject, &scope) + .await + .unwrap_or(false)) + { + // No consent exists or force re-consent - redirect to consent page + // If prompt=none, return error instead of redirecting + if prompt_values.contains(&"none") { + return oidc_error_redirect(&q.redirect_uri, q.state.as_deref(), "consent_required") + .into_response(); + } + + // Get client name for the consent page + let client_name = match storage::get_client(&state.db, &q.client_id).await { + Ok(Some(client)) => client.client_name, + _ => None, + }; + + // Build consent URL with all OAuth parameters + let mut consent_params = vec![ + ("client_id", q.client_id.clone()), + ("scope", scope.clone()), + ("redirect_uri", q.redirect_uri.clone()), + ("response_type", q.response_type.clone()), + ("code_challenge", code_challenge.clone()), + ("code_challenge_method", ccm.clone()), + ]; + + if let Some(name) = client_name { + consent_params.push(("client_name", name)); + } + if let Some(s) = &q.state { + consent_params.push(("state", s.clone())); + } + if let Some(n) = &nonce { + consent_params.push(("nonce", n.clone())); + } + if let Some(p) = &q.prompt { + consent_params.push(("prompt", p.clone())); + } + if let Some(d) = &q.display { + consent_params.push(("display", d.clone())); + } + if let Some(ui) = &q.ui_locales { + consent_params.push(("ui_locales", ui.clone())); + } + if let Some(cl) = &q.claims_locales { + consent_params.push(("claims_locales", cl.clone())); + } + if let Some(ma) = &q.max_age { + consent_params.push(("max_age", ma.clone())); + } + if let Some(acr) = &q.acr_values { + consent_params.push(("acr_values", acr.clone())); + } + + let consent_url = url_append_query( + "/consent".to_string(), + &consent_params + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect::>(), + ); + + return Redirect::temporary(&consent_url).into_response(); + } + // Handle different response types match q.response_type.as_str() { "code" => { @@ -711,6 +792,249 @@ async fn authorize( } } +#[derive(Debug, Deserialize)] +struct ConsentQuery { + client_id: String, + client_name: Option, + scope: String, + redirect_uri: String, + response_type: String, + state: Option, + nonce: Option, + code_challenge: Option, + code_challenge_method: Option, + prompt: Option, + display: Option, + ui_locales: Option, + claims_locales: Option, + max_age: Option, + acr_values: Option, +} + +#[derive(Debug, Deserialize)] +struct ConsentForm { + client_id: String, + scope: String, + redirect_uri: String, + response_type: String, + state: Option, + nonce: Option, + code_challenge: Option, + code_challenge_method: Option, + action: String, // "approve" or "deny" +} + +async fn consent_page( + State(state): State, + headers: HeaderMap, + Query(q): Query, +) -> impl IntoResponse { + // Verify user is authenticated + let session = match SessionCookie::from_headers(&headers) { + Some(cookie) => match storage::get_session(&state.db, &cookie.session_id).await { + Ok(Some(sess)) if sess.expires_at > chrono::Utc::now().timestamp() => sess, + _ => { + // Session expired or invalid - redirect to login + let return_to = format!( + "/consent?client_id={}&scope={}&redirect_uri={}&response_type={}&code_challenge={}&code_challenge_method={}{}", + urlencoded(&q.client_id), + urlencoded(&q.scope), + urlencoded(&q.redirect_uri), + urlencoded(&q.response_type), + urlencoded(&q.code_challenge.as_ref().unwrap_or(&String::new())), + urlencoded(&q.code_challenge_method.as_ref().unwrap_or(&String::new())), + q.state.as_ref().map(|s| format!("&state={}", urlencoded(s))).unwrap_or_default() + ); + return Redirect::temporary(&format!("/login?return_to={}", urlencoded(&return_to))) + .into_response(); + } + }, + None => { + // No session cookie - redirect to login + let return_to = format!( + "/consent?client_id={}&scope={}&redirect_uri={}&response_type={}&code_challenge={}&code_challenge_method={}{}", + urlencoded(&q.client_id), + urlencoded(&q.scope), + urlencoded(&q.redirect_uri), + urlencoded(&q.response_type), + urlencoded(&q.code_challenge.as_ref().unwrap_or(&String::new())), + urlencoded(&q.code_challenge_method.as_ref().unwrap_or(&String::new())), + q.state.as_ref().map(|s| format!("&state={}", urlencoded(s))).unwrap_or_default() + ); + return Redirect::temporary(&format!("/login?return_to={}", urlencoded(&return_to))) + .into_response(); + } + }; + + // Get username for display + let username = match storage::get_user_by_subject(&state.db, &session.subject).await { + Ok(Some(user)) => user.username, + _ => "User".to_string(), + }; + + // Get client name + let client_name = if let Some(name) = q.client_name { + name + } else { + match storage::get_client(&state.db, &q.client_id).await { + Ok(Some(client)) => client.client_name.unwrap_or_else(|| q.client_id.clone()), + _ => q.client_id.clone(), + } + }; + + // Serve the static consent.html file with query parameters + match tokio::fs::read_to_string("static/consent.html").await { + Ok(html) => { + // Build query string for the consent page + let mut params = vec![ + ("client_id", q.client_id.clone()), + ("client_name", client_name), + ("scope", q.scope.clone()), + ("redirect_uri", q.redirect_uri.clone()), + ("response_type", q.response_type.clone()), + ("username", username), + ]; + + if let Some(s) = &q.state { + params.push(("state", s.clone())); + } + if let Some(n) = &q.nonce { + params.push(("nonce", n.clone())); + } + if let Some(cc) = &q.code_challenge { + params.push(("code_challenge", cc.clone())); + } + if let Some(ccm) = &q.code_challenge_method { + params.push(("code_challenge_method", ccm.clone())); + } + + // Append query parameters to HTML + let query_string = serde_urlencoded::to_string(¶ms).unwrap_or_default(); + let html_with_params = if html.contains("") { + html.replace("", &format!("", query_string)) + } else { + html + }; + + Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "text/html; charset=utf-8") + .body(Body::from(html_with_params)) + .unwrap() + .into_response() + } + Err(_) => { + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::from("Consent page not found")) + .unwrap() + .into_response() + } + } +} + +async fn consent_submit( + State(state): State, + headers: HeaderMap, + Form(form): Form, +) -> impl IntoResponse { + // Verify user is authenticated + let session = match SessionCookie::from_headers(&headers) { + Some(cookie) => match storage::get_session(&state.db, &cookie.session_id).await { + Ok(Some(sess)) if sess.expires_at > chrono::Utc::now().timestamp() => sess, + _ => { + return oauth_error_redirect( + &form.redirect_uri, + form.state.as_deref(), + "access_denied", + "session expired", + ) + .into_response(); + } + }, + None => { + return oauth_error_redirect( + &form.redirect_uri, + form.state.as_deref(), + "access_denied", + "not authenticated", + ) + .into_response(); + } + }; + + // Handle deny action + if form.action == "deny" { + return oauth_error_redirect( + &form.redirect_uri, + form.state.as_deref(), + "access_denied", + "user denied consent", + ) + .into_response(); + } + + // Handle approve action + if form.action == "approve" { + // Store consent (no expiration - lasts until revoked) + if let Err(e) = storage::grant_consent( + &state.db, + &form.client_id, + &session.subject, + &form.scope, + None, // No expiration + ) + .await + { + tracing::error!("Failed to store consent: {}", e); + return oauth_error_redirect( + &form.redirect_uri, + form.state.as_deref(), + "server_error", + "failed to store consent", + ) + .into_response(); + } + + // Redirect back to /authorize with all parameters to complete the flow + let mut params = vec![ + ("client_id", form.client_id.clone()), + ("redirect_uri", form.redirect_uri.clone()), + ("response_type", form.response_type.clone()), + ("scope", form.scope.clone()), + ]; + + if let Some(s) = &form.state { + params.push(("state", s.clone())); + } + if let Some(n) = &form.nonce { + params.push(("nonce", n.clone())); + } + if let Some(cc) = &form.code_challenge { + params.push(("code_challenge", cc.clone())); + } + if let Some(ccm) = &form.code_challenge_method { + params.push(("code_challenge_method", ccm.clone())); + } + + let authorize_url = url_append_query( + "/authorize".to_string(), + ¶ms.iter().map(|(k, v)| (*k, v.clone())).collect::>(), + ); + + return Redirect::temporary(&authorize_url).into_response(); + } + + // Invalid action + oauth_error_redirect( + &form.redirect_uri, + form.state.as_deref(), + "invalid_request", + "invalid action", + ) + .into_response() +} + // Helper function to build ID token async fn build_id_token( state: &AppState, diff --git a/static/consent.html b/static/consent.html new file mode 100644 index 0000000..6e797d9 --- /dev/null +++ b/static/consent.html @@ -0,0 +1,311 @@ + + + + + + Grant Access - Barycenter + + + + + + + + diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 380e939..db78e43 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -36,6 +36,7 @@ impl TestServer { .env("RUST_LOG", "error") .env("BARYCENTER__SERVER__ALLOW_PUBLIC_REGISTRATION", "true") // Enable registration for tests .env("BARYCENTER__SERVER__PUBLIC_BASE_URL", &base_url) // Set public base URL for WebAuthn + .env("BARYCENTER_SKIP_CONSENT", "1") // Skip consent for tests .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()) .spawn() @@ -352,25 +353,119 @@ fn test_oauth2_authorization_code_flow() { println!("Redirect location: {}", location); - let redirect_url_parsed = if location.starts_with("http") { - url::Url::parse(location).expect("Invalid redirect URL") - } else { - let base_url_for_redirect = url::Url::parse(&redirect_uri).expect("Invalid redirect URI"); - base_url_for_redirect - .join(location) - .expect("Invalid redirect URL") - }; - let code = redirect_url_parsed - .query_pairs() - .find(|(k, _)| k == "code") - .map(|(_, v)| v.to_string()) - .expect("No code in redirect"); + // Check if redirected to consent page + let location_str = location.to_string(); + let (code, returned_state) = if location_str.starts_with("/consent") { + println!("Redirected to consent page, approving..."); - let returned_state = redirect_url_parsed - .query_pairs() - .find(|(k, _)| k == "state") - .map(|(_, v)| v.to_string()) - .expect("No state in redirect"); + // Parse consent URL to extract parameters + let consent_url = if location_str.starts_with("http") { + url::Url::parse(&location_str).expect("Invalid consent URL") + } else { + url::Url::parse(&format!("{}{}", server.base_url(), location_str)) + .expect("Invalid consent URL") + }; + + // Extract form parameters + let mut form_params = std::collections::HashMap::new(); + for (k, v) in consent_url.query_pairs() { + form_params.insert(k.to_string(), v.to_string()); + } + + // Add approval action + form_params.insert("action".to_string(), "approve".to_string()); + + // Submit consent form + let consent_response = authenticated_client + .post(format!("{}/consent", server.base_url())) + .form(&form_params) + .send() + .expect("Failed to submit consent"); + + assert!( + consent_response.status().is_redirection(), + "Expected redirect after consent, got {}", + consent_response.status() + ); + + // Get the redirect location (should be back to /authorize or to the callback) + let consent_location = consent_response + .headers() + .get("location") + .expect("No location header after consent") + .to_str() + .expect("Invalid location header"); + + println!("After consent redirect: {}", consent_location); + + // If redirected back to /authorize, follow it + let final_location = if consent_location.contains("/authorize") { + let reauth_response = authenticated_client + .get(format!("{}{}", server.base_url(), consent_location)) + .send() + .expect("Failed to re-request authorization"); + + assert!( + reauth_response.status().is_redirection(), + "Expected redirect after re-auth, got {}", + reauth_response.status() + ); + + reauth_response + .headers() + .get("location") + .expect("No location header after re-auth") + .to_str() + .expect("Invalid location header") + .to_string() + } else { + consent_location.to_string() + }; + + // Parse the final redirect URL + let redirect_url_parsed = if final_location.starts_with("http") { + url::Url::parse(&final_location).expect("Invalid redirect URL") + } else { + let base = url::Url::parse(&redirect_uri).expect("Invalid redirect URI"); + base.join(&final_location).expect("Invalid redirect URL") + }; + + let code = redirect_url_parsed + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + .expect("No code in redirect"); + + let state = redirect_url_parsed + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()) + .expect("No state in redirect"); + + (code, state) + } else { + // Direct redirect to callback (no consent needed) + let redirect_url_parsed = if location_str.starts_with("http") { + url::Url::parse(&location_str).expect("Invalid redirect URL") + } else { + let base = url::Url::parse(&redirect_uri).expect("Invalid redirect URI"); + base.join(&location_str).expect("Invalid redirect URL") + }; + + let code = redirect_url_parsed + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + .expect("No code in redirect"); + + let state = redirect_url_parsed + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()) + .expect("No state in redirect"); + + (code, state) + }; assert_eq!(returned_state, *csrf_token.secret());