mirror of
https://github.com/CloudNebulaProject/barycenter.git
synced 2026-04-10 05:00:42 +00:00
Implement consent workflow
Signed-off-by: Till Wegmueller <toasterson@gmail.com>
This commit is contained in:
parent
eb9c71a49f
commit
0fcd924105
9 changed files with 981 additions and 19 deletions
|
|
@ -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": []
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
77
migration/src/m20250108_000001_add_consent_table.rs
Normal file
77
migration/src/m20250108_000001_add_consent_table.rs
Normal file
|
|
@ -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,
|
||||
}
|
||||
19
src/entities/consent.rs
Normal file
19
src/entities/consent.rs
Normal file
|
|
@ -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<i64>,
|
||||
pub revoked: i64,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
130
src/storage.rs
130
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<i64>,
|
||||
) -> Result<entities::consent::Model, CrabError> {
|
||||
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<bool, CrabError> {
|
||||
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<Vec<entities::consent::Model>, 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::*;
|
||||
|
|
|
|||
324
src/web.rs
324
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::<Vec<_>>(),
|
||||
);
|
||||
|
||||
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<String>,
|
||||
scope: String,
|
||||
redirect_uri: String,
|
||||
response_type: String,
|
||||
state: Option<String>,
|
||||
nonce: Option<String>,
|
||||
code_challenge: Option<String>,
|
||||
code_challenge_method: Option<String>,
|
||||
prompt: Option<String>,
|
||||
display: Option<String>,
|
||||
ui_locales: Option<String>,
|
||||
claims_locales: Option<String>,
|
||||
max_age: Option<String>,
|
||||
acr_values: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ConsentForm {
|
||||
client_id: String,
|
||||
scope: String,
|
||||
redirect_uri: String,
|
||||
response_type: String,
|
||||
state: Option<String>,
|
||||
nonce: Option<String>,
|
||||
code_challenge: Option<String>,
|
||||
code_challenge_method: Option<String>,
|
||||
action: String, // "approve" or "deny"
|
||||
}
|
||||
|
||||
async fn consent_page(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(q): Query<ConsentQuery>,
|
||||
) -> 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("</body>") {
|
||||
html.replace("</body>", &format!("<script>window.location.search = '?{}';</script></body>", 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<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<ConsentForm>,
|
||||
) -> 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::<Vec<_>>(),
|
||||
);
|
||||
|
||||
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,
|
||||
|
|
|
|||
311
static/consent.html
Normal file
311
static/consent.html
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Grant Access - Barycenter</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.consent-container {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.consent-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.consent-header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.consent-header p {
|
||||
opacity: 0.9;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.consent-body {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.client-info {
|
||||
background: #f7f9fc;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.client-info h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.client-info p {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.scopes-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.scopes-section h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.scope-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.scope-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
background: #f7f9fc;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.scope-item::before {
|
||||
content: "✓";
|
||||
color: #667eea;
|
||||
font-weight: bold;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scope-item span {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.scope-description {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.warning-box p {
|
||||
color: #856404;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 14px 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-approve {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-approve:hover {
|
||||
background: #5568d3;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-deny {
|
||||
background: #f1f3f5;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.btn-deny:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-top: 1px solid #e9ecef;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.user-info strong {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.action-buttons {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="consent-container">
|
||||
<div class="consent-header">
|
||||
<h1>🔐 Grant Access</h1>
|
||||
<p>Authorization Request</p>
|
||||
</div>
|
||||
|
||||
<div class="consent-body">
|
||||
<div class="client-info">
|
||||
<h2 id="clientName"><!-- Client name inserted here --></h2>
|
||||
<p>This application is requesting access to your account.</p>
|
||||
</div>
|
||||
|
||||
<div class="scopes-section">
|
||||
<h3>Requested Permissions:</h3>
|
||||
<ul class="scope-list" id="scopesList">
|
||||
<!-- Scopes inserted dynamically -->
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="warning-box">
|
||||
<p>
|
||||
<strong>Before you continue:</strong> Only approve if you trust this application.
|
||||
You can revoke access at any time from your account settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/consent">
|
||||
<input type="hidden" name="client_id" id="clientId">
|
||||
<input type="hidden" name="scope" id="scope">
|
||||
<input type="hidden" name="state" id="state">
|
||||
<input type="hidden" name="redirect_uri" id="redirectUri">
|
||||
<input type="hidden" name="response_type" id="responseType">
|
||||
<input type="hidden" name="code_challenge" id="codeChallenge">
|
||||
<input type="hidden" name="code_challenge_method" id="codeChallengeMethod">
|
||||
<input type="hidden" name="nonce" id="nonce">
|
||||
|
||||
<div class="action-buttons">
|
||||
<button type="submit" name="action" value="deny" class="btn-deny">
|
||||
Deny Access
|
||||
</button>
|
||||
<button type="submit" name="action" value="approve" class="btn-approve">
|
||||
Approve Access
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="user-info">
|
||||
Logged in as <strong id="username"><!-- Username inserted here --></strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Parse query parameters
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const clientName = params.get('client_name') || 'Unknown Application';
|
||||
const clientId = params.get('client_id') || '';
|
||||
const scope = params.get('scope') || '';
|
||||
const state = params.get('state') || '';
|
||||
const redirectUri = params.get('redirect_uri') || '';
|
||||
const responseType = params.get('response_type') || '';
|
||||
const codeChallenge = params.get('code_challenge') || '';
|
||||
const codeChallengeMethod = params.get('code_challenge_method') || '';
|
||||
const nonce = params.get('nonce') || '';
|
||||
const username = params.get('username') || 'User';
|
||||
|
||||
// Update UI
|
||||
document.getElementById('clientName').textContent = clientName;
|
||||
document.getElementById('clientId').value = clientId;
|
||||
document.getElementById('scope').value = scope;
|
||||
document.getElementById('state').value = state;
|
||||
document.getElementById('redirectUri').value = redirectUri;
|
||||
document.getElementById('responseType').value = responseType;
|
||||
document.getElementById('codeChallenge').value = codeChallenge;
|
||||
document.getElementById('codeChallengeMethod').value = codeChallengeMethod;
|
||||
document.getElementById('nonce').value = nonce;
|
||||
document.getElementById('username').textContent = username;
|
||||
|
||||
// Scope descriptions
|
||||
const scopeDescriptions = {
|
||||
'openid': { name: 'OpenID Connect', desc: 'Basic user identity information' },
|
||||
'profile': { name: 'Profile Information', desc: 'Your name and basic profile' },
|
||||
'email': { name: 'Email Address', desc: 'Your email address' },
|
||||
'phone': { name: 'Phone Number', desc: 'Your phone number' },
|
||||
'address': { name: 'Address', desc: 'Your postal address' },
|
||||
'offline_access': { name: 'Offline Access', desc: 'Access when you\'re not present' },
|
||||
'admin': { name: 'Admin Access', desc: '⚠️ Full administrative privileges' },
|
||||
'payment': { name: 'Payment Access', desc: '⚠️ Initiate payments' },
|
||||
'transfer': { name: 'Transfer Access', desc: '⚠️ Transfer funds' },
|
||||
'delete': { name: 'Delete Access', desc: '⚠️ Delete data' },
|
||||
};
|
||||
|
||||
// Render scopes
|
||||
const scopesList = document.getElementById('scopesList');
|
||||
const scopes = scope.split(' ').filter(s => s);
|
||||
|
||||
scopes.forEach(s => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'scope-item';
|
||||
|
||||
const scopeInfo = scopeDescriptions[s] || { name: s, desc: '' };
|
||||
li.innerHTML = `
|
||||
<span>
|
||||
<strong>${scopeInfo.name}</strong>
|
||||
${scopeInfo.desc ? `<div class="scope-description">${scopeInfo.desc}</div>` : ''}
|
||||
</span>
|
||||
`;
|
||||
scopesList.appendChild(li);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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());
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue