use crate::core::{AitpEnvelope, MessageType, Timestamp};
use crate::crypto::{AitpSigningKey, AitpVerifyingKey};
use crate::handshake::{
Initiator, JwksResolver, MutualCommitAckPayload, MutualHelloAckPayload, OidcMintJwtFn,
PeerConfig, PinnedKeyStore, PresentedIdentity,
};
use crate::manifest::Manifest;
use crate::tct::VerifiedTct;
#[cfg(feature = "experimental-renewal")]
use crate::tct::{build_renewal_request, TctRenewalPayload};
use crate::transport::{
sign_envelope_with, verify_envelope_signature, FetchError, HostGuard, ManifestFetcher,
};
use std::time::Duration;
use uuid::Uuid;
pub enum TrustMode<'a> {
PinnedKeys(&'a dyn PinnedKeyStore),
Oidc {
trust_anchors: &'a [aitp_core::RawUrl],
jwks_resolver: &'a dyn JwksResolver,
},
UnsafeNoTrustEnforcement,
}
pub enum IdentityMode<'a> {
PinnedKey {
subject: String,
},
Oidc {
issuer: url::Url,
subject: String,
proof_jwt: &'a str,
},
OidcWithMintCallback {
issuer: url::Url,
subject: String,
mint: Box<OidcMintJwtFn>,
},
}
impl IdentityMode<'_> {
fn presented_type(&self) -> &'static str {
match self {
IdentityMode::PinnedKey { .. } => "pinned_key",
IdentityMode::Oidc { .. } | IdentityMode::OidcWithMintCallback { .. } => "oidc",
}
}
fn into_presented_identity(self) -> PresentedIdentity {
match self {
IdentityMode::PinnedKey { subject } => PresentedIdentity::PinnedKey { subject },
IdentityMode::Oidc {
issuer,
subject,
proof_jwt,
} => PresentedIdentity::Oidc {
issuer,
subject,
proof_jwt: proof_jwt.to_string(),
},
IdentityMode::OidcWithMintCallback {
issuer,
subject,
mint,
} => PresentedIdentity::OidcMinter {
issuer,
subject,
mint_jwt: mint,
},
}
}
}
struct NoOpJwksResolver;
impl JwksResolver for NoOpJwksResolver {
fn resolve(
&self,
_issuer: &url::Url,
) -> Result<Vec<crate::handshake::JwkPublicKey>, crate::handshake::ResolveError> {
Err(crate::handshake::ResolveError::NetworkError(
"no JWKS resolver configured (TrustMode::PinnedKeys / UnsafeNoTrustEnforcement)".into(),
))
}
}
#[derive(Debug, Clone)]
pub struct SessionContext {
pub peer_aid: aitp_core::Aid,
pub peer_pubkey: AitpVerifyingKey,
pub held_tct: VerifiedTct,
pub grant_voucher: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum FacadeError {
#[error("manifest fetch failed: {0}")]
Manifest(#[from] FetchError),
#[error("handshake failed: {0}")]
Handshake(#[from] aitp_handshake::HandshakeError),
#[error("HTTP error: {0}")]
Http(String),
#[error("peer returned AITP error {code}: {message}")]
Protocol {
code: String,
message: String,
},
#[error("serialization: {0}")]
Serde(#[from] serde_json::Error),
#[error("renewal failed: {0}")]
Renewal(#[from] aitp_tct::TctError),
#[error("manifest verification: {0}")]
ManifestVerify(#[from] aitp_manifest::ManifestError),
}
const MAX_RESPONSE_BYTES: usize = 256 * 1024;
#[derive(serde::Deserialize)]
struct AitpErrorEnvelope {
error: AitpErrorBody,
}
#[derive(serde::Deserialize)]
struct AitpErrorBody {
code: String,
message: String,
}
fn interpret_aitp_response<T: serde::de::DeserializeOwned>(
status: reqwest::StatusCode,
content_type: &str,
body: &[u8],
max_bytes: usize,
) -> Result<T, FacadeError> {
if body.len() > max_bytes {
return Err(FacadeError::Http(format!(
"response body {} bytes exceeds {max_bytes}-byte limit",
body.len()
)));
}
if !status.is_success() {
if let Ok(env) = serde_json::from_slice::<AitpErrorEnvelope>(body) {
return Err(FacadeError::Protocol {
code: env.error.code,
message: env.error.message,
});
}
let excerpt: String = String::from_utf8_lossy(body).chars().take(256).collect();
return Err(FacadeError::Http(format!(
"HTTP {} from peer: {excerpt}",
status.as_u16()
)));
}
if !content_type
.to_ascii_lowercase()
.contains("application/json")
{
return Err(FacadeError::Http(format!(
"unexpected Content-Type `{content_type}` on a 2xx response (expected application/json)"
)));
}
serde_json::from_slice(body)
.map_err(|e| FacadeError::Http(format!("malformed JSON in response body: {e}")))
}
async fn read_aitp_json_response<T: serde::de::DeserializeOwned>(
resp: reqwest::Response,
max_bytes: usize,
) -> Result<T, FacadeError> {
let status = resp.status();
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
if let Some(declared) = resp.content_length() {
if declared > max_bytes as u64 {
return Err(FacadeError::Http(format!(
"response Content-Length {declared} exceeds {max_bytes}-byte limit"
)));
}
}
let mut resp = resp;
let mut body: Vec<u8> = Vec::new();
while let Some(chunk) = resp
.chunk()
.await
.map_err(|e| FacadeError::Http(e.to_string()))?
{
if body.len() + chunk.len() > max_bytes {
return Err(FacadeError::Http(format!(
"response body exceeds {max_bytes}-byte limit"
)));
}
body.extend_from_slice(&chunk);
}
interpret_aitp_response(status, &content_type, &body, max_bytes)
}
async fn build_guarded_client(
endpoint: &url::Url,
timeout: Duration,
guard: &HostGuard,
) -> Result<reqwest::Client, FacadeError> {
let addrs = guard
.resolve_checked(endpoint)
.await
.map_err(|e| FacadeError::Http(format!("handshake endpoint rejected: {e}")))?;
let mut builder = reqwest::Client::builder()
.timeout(timeout)
.redirect(reqwest::redirect::Policy::none());
if let Some(url::Host::Domain(domain)) = endpoint.host() {
builder = builder.resolve_to_addrs(domain, &addrs);
}
builder
.build()
.map_err(|e| FacadeError::Http(e.to_string()))
}
pub async fn run_initiator_handshake(
config: InitiatorConfig<'_>,
) -> Result<SessionContext, FacadeError> {
let manifest_fetcher = ManifestFetcher::new()
.with_timeout(config.http_timeout)
.with_host_guard(config.host_guard.clone());
let peer_manifest = manifest_fetcher.fetch(&config.peer_origin).await?;
let presented_type = config.identity_mode.presented_type();
aitp_manifest::check_identity_type_compatibility(&peer_manifest, presented_type)?;
let no_op_resolver = NoOpJwksResolver;
let empty_anchors: &[aitp_core::RawUrl] = &[];
let (trust_anchors, jwks_resolver, pinned_key_store): (
&[aitp_core::RawUrl],
&dyn JwksResolver,
Option<&dyn PinnedKeyStore>,
) = match &config.trust_mode {
TrustMode::PinnedKeys(store) => (empty_anchors, &no_op_resolver, Some(*store)),
TrustMode::Oidc {
trust_anchors,
jwks_resolver,
} => (*trust_anchors, *jwks_resolver, None),
TrustMode::UnsafeNoTrustEnforcement => (empty_anchors, &no_op_resolver, None),
};
let make_cfg = || PeerConfig {
signing_key: config.signing_key,
manifest: config.own_manifest,
trust_anchors,
jwks_resolver,
pinned_key_store,
grant_policy: None,
revocation_check: None,
now: Timestamp::now(),
};
let cfg = make_cfg();
let mid = Uuid::new_v4();
let ts = Timestamp::now();
let (mut initiator, hello) = Initiator::start(
&cfg,
config.identity_mode.into_presented_identity(),
&peer_manifest.aid,
&mid,
ts,
config.requested_grants.clone(),
)?;
let hello_payload = serde_json::to_value(&hello)
.map_err(|e| FacadeError::Http(format!("serialize hello: {e}")))?;
let hello_envelope = sign_envelope_with(
config.signing_key,
MessageType::MutualHello,
hello_payload,
mid,
ts,
)
.map_err(FacadeError::Http)?;
let endpoint_url = peer_manifest
.handshake_endpoint
.parse_url()
.map_err(|e| FacadeError::Http(format!("handshake_endpoint not a URL: {e}")))?;
let client =
build_guarded_client(&endpoint_url, config.http_timeout, &config.host_guard).await?;
let hello_url = endpoint_url
.join("hello")
.map_err(|e| FacadeError::Http(format!("build hello URL: {e}")))?;
let resp = client
.post(hello_url)
.json(&hello_envelope)
.send()
.await
.map_err(|e| FacadeError::Http(e.to_string()))?;
let session_header = resp
.headers()
.get("x-aitp-session-id")
.and_then(|v| v.to_str().ok())
.map(String::from)
.unwrap_or_default();
let hello_ack_envelope: AitpEnvelope =
read_aitp_json_response(resp, MAX_RESPONSE_BYTES).await?;
let peer_pk = AitpVerifyingKey::from_aid(&hello_ack_envelope.sender.agent_id)
.map_err(|e| FacadeError::Http(e.to_string()))?;
verify_envelope_signature(&hello_ack_envelope, &peer_pk)
.map_err(|e| FacadeError::Http(e.to_string()))?;
let hello_ack: MutualHelloAckPayload =
serde_json::from_value(hello_ack_envelope.payload.clone())?;
let cfg = make_cfg();
let commit = initiator.on_hello_ack(&hello_ack_envelope, &hello_ack, &cfg)?;
let commit_payload = serde_json::to_value(&commit)
.map_err(|e| FacadeError::Http(format!("serialize commit: {e}")))?;
let commit_envelope = sign_envelope_with(
config.signing_key,
MessageType::MutualCommit,
commit_payload,
Uuid::new_v4(),
Timestamp::now(),
)
.map_err(FacadeError::Http)?;
let commit_url = endpoint_url
.join("commit")
.map_err(|e| FacadeError::Http(format!("build commit URL: {e}")))?;
let commit_resp = client
.post(commit_url)
.header("x-aitp-session-id", session_header)
.json(&commit_envelope)
.send()
.await
.map_err(|e| FacadeError::Http(e.to_string()))?;
let commit_ack_envelope: AitpEnvelope =
read_aitp_json_response(commit_resp, MAX_RESPONSE_BYTES).await?;
verify_envelope_signature(&commit_ack_envelope, &peer_pk)
.map_err(|e| FacadeError::Http(e.to_string()))?;
let commit_ack: MutualCommitAckPayload =
serde_json::from_value(commit_ack_envelope.payload.clone())?;
let cfg = make_cfg();
let completed = initiator.on_commit_ack(&commit_ack_envelope, &commit_ack, &cfg)?;
Ok(SessionContext {
peer_aid: peer_manifest.aid.clone(),
peer_pubkey: peer_pk,
held_tct: completed.tct,
grant_voucher: completed.grant_voucher,
})
}
#[non_exhaustive]
pub struct InitiatorConfig<'a> {
pub signing_key: &'a AitpSigningKey,
pub own_manifest: &'a Manifest,
pub peer_origin: url::Url,
pub trust_mode: TrustMode<'a>,
pub identity_mode: IdentityMode<'a>,
pub requested_grants: Vec<String>,
pub http_timeout: Duration,
pub host_guard: HostGuard,
}
impl<'a> InitiatorConfig<'a> {
pub fn new(
signing_key: &'a AitpSigningKey,
own_manifest: &'a Manifest,
peer_origin: url::Url,
trust_mode: TrustMode<'a>,
identity_mode: IdentityMode<'a>,
requested_grants: Vec<String>,
) -> Self {
Self {
signing_key,
own_manifest,
peer_origin,
trust_mode,
identity_mode,
requested_grants,
http_timeout: Duration::from_secs(10),
host_guard: HostGuard::default(),
}
}
pub fn with_http_timeout(mut self, timeout: Duration) -> Self {
self.http_timeout = timeout;
self
}
pub fn with_host_guard(mut self, guard: HostGuard) -> Self {
self.host_guard = guard;
self
}
}
#[cfg(feature = "experimental-renewal")]
#[derive(Debug, Clone, serde::Deserialize)]
pub struct RenewedTct {
pub tct: String,
#[serde(default)]
pub grant_voucher: Option<String>,
}
#[cfg(feature = "experimental-renewal")]
pub async fn renew_tct(
holder_key: &AitpSigningKey,
current: String,
peer_handshake_endpoint: &url::Url,
) -> Result<RenewedTct, FacadeError> {
let pop_nonce = aitp_core::base64url::encode(&rand_bytes_16());
let request: TctRenewalPayload = build_renewal_request(holder_key, current, pop_nonce)?;
let url = peer_handshake_endpoint
.join("renew")
.map_err(|e| FacadeError::Http(format!("build renew URL: {e}")))?;
let client = build_guarded_client(
peer_handshake_endpoint,
Duration::from_secs(10),
&HostGuard::default(),
)
.await?;
let renew_resp = client
.post(url)
.json(&request)
.send()
.await
.map_err(|e| FacadeError::Http(e.to_string()))?;
let renewed: RenewedTct = read_aitp_json_response(renew_resp, MAX_RESPONSE_BYTES).await?;
Ok(renewed)
}
#[cfg(feature = "experimental-renewal")]
fn rand_bytes_16() -> [u8; 16] {
use rand::RngCore;
let mut buf = [0u8; 16];
rand::rngs::OsRng.fill_bytes(&mut buf);
buf
}
#[derive(Clone)]
pub struct TctStore {
inner: std::sync::Arc<parking_lot::RwLock<std::collections::HashMap<aitp_core::Aid, Stored>>>,
refresh_threshold: f64,
}
#[derive(Clone)]
struct Stored {
tct: VerifiedTct,
grant_voucher: Option<String>,
original_ttl_secs: i64,
}
impl Default for TctStore {
fn default() -> Self {
Self::new(0.20)
}
}
impl TctStore {
pub fn new(refresh_threshold: f64) -> Self {
Self {
inner: std::sync::Arc::new(parking_lot::RwLock::new(std::collections::HashMap::new())),
refresh_threshold,
}
}
pub fn insert(&self, tct: VerifiedTct, grant_voucher: Option<String>) {
let issuer = tct.claims.iss.clone();
let original_ttl_secs = tct.claims.exp.0 - tct.claims.iat.0;
let mut map = self.inner.write();
map.insert(
issuer,
Stored {
tct,
grant_voucher,
original_ttl_secs,
},
);
}
pub fn get(&self, peer_aid: &aitp_core::Aid) -> Option<VerifiedTct> {
self.inner.read().get(peer_aid).map(|s| s.tct.clone())
}
pub fn get_voucher(&self, peer_aid: &aitp_core::Aid) -> Option<String> {
self.inner
.read()
.get(peer_aid)
.and_then(|s| s.grant_voucher.clone())
}
pub fn remove(&self, peer_aid: &aitp_core::Aid) {
self.inner.write().remove(peer_aid);
}
pub fn needs_refresh(&self, peer_aid: &aitp_core::Aid, now: Timestamp) -> bool {
let map = self.inner.read();
let Some(entry) = map.get(peer_aid) else {
return false;
};
if entry.original_ttl_secs <= 0 {
return true;
}
let remaining = entry.tct.claims.exp.0 - now.0;
if remaining <= 0 {
return true;
}
let frac = remaining as f64 / entry.original_ttl_secs as f64;
frac < self.refresh_threshold
}
pub fn peer_aids(&self) -> Vec<aitp_core::Aid> {
self.inner.read().keys().cloned().collect()
}
}
#[cfg(test)]
mod tct_store_tests {
use super::*;
use aitp_crypto::AitpSigningKey;
use aitp_tct::TctBuilder;
fn build_tct(issued_at: Timestamp, ttl_secs: i64) -> (VerifiedTct, Option<String>) {
let issuer = AitpSigningKey::from_seed(&[1u8; 32]);
let holder = AitpSigningKey::from_seed(&[2u8; 32]);
let issued = TctBuilder::new(&issuer)
.subject(holder.aid().clone())
.audience(holder.aid().clone())
.grants(["demo.echo"])
.ttl_secs(ttl_secs)
.subject_pubkey(holder.verifying_key())
.issued_at(issued_at)
.build()
.unwrap();
(
VerifiedTct {
token: issued.token,
claims: issued.claims,
},
issued.voucher,
)
}
#[test]
fn fresh_tct_does_not_need_refresh() {
let store = TctStore::default();
let now = Timestamp(1_700_000_000);
let (tct, voucher) = build_tct(now, 3600);
let issuer = tct.claims.iss.clone();
store.insert(tct, voucher);
assert!(!store.needs_refresh(&issuer, now));
assert!(store.get_voucher(&issuer).is_some());
}
#[test]
fn near_expiry_needs_refresh() {
let store = TctStore::default();
let now = Timestamp(1_700_000_000);
let (tct, voucher) = build_tct(now, 3600);
let issuer = tct.claims.iss.clone();
store.insert(tct, voucher);
let later = Timestamp(now.0 + 3240);
assert!(store.needs_refresh(&issuer, later));
}
#[test]
fn expired_needs_refresh() {
let store = TctStore::default();
let now = Timestamp(1_700_000_000);
let (tct, voucher) = build_tct(now, 3600);
let issuer = tct.claims.iss.clone();
store.insert(tct, voucher);
let past_expiry = Timestamp(now.0 + 7200);
assert!(store.needs_refresh(&issuer, past_expiry));
}
#[test]
fn unknown_peer_does_not_need_refresh() {
let store = TctStore::default();
let key = AitpSigningKey::from_seed(&[9u8; 32]);
assert!(!store.needs_refresh(key.aid(), Timestamp(1_700_000_000)));
}
#[test]
fn remove_deletes_entry() {
let store = TctStore::default();
let (tct, voucher) = build_tct(Timestamp(1_700_000_000), 3600);
let issuer = tct.claims.iss.clone();
store.insert(tct, voucher);
assert!(store.get(&issuer).is_some());
store.remove(&issuer);
assert!(store.get(&issuer).is_none());
}
}
#[cfg(test)]
mod facade_http_tests {
use super::*;
use reqwest::StatusCode;
#[test]
fn http_500_with_non_aitp_body_is_http_error() {
let err = interpret_aitp_response::<serde_json::Value>(
StatusCode::INTERNAL_SERVER_ERROR,
"text/html",
b"<html><body>500 Internal Server Error</body></html>",
MAX_RESPONSE_BYTES,
)
.unwrap_err();
match err {
FacadeError::Http(msg) => assert!(msg.contains("HTTP 500"), "got {msg}"),
other => panic!("expected Http, got {other:?}"),
}
}
#[test]
fn non_json_content_type_on_success_is_http_error() {
let err = interpret_aitp_response::<serde_json::Value>(
StatusCode::OK,
"text/html; charset=utf-8",
b"<html>not json</html>",
MAX_RESPONSE_BYTES,
)
.unwrap_err();
match err {
FacadeError::Http(msg) => assert!(msg.contains("Content-Type"), "got {msg}"),
other => panic!("expected Http, got {other:?}"),
}
}
#[test]
fn aitp_error_envelope_is_protocol_error() {
let body = br#"{"error":{"code":"IDENTITY_FAILED","message":"pinned key not trusted"}}"#;
let err = interpret_aitp_response::<serde_json::Value>(
StatusCode::BAD_REQUEST,
"application/json",
body,
MAX_RESPONSE_BYTES,
)
.unwrap_err();
match err {
FacadeError::Protocol { code, message } => {
assert_eq!(code, "IDENTITY_FAILED");
assert_eq!(message, "pinned key not trusted");
}
other => panic!("expected Protocol, got {other:?}"),
}
}
#[test]
fn oversized_body_is_http_error() {
let big = vec![b'x'; 64];
let err = interpret_aitp_response::<serde_json::Value>(
StatusCode::OK,
"application/json",
&big,
16, )
.unwrap_err();
match err {
FacadeError::Http(msg) => assert!(msg.contains("exceeds"), "got {msg}"),
other => panic!("expected Http, got {other:?}"),
}
}
#[test]
fn valid_json_success_deserializes() {
let v: serde_json::Value = interpret_aitp_response(
StatusCode::OK,
"application/json",
br#"{"ok":true}"#,
MAX_RESPONSE_BYTES,
)
.unwrap();
assert_eq!(v, serde_json::json!({"ok": true}));
}
}
#[cfg(test)]
mod initiator_config_knobs {
use super::*;
fn key_and_manifest() -> (AitpSigningKey, Manifest) {
let key = AitpSigningKey::from_seed(&[0x33; 32]);
let manifest = crate::manifest::ManifestBuilder::new(&key)
.display_name("t")
.handshake_endpoint("https://t.example.com/handshake".parse().unwrap())
.identity_hint(crate::manifest::IdentityHint {
kind: crate::manifest::IdentityHintKind::PinnedKey,
subject: "t".into(),
issuer: None,
public_key: Some(key.aid().identifier().to_string()),
})
.offer("demo.echo")
.published_at(Timestamp(1_700_000_000))
.build()
.unwrap();
(key, manifest)
}
#[test]
fn defaults_are_10s_and_warn_private() {
let (key, manifest) = key_and_manifest();
let cfg = InitiatorConfig::new(
&key,
&manifest,
"https://peer.example.com".parse().unwrap(),
TrustMode::UnsafeNoTrustEnforcement,
IdentityMode::PinnedKey {
subject: "t".into(),
},
vec!["demo.echo".into()],
);
assert_eq!(cfg.http_timeout, Duration::from_secs(10));
assert_eq!(
cfg.host_guard.mode(),
crate::transport::GuardMode::WarnPrivate
);
}
#[test]
fn with_setters_override_defaults() {
let (key, manifest) = key_and_manifest();
let cfg = InitiatorConfig::new(
&key,
&manifest,
"https://peer.example.com".parse().unwrap(),
TrustMode::UnsafeNoTrustEnforcement,
IdentityMode::PinnedKey {
subject: "t".into(),
},
vec!["demo.echo".into()],
)
.with_http_timeout(Duration::from_secs(3))
.with_host_guard(HostGuard::strict());
assert_eq!(cfg.http_timeout, Duration::from_secs(3));
assert_eq!(
cfg.host_guard.mode(),
crate::transport::GuardMode::DenyPrivate
);
}
}
#[cfg(test)]
mod identity_mode_tests {
use super::*;
#[test]
fn pinned_key_mode_presents_pinned_key_type() {
let m = IdentityMode::PinnedKey {
subject: "alice".into(),
};
assert_eq!(m.presented_type(), "pinned_key");
match m.into_presented_identity() {
PresentedIdentity::PinnedKey { subject } => assert_eq!(subject, "alice"),
_ => panic!("expected a PinnedKey PresentedIdentity"),
}
}
#[test]
fn oidc_mode_presents_oidc_type() {
let issuer: url::Url = "https://idp.example.com/".parse().unwrap();
let m = IdentityMode::Oidc {
issuer: issuer.clone(),
subject: "alice@example.com".into(),
proof_jwt: "eyJ.fake.jwt",
};
assert_eq!(m.presented_type(), "oidc");
match m.into_presented_identity() {
PresentedIdentity::Oidc {
issuer: got_issuer,
subject,
proof_jwt,
} => {
assert_eq!(got_issuer, issuer);
assert_eq!(subject, "alice@example.com");
assert_eq!(proof_jwt, "eyJ.fake.jwt");
}
_ => panic!("expected an Oidc PresentedIdentity"),
}
}
#[test]
fn oidc_mint_callback_mode_presents_oidc_and_forwards_nonce() {
use std::sync::{Arc, Mutex};
let issuer: url::Url = "https://idp.example.com/".parse().unwrap();
let seen = Arc::new(Mutex::new(None::<String>));
let seen_in_cb = Arc::clone(&seen);
let m = IdentityMode::OidcWithMintCallback {
issuer: issuer.clone(),
subject: "alice@example.com".into(),
mint: Box::new(move |nonce: &str| {
*seen_in_cb.lock().unwrap() = Some(nonce.to_string());
Ok(format!("header.{nonce}.sig"))
}),
};
assert_eq!(m.presented_type(), "oidc");
match m.into_presented_identity() {
PresentedIdentity::OidcMinter {
issuer: got_issuer,
subject,
mint_jwt,
} => {
assert_eq!(got_issuer, issuer);
assert_eq!(subject, "alice@example.com");
let jwt = mint_jwt("nonce-xyz").expect("mint should succeed");
assert_eq!(jwt, "header.nonce-xyz.sig");
assert_eq!(seen.lock().unwrap().as_deref(), Some("nonce-xyz"));
}
_ => panic!("expected an OidcMinter PresentedIdentity"),
}
}
}