use std::collections::HashMap;
use std::sync::Arc;
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use issuerd_core::{
AccessTokenClaims, Client, ClientIdentifier, EventType, Realm, RealmId, SessionId, User, UserId,
};
use issuerd_protocol::token::{GrantType, TokenRequest, TOKEN_TYPE_ACCESS_TOKEN};
use tracing::{debug, info, warn};
use super::oidc::{
emit_oidc_event, error_response, persist_session, resolve_token_user, session_invalidated,
token_response, TokenResponse,
};
use crate::state::ServerState;
pub(crate) const CLIENT_TOKEN_EXCHANGE_ATTRIBUTE: &str = "token.exchange.enabled";
pub(crate) const REQUIRE_REQUESTER_IN_SUBJECT_AUD_ATTRIBUTE: &str =
"require_requester_in_subject_aud";
const IMPERSONATION_ROLE: &str = "impersonation";
pub(crate) async fn token_exchange_grant(
state: &Arc<ServerState>,
realm: &Realm,
client: &Client,
token_req: &TokenRequest,
ip: &std::net::IpAddr,
dpop_jkt: Option<&str>,
) -> Response {
let realm_id = &realm.id;
let subject_token = token_req.subject_token.as_deref().unwrap_or("");
if matches!(state.cache.get(&format!("revoked:{subject_token}")).await, Ok(Some(_))) {
warn!(realm = %realm_id, client_id = %client.client_id, "token exchange: revoked subject token presented");
return exchange_error(state, realm_id, client, ip, "invalid_grant").await;
}
let subject_claims = match state.token_service.validate_access_token(subject_token) {
Ok(v) => v.claims,
Err(e) => {
debug!(realm = %realm_id, client_id = %client.client_id, error = %e, "token exchange: subject token validation failed");
return exchange_error(state, realm_id, client, ip, "invalid_grant").await;
}
};
if issuerd_core::typestate::extract_realm_from_issuer(subject_claims.iss.as_str())
!= Some(realm.name.as_str())
{
warn!(realm = %realm_id, client_id = %client.client_id, iss = %subject_claims.iss.as_str(), "token exchange: subject token from another realm");
return exchange_error(state, realm_id, client, ip, "invalid_grant").await;
}
if realm.not_before > 0 && subject_claims.iat < realm.not_before {
return exchange_error(state, realm_id, client, ip, "invalid_grant").await;
}
if session_invalidated(state, &subject_claims).await {
return exchange_error(state, realm_id, client, ip, "invalid_grant").await;
}
let subject_user = match resolve_token_user(state, realm_id, &subject_claims, None).await {
Some(u) if u.enabled => u,
_ => {
warn!(realm = %realm_id, client_id = %client.client_id, sub = %subject_claims.sub, "token exchange: subject user missing or disabled");
return exchange_error(state, realm_id, client, ip, "invalid_grant").await;
}
};
if require_requester_in_subject_aud(realm, client)
&& !subject_token_audience_contains(subject_token, client.client_id.as_ref())
{
warn!(realm = %realm_id, client_id = %client.client_id, "token exchange: requesting client not in the subject token's audience (require_requester_in_subject_aud)");
return exchange_error(state, realm_id, client, ip, "invalid_grant").await;
}
if let Some(cnf) = &subject_claims.cnf {
if dpop_jkt != Some(cnf.jkt.as_str()) {
warn!(realm = %realm_id, client_id = %client.client_id, "token exchange: subject token is DPoP-bound but the request proof is missing or carries a different key");
return exchange_error(state, realm_id, client, ip, "invalid_grant").await;
}
}
if let Some(requested_subject) = token_req.requested_subject.as_deref() {
impersonation_exchange(
state,
realm,
client,
token_req,
ip,
&subject_claims,
&subject_user,
requested_subject,
dpop_jkt,
)
.await
} else {
internal_exchange(
state,
realm,
client,
token_req,
ip,
&subject_claims,
&subject_user,
dpop_jkt,
)
.await
}
}
#[allow(clippy::too_many_arguments)]
async fn internal_exchange(
state: &Arc<ServerState>,
realm: &Realm,
client: &Client,
token_req: &TokenRequest,
ip: &std::net::IpAddr,
subject_claims: &AccessTokenClaims,
subject_user: &User,
dpop_jkt: Option<&str>,
) -> Response {
let realm_id = &realm.id;
let target_client =
match resolve_target_client(state, realm_id, token_req.audience.as_deref()).await {
Some(c) => c,
None => {
return exchange_error(state, realm_id, client, ip, "invalid_grant").await;
}
};
let target_client = target_client.unwrap_or_else(|| client.clone());
let permitted = target_client
.attributes
.get(CLIENT_TOKEN_EXCHANGE_ATTRIBUTE)
.is_some_and(|v| v == "true");
if !permitted {
warn!(realm = %realm_id, client_id = %client.client_id, target = %target_client.client_id, "token exchange: target client has not opted in");
return exchange_error(state, realm_id, client, ip, "invalid_grant").await;
}
let scope = if token_req.scope.is_empty() {
subject_claims.scope.clone()
} else {
let granted = &subject_claims.scope;
if token_req.scope.iter().any(|s| !granted.contains(s)) {
return exchange_error(state, realm_id, client, ip, "invalid_scope").await;
}
token_req.scope.clone()
};
let scope = intersect_client_scopes(scope, &target_client);
let subject_session = match &subject_claims.sid {
Some(sid) => match state.storage.get_user_session(realm_id, sid).await {
Ok(session) => session,
Err(e) => {
warn!(realm = %realm_id, client_id = %client.client_id, error = %e, "token exchange: subject session lookup failed; impersonator audit claim may be dropped");
None
}
},
None => None,
};
let session_id = subject_claims
.sid
.clone()
.unwrap_or_else(|| SessionId::new(issuerd_core::utils::generate_id()).unwrap());
let mut overlay = crate::claims::build_claims_overlay(
state,
realm_id,
Some(&target_client),
subject_user,
scope.as_slice(),
issuerd_core::ClaimTarget::AccessToken,
)
.await
.unwrap_or_default();
if let Some(impersonator) = subject_session.and_then(|s| s.impersonator) {
overlay.insert(
"impersonator".to_string(),
serde_json::Value::String(impersonator.to_string()),
);
}
let overlay = crate::dpop::bind_cnf_overlay(Some(overlay), dpop_jkt);
let access_token = match state
.token_manager
.issue_access_token_with_roles(
subject_user,
&target_client,
realm,
scope.as_slice(),
&session_id,
None,
None,
overlay,
)
.await
{
Ok(t) => t,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response(&e))).into_response();
}
};
info!(realm = %realm_id, client_id = %client.client_id, target = %target_client.client_id, user_id = %subject_user.id, "token exchanged");
let mut details = HashMap::new();
details.insert("grant_type".to_string(), GrantType::TokenExchange.as_str().to_string());
details.insert("audience".to_string(), target_client.client_id.to_string());
emit_oidc_event(
state,
realm_id,
EventType::TokenExchange,
ip,
Some(client.id.clone()),
Some(subject_user.id.clone()),
Some(session_id),
None,
details,
)
.await;
token_response(TokenResponse {
access_token: access_token.token,
token_type: crate::dpop::token_type(dpop_jkt.is_some()),
expires_in: realm.access_token_lifespan.get(),
refresh_token: None,
id_token: None,
scope: Some(scope.join(" ")),
issued_token_type: Some(TOKEN_TYPE_ACCESS_TOKEN.to_string()),
authorization_details: None,
})
}
#[allow(clippy::too_many_arguments)]
async fn impersonation_exchange(
state: &Arc<ServerState>,
realm: &Realm,
client: &Client,
token_req: &TokenRequest,
ip: &std::net::IpAddr,
subject_claims: &AccessTokenClaims,
subject_user: &User,
requested_subject: &str,
dpop_jkt: Option<&str>,
) -> Response {
let realm_id = &realm.id;
let permitted = subject_claims
.realm_access
.as_ref()
.is_some_and(|ra| ra.roles.iter().any(|r| r.as_ref() == IMPERSONATION_ROLE));
if !permitted {
warn!(realm = %realm_id, client_id = %client.client_id, user_id = %subject_user.id, "impersonation exchange: caller lacks the impersonation role");
return exchange_error(state, realm_id, client, ip, "invalid_grant").await;
}
let target_id = match UserId::new(requested_subject) {
Ok(id) => id,
Err(_) => return exchange_error(state, realm_id, client, ip, "invalid_grant").await,
};
let target_user = match state.storage.get_user(realm_id, &target_id).await {
Ok(Some(u)) if u.enabled => u,
_ => {
return exchange_error(state, realm_id, client, ip, "invalid_grant").await;
}
};
if target_user.id == subject_user.id {
return exchange_error(state, realm_id, client, ip, "invalid_grant").await;
}
let target_client =
match resolve_target_client(state, realm_id, token_req.audience.as_deref()).await {
Some(c) => c,
None => {
return exchange_error(state, realm_id, client, ip, "invalid_grant").await;
}
};
let target_client = target_client.unwrap_or_else(|| client.clone());
let now = chrono::Utc::now();
let session_id = SessionId::new(issuerd_core::utils::generate_id()).unwrap();
let session = issuerd_core::UserSession {
id: session_id.clone(),
realm_id: realm_id.clone(),
user_id: target_user.id.clone(),
login_username: target_user.username.clone(),
ip_address: *ip,
auth_method: issuerd_core::AuthMethod::Impersonation,
remember_me: false,
offline: false,
started: now,
last_session_refresh: now,
auth_time: now,
impersonator: Some(subject_user.id.clone()),
clients: vec![issuerd_core::ClientSession {
id: issuerd_core::ClientSessionId::new(issuerd_core::utils::generate_id()).unwrap(),
client_id: target_client.id.clone(),
session_id: session_id.clone(),
redirect_uri: None,
state: None,
auth_method: issuerd_core::AuthMethod::Impersonation,
timestamp: now,
}],
};
if let Err(resp) = persist_session(state, realm_id, &session, false).await {
return resp;
}
let scope = if token_req.scope.is_empty() {
issuerd_core::Scope::parse("openid profile email")
} else {
token_req.scope.clone()
};
let scope = intersect_client_scopes(scope, &target_client);
let mut overlay = crate::claims::build_claims_overlay(
state,
realm_id,
Some(&target_client),
&target_user,
scope.as_slice(),
issuerd_core::ClaimTarget::AccessToken,
)
.await
.unwrap_or_default();
overlay.insert(
"impersonator".to_string(),
serde_json::Value::String(subject_user.id.to_string()),
);
let overlay = crate::dpop::bind_cnf_overlay(Some(overlay), dpop_jkt);
let access_token = match state
.token_manager
.issue_access_token_with_roles(
&target_user,
&target_client,
realm,
scope.as_slice(),
&session_id,
None,
None,
overlay,
)
.await
{
Ok(t) => t,
Err(e) => {
let _ = state.storage.delete_user_session(realm_id, &session_id).await;
crate::session_cache::invalidate_session(state, realm_id, &session_id).await;
return (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response(&e))).into_response();
}
};
if realm.admin_events_enabled {
let representation = if realm.include_representations {
serde_json::to_string(&serde_json::json!({
"impersonator": subject_user.id.to_string(),
"impersonated_user": target_user.id.to_string(),
"via": "token-exchange",
}))
.ok()
} else {
None
};
let event = issuerd_core::AdminEvent {
id: issuerd_core::EventId::new(issuerd_core::utils::generate_id()).unwrap(),
realm_id: realm_id.clone(),
auth_realm_id: Some(realm_id.clone()),
auth_client_id: Some(client.id.clone()),
auth_user_id: Some(subject_user.id.clone()),
operation_type: issuerd_core::OperationType::Action,
resource_type: issuerd_core::ResourceType::User,
resource_path: format!("users/{}/impersonation", target_user.id),
representation,
error: None,
event_time: now,
};
if let Err(e) = state.storage.save_admin_event(&event).await {
warn!(realm = %realm_id, impersonator = %subject_user.id, impersonated = %target_user.id, error = %e, "impersonation admin event write failed");
}
}
let mut details = HashMap::new();
details.insert("method".to_string(), "impersonation".to_string());
details.insert("impersonator".to_string(), subject_user.id.to_string());
details.insert("username".to_string(), target_user.username.to_string());
details.insert("grant_type".to_string(), GrantType::TokenExchange.as_str().to_string());
let event = issuerd_core::Event {
id: issuerd_core::EventId::new(issuerd_core::utils::generate_id()).unwrap(),
realm_id: realm_id.clone(),
event_time: now,
event_type: EventType::Login,
ip_address: Some(*ip),
client_id: Some(target_client.id.clone()),
user_id: Some(target_user.id.clone()),
session_id: Some(session_id.clone()),
error: None,
details,
};
if let Err(e) = state.storage.save_event(realm_id, &event).await {
warn!(realm = %realm_id, impersonator = %subject_user.id, impersonated = %target_user.id, error = %e, "impersonation login event write failed");
}
info!(realm = %realm_id, client_id = %client.client_id, user_id = %target_user.id, impersonator = %subject_user.id, "impersonation token exchange");
token_response(TokenResponse {
access_token: access_token.token,
token_type: crate::dpop::token_type(dpop_jkt.is_some()),
expires_in: realm.access_token_lifespan.get(),
refresh_token: None,
id_token: None,
scope: Some(scope.join(" ")),
issued_token_type: Some(TOKEN_TYPE_ACCESS_TOKEN.to_string()),
authorization_details: None,
})
}
fn require_requester_in_subject_aud(realm: &Realm, client: &Client) -> bool {
client
.attributes
.get(REQUIRE_REQUESTER_IN_SUBJECT_AUD_ATTRIBUTE)
.or_else(|| realm.attributes.get(REQUIRE_REQUESTER_IN_SUBJECT_AUD_ATTRIBUTE))
.is_some_and(|v| v == "true")
}
fn subject_token_audience_contains(subject_token: &str, client_id: &str) -> bool {
use base64::Engine as _;
let Some(payload) = subject_token.split('.').nth(1) else {
return false;
};
let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) else {
return false;
};
let Ok(claims) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
return false;
};
match claims.get("aud") {
Some(serde_json::Value::String(aud)) => aud == client_id,
Some(serde_json::Value::Array(auds)) => {
auds.iter().any(|aud| aud.as_str() == Some(client_id))
}
_ => false,
}
}
fn intersect_client_scopes(scope: issuerd_core::Scope, client: &Client) -> issuerd_core::Scope {
issuerd_core::Scope::from(
scope
.iter()
.filter(|s| client.default_scopes.contains(s) || client.optional_scopes.contains(s))
.cloned()
.collect::<Vec<_>>(),
)
}
async fn resolve_target_client(
state: &Arc<ServerState>,
realm_id: &RealmId,
audience: Option<&str>,
) -> Option<Option<Client>> {
let Some(audience) = audience else {
return Some(None);
};
let identifier = ClientIdentifier::new(audience).ok()?;
match state.storage.get_client_by_client_id(realm_id, &identifier).await {
Ok(Some(c)) if c.enabled => Some(Some(c)),
Err(e) => {
warn!(realm = %realm_id, client_id = %identifier, error = %e, "token exchange: target client lookup failed");
None
}
_ => None,
}
}
async fn exchange_error(
state: &Arc<ServerState>,
realm_id: &RealmId,
client: &Client,
ip: &std::net::IpAddr,
error: &str,
) -> Response {
let mut details = HashMap::new();
details.insert("grant_type".to_string(), GrantType::TokenExchange.as_str().to_string());
emit_oidc_event(
state,
realm_id,
EventType::TokenExchangeError,
ip,
Some(client.id.clone()),
None,
None,
Some(error.to_string()),
details,
)
.await;
(StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": error }))).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use issuerd_core::{ClientProtocol, Scope};
fn realm_with_policy(value: Option<&str>) -> Realm {
let mut realm = Realm {
id: RealmId::new("test").unwrap(),
name: issuerd_core::RealmName::new("test").unwrap(),
..Default::default()
};
if let Some(v) = value {
realm
.attributes
.insert(REQUIRE_REQUESTER_IN_SUBJECT_AUD_ATTRIBUTE.to_string(), v.to_string());
}
realm
}
fn client_with_policy(value: Option<&str>) -> Client {
let mut client = Client {
id: issuerd_core::ClientId::new("client-uuid-1").unwrap(),
realm_id: RealmId::new("test").unwrap(),
client_id: ClientIdentifier::new("requester").unwrap(),
name: None,
description: None,
enabled: true,
protocol: ClientProtocol::OpenIdConnect,
public_client: false,
bearer_only: false,
client_authenticator_type: issuerd_core::ClientAuthenticatorType::ClientSecret,
secret: None,
redirect_uris: vec![],
web_origins: vec![],
default_scopes: Scope::empty(),
optional_scopes: Scope::empty(),
consent_required: false,
full_scope_allowed: true,
service_accounts_enabled: false,
protocol_mappers: Vec::new(),
scope_mappings: Default::default(),
attributes: HashMap::new(),
};
if let Some(v) = value {
client
.attributes
.insert(REQUIRE_REQUESTER_IN_SUBJECT_AUD_ATTRIBUTE.to_string(), v.to_string());
}
client
}
fn token_with_aud(aud: serde_json::Value) -> String {
use base64::Engine as _;
let b64 = |v: serde_json::Value| {
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&v).unwrap())
};
format!(
"{}.{}.sig",
b64(serde_json::json!({"alg": "none"})),
b64(serde_json::json!({"sub": "user-1", "aud": aud}))
)
}
#[test]
fn policy_defaults_off_without_attributes() {
assert!(!require_requester_in_subject_aud(
&realm_with_policy(None),
&client_with_policy(None)
));
}
#[test]
fn policy_realm_attribute_enables() {
assert!(require_requester_in_subject_aud(
&realm_with_policy(Some("true")),
&client_with_policy(None)
));
assert!(!require_requester_in_subject_aud(
&realm_with_policy(Some("false")),
&client_with_policy(None)
));
assert!(!require_requester_in_subject_aud(
&realm_with_policy(Some("1")),
&client_with_policy(None)
));
}
#[test]
fn policy_client_attribute_overrides_realm_in_both_directions() {
assert!(require_requester_in_subject_aud(
&realm_with_policy(None),
&client_with_policy(Some("true"))
));
assert!(require_requester_in_subject_aud(
&realm_with_policy(Some("false")),
&client_with_policy(Some("true"))
));
assert!(!require_requester_in_subject_aud(
&realm_with_policy(Some("true")),
&client_with_policy(Some("false"))
));
}
#[test]
fn audience_string_form_matches_exactly() {
let token = token_with_aud(serde_json::json!("requester"));
assert!(subject_token_audience_contains(&token, "requester"));
assert!(!subject_token_audience_contains(&token, "other-client"));
assert!(!subject_token_audience_contains(&token, "request"));
assert!(!subject_token_audience_contains(&token, "Requester"));
}
#[test]
fn audience_array_form_matches_any_entry() {
let token = token_with_aud(serde_json::json!(["frontend", "requester"]));
assert!(subject_token_audience_contains(&token, "requester"));
assert!(subject_token_audience_contains(&token, "frontend"));
assert!(!subject_token_audience_contains(&token, "absent"));
}
#[test]
fn audience_missing_or_malformed_fails_closed() {
let token = token_with_aud(serde_json::Value::Null);
assert!(!subject_token_audience_contains(&token, "requester"));
let token = token_with_aud(serde_json::json!(42));
assert!(!subject_token_audience_contains(&token, "requester"));
assert!(!subject_token_audience_contains("not-a-jwt", "requester"));
assert!(!subject_token_audience_contains("a.!!!.b", "requester"));
}
}