Implement consent workflow

Signed-off-by: Till Wegmueller <toasterson@gmail.com>
This commit is contained in:
Till Wegmueller 2026-01-06 16:49:49 +01:00
parent eb9c71a49f
commit 0fcd924105
No known key found for this signature in database
9 changed files with 981 additions and 19 deletions

View file

@ -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": []

View file

@ -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),
]
}
}

View 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
View 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 {}

View file

@ -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;

View file

@ -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::*;

View file

@ -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(&params).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(),
&params.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
View 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>

View file

@ -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,26 +353,120 @@ 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")
// 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...");
// 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 {
let base_url_for_redirect = url::Url::parse(&redirect_uri).expect("Invalid redirect URI");
base_url_for_redirect
.join(location)
.expect("Invalid redirect URL")
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 returned_state = redirect_url_parsed
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());
let http_client = reqwest::blocking::Client::new();