use std::sync::Arc;
use std::time::Duration;
use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use tracing::{error, info, warn};
use issuerd_core::{AuthMethod, Client, Realm, RealmId, SessionId, User, UserId};
use crate::email::html_escape;
use crate::state::ServerState;
use super::login_api::LoginResponse;
use super::oidc::{flow_cookie_header, has_flow_cookie, PendingAuthData};
use super::required_actions::{error_banner, error_response, page, url_path_segment};
const PENDING_CONSENT_TTL_SECS: u64 = 600;
pub(crate) fn pending_consent_cache_key(realm_id: &RealmId, execution: &str) -> String {
format!("pending_consent:{}:{}", realm_id.0, execution)
}
fn consent_tag() -> String {
"consent".to_string()
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PendingConsentData {
pub pending: PendingAuthData,
pub user_id: String,
pub session_id: String,
pub result_auth_time: chrono::DateTime<chrono::Utc>,
pub auth_time: chrono::DateTime<chrono::Utc>,
pub is_browser_form: bool,
pub sso_resume: bool,
pub auth_method: AuthMethod,
pub method_label: String,
#[serde(default)]
pub error: Option<String>,
#[serde(default = "consent_tag")]
pub _typestate_tag: String,
}
fn continuation_url(realm_name: &str, execution: &str) -> String {
format!("/realms/{}/login/consent/{execution}", url_path_segment(realm_name))
}
pub(crate) async fn consent_needed(
state: &Arc<ServerState>,
realm_id: &RealmId,
client: &Client,
user_id: &UserId,
granted_scopes: &[String],
prompt_consent: bool,
) -> bool {
if !client.consent_required && !prompt_consent {
return false;
}
if prompt_consent {
return true;
}
match state.storage.get_consents(realm_id, user_id).await {
Ok(consents) => match consents.iter().find(|c| c.client_id == client.id) {
Some(grant) => !granted_scopes.iter().all(|s| grant.granted_scopes.contains(s)),
None => true,
},
Err(e) => {
warn!(realm = %realm_id, error = %e, "consent lookup failed; skipping consent screen");
false
}
}
}
pub(crate) async fn begin_consent(
state: &Arc<ServerState>,
realm_name: &str,
entry: PendingConsentData,
) -> Response {
let realm_id = match RealmId::new(entry.pending.realm_id.clone()) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error": "invalid_grant"})),
)
.into_response();
}
};
let execution = issuerd_core::utils::generate_id();
let bytes = serde_json::to_vec(&entry).unwrap();
let _ = state
.cache
.set(
&pending_consent_cache_key(&realm_id, &execution),
bytes,
Some(Duration::from_secs(PENDING_CONSENT_TTL_SECS)),
)
.await;
let url = continuation_url(realm_name, &execution);
let cookie = flow_cookie_header(&execution, state.config.secure_cookies());
if entry.is_browser_form {
let mut resp = axum::response::Redirect::to(&url).into_response();
if let Ok(v) = HeaderValue::from_str(&cookie) {
resp.headers_mut().insert(axum::http::header::SET_COOKIE, v);
}
resp
} else {
(
[(axum::http::header::SET_COOKIE, cookie)],
axum::Json(LoginResponse {
redirect_uri: url,
code: None,
id_token: None,
state: entry.pending.state.clone(),
}),
)
.into_response()
}
}
async fn store_entry(
state: &Arc<ServerState>,
realm_id: &RealmId,
execution: &str,
entry: &PendingConsentData,
) {
let bytes = serde_json::to_vec(entry).unwrap();
let _ = state
.cache
.set(
&pending_consent_cache_key(realm_id, execution),
bytes,
Some(Duration::from_secs(PENDING_CONSENT_TTL_SECS)),
)
.await;
}
#[allow(clippy::result_large_err)]
async fn load_consent_context(
state: &Arc<ServerState>,
realm_name: &str,
execution: &str,
) -> Result<(Realm, PendingConsentData, Client), Response> {
let realm = match state.resolve_realm(realm_name).await {
Ok(Some(r)) => r,
_ => {
return Err(error_response(StatusCode::NOT_FOUND, "realm not found"));
}
};
let key = pending_consent_cache_key(&realm.id, execution);
let entry: PendingConsentData = match state.cache.get(&key).await {
Ok(Some(bytes)) => match serde_json::from_slice(&bytes) {
Ok(e) => e,
Err(_) => {
return Err(error_response(StatusCode::BAD_REQUEST, "invalid consent state"));
}
},
_ => {
return Err(error_response(
StatusCode::BAD_REQUEST,
"consent request expired — please restart the login",
));
}
};
let identifier = match issuerd_core::ClientIdentifier::new(&entry.pending.client_id) {
Ok(id) => id,
Err(_) => {
return Err(error_response(StatusCode::BAD_REQUEST, "invalid client"));
}
};
let client = match state.storage.get_client_by_client_id(&realm.id, &identifier).await {
Ok(Some(c)) => c,
_ => {
return Err(error_response(StatusCode::BAD_REQUEST, "unknown client"));
}
};
Ok((realm, entry, client))
}
pub async fn consent_page(
State(state): State<Arc<ServerState>>,
Path((realm_name, execution)): Path<(String, String)>,
) -> Response {
let (realm, entry, client) = match load_consent_context(&state, &realm_name, &execution).await {
Ok(v) => v,
Err(resp) => return resp,
};
render_consent_page(&state, &realm, &realm_name, &execution, &entry, &client).await
}
async fn render_consent_page(
state: &Arc<ServerState>,
realm: &Realm,
realm_name: &str,
execution: &str,
entry: &PendingConsentData,
client: &Client,
) -> Response {
let mut items = String::new();
for scope in &entry.pending.scope {
let description =
match state.storage.get_client_scope_by_name(&client.realm_id, scope).await {
Ok(Some(cs)) => cs.description,
_ => None,
};
let label = match description.as_deref().map(str::trim) {
Some(d) if !d.is_empty() => {
format!("{} — {}", html_escape(scope), html_escape(d))
}
_ => html_escape(scope),
};
items.push_str(&format!("<li>{label}</li>"));
}
let client_display = client
.name
.as_deref()
.map(str::trim)
.filter(|n| !n.is_empty())
.unwrap_or_else(|| client.client_id.as_ref());
let locale = entry
.pending
.locale
.clone()
.unwrap_or_else(|| crate::i18n::resolve_locale(realm, &[], None));
let bundle = crate::i18n::message_bundle(
Some(state.config.themes.dir.as_path()),
realm.login_theme.as_ref().map(|t| t.as_str()),
&locale,
);
let title = crate::i18n::msg(&bundle, "consent.title", "Grant access");
let lead = crate::i18n::msg(&bundle, "consent.lead", "requests permission to:");
let allow = crate::i18n::msg(&bundle, "consent.allow", "Allow");
let deny = crate::i18n::msg(&bundle, "consent.deny", "Deny");
let banner = error_banner(entry.error.as_deref());
let action = continuation_url(realm_name, execution);
let body = format!(
"<h1>{title}</h1>\
<p><strong>{}</strong> {}</p>\
<ul>{items}</ul>\
{banner}\
<form method=\"post\" action=\"{action}\">\
<button type=\"submit\" name=\"decision\" value=\"allow\">{allow}</button>\
<button type=\"submit\" name=\"decision\" value=\"deny\" class=\"secondary\">{deny}</button>\
</form>",
html_escape(client_display),
html_escape(&lead),
);
page(&title, &body).into_response()
}
pub async fn consent_submit(
State(state): State<Arc<ServerState>>,
Path((realm_name, execution)): Path<(String, String)>,
headers: HeaderMap,
body: Bytes,
) -> Response {
if !has_flow_cookie(&headers, &execution) {
return error_response(StatusCode::FORBIDDEN, "missing flow correlation cookie");
}
let realm = match state.resolve_realm(&realm_name).await {
Ok(Some(r)) => r,
_ => return error_response(StatusCode::NOT_FOUND, "realm not found"),
};
let realm_id = realm.id.clone();
let key = pending_consent_cache_key(&realm_id, &execution);
let mut entry: PendingConsentData = match state.cache.get_and_delete(&key).await {
Ok(Some(bytes)) => match serde_json::from_slice(&bytes) {
Ok(e) => e,
Err(_) => {
return error_response(StatusCode::BAD_REQUEST, "invalid consent state");
}
},
_ => {
return error_response(
StatusCode::BAD_REQUEST,
"consent request expired — please restart the login",
);
}
};
let form: std::collections::HashMap<String, String> =
serde_urlencoded::from_bytes(&body).unwrap_or_default();
if form.get("decision").map(String::as_str) != Some("allow") {
info!(realm = %realm_id, client_id = %entry.pending.client_id, "user denied consent");
return super::auth_response::oauth_error_redirect(
&state,
&entry.pending.redirect_uri,
&issuerd_core::IssuerdError::AccessDenied,
entry.pending.state.as_deref(),
&super::auth_response::ResponsePackaging::from_pending(&realm, &entry.pending),
)
.await;
}
let (_user, client) = match load_user_and_client(&state, &realm_id, &entry).await {
Ok(v) => v,
Err(resp) => return resp,
};
let user_id = match UserId::new(entry.user_id.clone()) {
Ok(id) => id,
Err(_) => return error_response(StatusCode::BAD_REQUEST, "invalid user"),
};
let now = chrono::Utc::now();
let consent = issuerd_core::Consent {
client_id: client.id.clone(),
user_id,
granted_scopes: issuerd_core::Scope::parse(&entry.pending.scope.join(" ")),
granted_realm_roles: Vec::new(),
granted_client_roles: std::collections::HashMap::new(),
created_at: now,
last_updated_at: now,
};
if let Err(e) = state.storage.create_consent(&realm_id, &consent).await {
error!(realm = %realm_id, error = %e, "failed to persist consent grant");
entry.error = Some("could not record your choice — please try again".to_string());
store_entry(&state, &realm_id, &execution, &entry).await;
return axum::response::Redirect::to(&continuation_url(&realm_name, &execution))
.into_response();
}
info!(realm = %realm_id, client_id = %client.client_id, user_id = %consent.user_id, "user granted consent");
resume_after_consent(&state, &realm, entry).await
}
async fn resume_after_consent(
state: &Arc<ServerState>,
realm: &Realm,
mut entry: PendingConsentData,
) -> Response {
entry.pending.prompt_consent = false;
let realm_id = realm.id.clone();
let (user_id, session_id) =
match (UserId::new(entry.user_id.clone()), SessionId::new(entry.session_id.clone())) {
(Ok(u), Ok(s)) => (u, s),
_ => return error_response(StatusCode::BAD_REQUEST, "invalid consent state"),
};
if !entry.sso_resume {
return super::login_api::complete_login_with_method(
state,
&realm_id,
&entry.pending,
&user_id,
&session_id,
entry.result_auth_time,
entry.is_browser_form,
entry.auth_method,
&entry.method_label,
)
.await;
}
let (user, client) = match load_user_and_client(state, &realm_id, &entry).await {
Ok(v) => v,
Err(resp) => return resp,
};
let existing = state.storage.get_user_session(&realm_id, &session_id).await.ok().flatten();
super::oidc::finish_sso_login(
state,
realm,
&client,
&user,
&entry.pending,
existing,
&session_id,
entry.result_auth_time,
entry.auth_time,
entry.pending.ip_address.unwrap_or("127.0.0.1".parse().unwrap()),
false,
)
.await
}
#[allow(clippy::result_large_err)]
async fn load_user_and_client(
state: &Arc<ServerState>,
realm_id: &RealmId,
entry: &PendingConsentData,
) -> Result<(User, Client), Response> {
let user_id = match UserId::new(entry.user_id.clone()) {
Ok(id) => id,
Err(_) => return Err(error_response(StatusCode::BAD_REQUEST, "invalid user")),
};
let user = match state.storage.get_user(realm_id, &user_id).await {
Ok(Some(u)) => u,
_ => return Err(error_response(StatusCode::BAD_REQUEST, "invalid user")),
};
let identifier = match issuerd_core::ClientIdentifier::new(&entry.pending.client_id) {
Ok(id) => id,
Err(_) => return Err(error_response(StatusCode::BAD_REQUEST, "invalid client")),
};
let client = match state.storage.get_client_by_client_id(realm_id, &identifier).await {
Ok(Some(c)) => c,
_ => return Err(error_response(StatusCode::BAD_REQUEST, "unknown client")),
};
Ok((user, client))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ServerConfig;
use issuerd_core::{
ClientAuthenticatorType, ClientId, ClientIdentifier, ClientProtocol, Consent, RoleName,
Scope,
};
use std::collections::HashMap;
async fn setup() -> (Arc<ServerState>, RealmId, UserId) {
let state = Arc::new(ServerState::from_config(&ServerConfig::default()).await.unwrap());
(state, RealmId::new("master").unwrap(), UserId::new("admin").unwrap())
}
async fn admin_cli(state: &Arc<ServerState>, realm_id: &RealmId) -> Client {
state
.storage
.get_client_by_client_id(realm_id, &ClientIdentifier::new("admin-cli").unwrap())
.await
.unwrap()
.unwrap()
}
async fn consenting_client(state: &Arc<ServerState>, realm_id: &RealmId) -> Client {
let client = Client {
id: ClientId::new("consent-client-uuid").unwrap(),
realm_id: realm_id.clone(),
client_id: ClientIdentifier::new("consent-client").unwrap(),
name: None,
description: None,
enabled: true,
protocol: ClientProtocol::OpenIdConnect,
public_client: true,
bearer_only: false,
client_authenticator_type: ClientAuthenticatorType::ClientSecret,
secret: None,
redirect_uris: vec![],
web_origins: vec![],
default_scopes: Scope::parse("openid"),
optional_scopes: Scope::empty(),
consent_required: true,
full_scope_allowed: true,
service_accounts_enabled: false,
protocol_mappers: Vec::new(),
scope_mappings: Default::default(),
attributes: HashMap::new(),
};
state.storage.create_client(realm_id, &client).await.unwrap();
client
}
#[tokio::test]
async fn no_consent_when_client_does_not_require_it() {
let (state, realm_id, user_id) = setup().await;
let client = admin_cli(&state, &realm_id).await;
assert!(
!consent_needed(&state, &realm_id, &client, &user_id, &["openid".to_string()], false)
.await,
"a client without consent_required must never pause for consent"
);
}
#[tokio::test]
async fn prompt_consent_forces_the_screen() {
let (state, realm_id, user_id) = setup().await;
let client = admin_cli(&state, &realm_id).await;
assert!(
consent_needed(&state, &realm_id, &client, &user_id, &["openid".to_string()], true)
.await,
"prompt=consent forces the page regardless of client settings or stored grants"
);
}
#[tokio::test]
async fn consent_required_client_without_grant_needs_consent() {
let (state, realm_id, user_id) = setup().await;
let client = consenting_client(&state, &realm_id).await;
assert!(
consent_needed(&state, &realm_id, &client, &user_id, &["openid".to_string()], false)
.await,
"no stored grant -> consent page"
);
}
#[tokio::test]
async fn stored_grant_skips_consent_only_when_covering_requested_scopes() {
let (state, realm_id, user_id) = setup().await;
let client = consenting_client(&state, &realm_id).await;
let consent = Consent {
client_id: client.id.clone(),
user_id: user_id.clone(),
granted_scopes: Scope::parse("openid profile"),
granted_realm_roles: Vec::<RoleName>::new(),
granted_client_roles: HashMap::new(),
created_at: chrono::Utc::now(),
last_updated_at: chrono::Utc::now(),
};
state.storage.create_consent(&realm_id, &consent).await.unwrap();
assert!(
!consent_needed(&state, &realm_id, &client, &user_id, &["openid".to_string()], false)
.await,
"a stored grant covering the requested scopes skips the page"
);
assert!(
consent_needed(
&state,
&realm_id,
&client,
&user_id,
&["openid".to_string(), "email".to_string()],
false
)
.await,
"a scope outside the stored grant re-triggers the page"
);
}
fn test_pending_auth(client_id: &str, redirect_uri: &str, scope: &[&str]) -> PendingAuthData {
PendingAuthData {
realm_id: "master".to_string(),
client_id: client_id.to_string(),
redirect_uri: redirect_uri.to_string(),
scope: scope.iter().map(|s| s.to_string()).collect(),
state: Some("state-xyz".to_string()),
nonce: None,
response_type: "code".to_string(),
code_challenge: None,
code_challenge_method: None,
ip_address: Some("127.0.0.1".parse().unwrap()),
execution_id: issuerd_core::FlowStageId::new("username-password").unwrap(),
acr_values: vec![],
claims: None,
_typestate_tag: "challenged".to_string(),
attempt_count: 0,
remember_me: false,
user_id: None,
prompt_consent: true,
locale: None,
response_mode: None,
authorization_details: None,
}
}
fn consent_entry(pending: PendingAuthData) -> PendingConsentData {
PendingConsentData {
pending,
user_id: "admin".to_string(),
session_id: issuerd_core::utils::generate_id(),
result_auth_time: chrono::Utc::now() - chrono::Duration::hours(1),
auth_time: chrono::Utc::now(),
is_browser_form: true,
sso_resume: false,
auth_method: AuthMethod::Spnego,
method_label: "password".to_string(),
error: None,
_typestate_tag: consent_tag(),
}
}
async fn seed_entry(
state: &Arc<ServerState>,
execution: &str,
entry: &PendingConsentData,
) -> String {
let key = pending_consent_cache_key(&RealmId::new("master").unwrap(), execution);
state
.cache
.set(&key, serde_json::to_vec(entry).unwrap(), Some(Duration::from_secs(600)))
.await
.unwrap();
key
}
fn cookie_headers(state: &Arc<ServerState>, execution: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
let cookie = flow_cookie_header(execution, state.config.secure_cookies());
headers.insert(axum::http::header::COOKIE, cookie.parse().unwrap());
headers
}
fn named_client_model(realm_id: &RealmId) -> Client {
Client {
id: ClientId::new("named-client-uuid").unwrap(),
realm_id: realm_id.clone(),
client_id: ClientIdentifier::new("named-client").unwrap(),
name: Some(issuerd_core::DisplayName::new("My App").unwrap()),
description: None,
enabled: true,
protocol: ClientProtocol::OpenIdConnect,
public_client: true,
bearer_only: false,
client_authenticator_type: ClientAuthenticatorType::ClientSecret,
secret: None,
redirect_uris: vec![issuerd_core::RedirectUri::new(
"https://app.example.com/callback".to_string(),
)
.unwrap()],
web_origins: vec![],
default_scopes: Scope::parse("openid"),
optional_scopes: Scope::empty(),
consent_required: true,
full_scope_allowed: true,
service_accounts_enabled: false,
protocol_mappers: Vec::new(),
scope_mappings: Default::default(),
attributes: HashMap::new(),
}
}
async fn named_client(state: &Arc<ServerState>, realm_id: &RealmId) -> Client {
let client = named_client_model(realm_id);
state.storage.create_client(realm_id, &client).await.unwrap();
client
}
fn admin_user_model(realm_id: &RealmId) -> User {
User {
id: UserId::new("admin").unwrap(),
realm_id: realm_id.clone(),
username: issuerd_core::Username::new("admin").unwrap(),
email: None,
email_verified: false,
first_name: None,
last_name: None,
enabled: true,
federation_link: None,
attributes: HashMap::new(),
required_actions: vec![],
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
async fn seed_scope(
state: &Arc<ServerState>,
realm_id: &RealmId,
name: &str,
description: Option<&str>,
) {
let scope = issuerd_core::ClientScope {
id: issuerd_core::ClientScopeId::new(issuerd_core::utils::generate_id()).unwrap(),
realm_id: realm_id.clone(),
name: name.to_string(),
description: description.map(str::to_string),
protocol: ClientProtocol::OpenIdConnect,
attributes: HashMap::new(),
protocol_mappers: Vec::new(),
scope_mappings: Default::default(),
};
state.storage.create_client_scope(realm_id, &scope).await.unwrap();
}
#[test]
fn pending_consent_cache_key_scopes_realm_and_execution() {
let realm = RealmId::new("master").unwrap();
assert_eq!(pending_consent_cache_key(&realm, "exec-1"), "pending_consent:master:exec-1");
assert_ne!(
pending_consent_cache_key(&realm, "exec-1"),
pending_consent_cache_key(&RealmId::new("other").unwrap(), "exec-1")
);
assert_ne!(
pending_consent_cache_key(&realm, "exec-1"),
pending_consent_cache_key(&realm, "exec-2")
);
}
#[test]
fn consent_tag_is_the_stable_typestate_marker() {
assert_eq!(consent_tag(), "consent");
let entry = consent_entry(test_pending_auth(
"named-client",
"https://app.example.com/callback",
&["openid"],
));
let mut json = serde_json::to_value(&entry).unwrap();
json.as_object_mut().unwrap().remove("_typestate_tag");
let parsed: PendingConsentData = serde_json::from_value(json).unwrap();
assert_eq!(parsed._typestate_tag, "consent");
}
#[test]
fn continuation_url_points_at_the_consent_endpoint() {
assert_eq!(continuation_url("master", "exec-1"), "/realms/master/login/consent/exec-1");
}
#[tokio::test]
async fn begin_consent_redirects_browser_and_stores_entry() {
let (state, realm_id, _user_id) = setup().await;
let entry = consent_entry(test_pending_auth(
"named-client",
"https://app.example.com/callback",
&["openid"],
));
let resp = begin_consent(&state, "master", entry.clone()).await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let location = resp.headers()["location"].to_str().unwrap();
let prefix = "/realms/master/login/consent/";
assert!(location.starts_with(prefix), "location: {location}");
let execution = &location[prefix.len()..];
let set_cookie = resp.headers()["set-cookie"].to_str().unwrap();
assert!(
set_cookie.contains(&format!("issuerd_flow_{execution}=1")),
"cookie: {set_cookie}"
);
let bytes = state
.cache
.get(&pending_consent_cache_key(&realm_id, execution))
.await
.unwrap()
.expect("pending consent entry");
let stored: PendingConsentData = serde_json::from_slice(&bytes).unwrap();
assert_eq!(stored.user_id, entry.user_id);
assert_eq!(stored.pending.client_id, "named-client");
}
#[tokio::test]
async fn consent_page_renders_client_name_and_scope_labels() {
let (state, realm_id, _user_id) = setup().await;
named_client(&state, &realm_id).await;
seed_scope(&state, &realm_id, "read:files", Some("Read your files")).await;
seed_scope(&state, &realm_id, "reports", Some(" ")).await;
let execution = "exec-page";
let entry = consent_entry(test_pending_auth(
"named-client",
"https://app.example.com/callback",
&["openid", "read:files", "reports"],
));
seed_entry(&state, execution, &entry).await;
let resp =
consent_page(State(state.clone()), Path(("master".to_string(), execution.to_string())))
.await;
assert_eq!(resp.status(), StatusCode::OK);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(body.contains("<strong>My App</strong>"), "body: {body}");
assert!(body.contains("<li>read:files — Read your files</li>"), "body: {body}");
assert!(body.contains("<li>reports</li>"), "body: {body}");
assert!(!body.contains("reports —"), "body: {body}");
assert!(
body.contains("action=\"/realms/master/login/consent/exec-page\""),
"body: {body}"
);
assert!(body.contains("Grant access"), "body: {body}");
}
#[tokio::test]
async fn consent_submit_without_flow_cookie_is_forbidden() {
let (state, _realm_id, _user_id) = setup().await;
let resp = consent_submit(
State(state),
Path(("master".to_string(), "exec-1".to_string())),
HeaderMap::new(),
Bytes::from_static(b"decision=allow"),
)
.await;
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn consent_submit_denial_redirects_access_denied_and_consumes_entry() {
let (state, realm_id, user_id) = setup().await;
named_client(&state, &realm_id).await;
let execution = "exec-deny";
let entry = consent_entry(test_pending_auth(
"named-client",
"https://app.example.com/callback",
&["openid"],
));
let key = seed_entry(&state, execution, &entry).await;
let resp = consent_submit(
State(state.clone()),
Path(("master".to_string(), execution.to_string())),
cookie_headers(&state, execution),
Bytes::from_static(b"decision=deny"),
)
.await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let location = resp.headers()["location"].to_str().unwrap();
assert!(
location.starts_with("https://app.example.com/callback?"),
"location: {location}"
);
assert!(location.contains("error=access_denied"), "location: {location}");
assert!(location.contains("state=state-xyz"), "location: {location}");
assert!(state.storage.get_consents(&realm_id, &user_id).await.unwrap().is_empty());
assert!(state.cache.get(&key).await.unwrap().is_none());
}
#[tokio::test]
async fn consent_submit_approval_persists_grant_and_resumes_login() {
let (state, realm_id, user_id) = setup().await;
let client = named_client(&state, &realm_id).await;
let execution = "exec-allow";
let entry = consent_entry(test_pending_auth(
"named-client",
"https://app.example.com/callback",
&["openid"],
));
let session_id = entry.session_id.clone();
let result_auth_time = entry.result_auth_time;
let key = seed_entry(&state, execution, &entry).await;
let resp = consent_submit(
State(state.clone()),
Path(("master".to_string(), execution.to_string())),
cookie_headers(&state, execution),
Bytes::from_static(b"decision=allow"),
)
.await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
let location = resp.headers()["location"].to_str().unwrap();
assert!(
location.starts_with("https://app.example.com/callback?"),
"location: {location}"
);
assert!(location.contains("code="), "location: {location}");
assert!(location.contains("state=state-xyz"), "location: {location}");
let consents = state.storage.get_consents(&realm_id, &user_id).await.unwrap();
assert_eq!(consents.len(), 1);
assert_eq!(consents[0].client_id, client.id);
assert!(consents[0].granted_scopes.contains("openid"));
let session = state
.storage
.get_user_session(&realm_id, &SessionId::new(session_id).unwrap())
.await
.unwrap()
.expect("resumed session");
assert_eq!(session.auth_method, AuthMethod::Spnego);
assert_eq!(session.auth_time, result_auth_time);
assert!(state.cache.get(&key).await.unwrap().is_none());
}
#[tokio::test]
async fn consent_submit_restores_entry_and_redirects_when_grant_persist_fails() {
let realm_id = RealmId::new("master").unwrap();
let realm = Realm {
id: realm_id.clone(),
name: issuerd_core::RealmName::new("master").unwrap(),
display_name: None,
enabled: true,
..Default::default()
};
let user = admin_user_model(&realm_id);
let client = named_client_model(&realm_id);
let mut storage = issuerd_core::MockStorage::new();
storage
.expect_get_realm_by_name()
.returning(move |name| Ok((name == "master").then(|| realm.clone())));
storage.expect_get_user().returning(move |_, _| Ok(Some(user.clone())));
storage
.expect_get_client_by_client_id()
.returning(move |_, _| Ok(Some(client.clone())));
storage
.expect_create_consent()
.returning(|_, _| Err(issuerd_core::IssuerdError::ServerError("db down".to_string())));
let mut state = ServerState::from_config(&ServerConfig::default()).await.unwrap();
state.storage = Arc::new(storage);
let state = Arc::new(state);
let execution = "exec-persist-fail";
let entry = consent_entry(test_pending_auth(
"named-client",
"https://app.example.com/callback",
&["openid"],
));
let key = seed_entry(&state, execution, &entry).await;
let resp = consent_submit(
State(state.clone()),
Path(("master".to_string(), execution.to_string())),
cookie_headers(&state, execution),
Bytes::from_static(b"decision=allow"),
)
.await;
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(
resp.headers()["location"].to_str().unwrap(),
format!("/realms/master/login/consent/{execution}")
);
let bytes = state.cache.get(&key).await.unwrap().expect("entry restored after failure");
let restored: PendingConsentData = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
restored.error.as_deref(),
Some("could not record your choice — please try again")
);
}
}