use chrono::{Duration, Utc};
use std::sync::Arc;
use tokio::sync::RwLock;
use wami::service::oauth::oidc::{
Authorization, AuthorizationRequest, CodeExchange, UserClaimsSource,
};
use wami::service::oauth::OAuthService;
use wami::store::memory::InMemoryOAuthStore;
use wami::wami::oauth::{
build_client, derive_s256_challenge, generate_client_secret, AuthenticationEvent,
CodeChallenge, GrantType, IdTokenClaims, OAuthClaims, UserProfile,
};
use wami::wami::sts::jwt::{KeyManager, TokenType, TypePolicy};
use wami_core::error::Result;
const AUDIENCE: &str = "photos-api";
const REDIRECT: &str = "https://gallery.example.test/callback";
struct Directory;
#[async_trait::async_trait]
impl UserClaimsSource for Directory {
async fn claims_for(&self, user_name: &str) -> Result<Option<UserProfile>> {
Ok((user_name == "alice").then(|| UserProfile {
name: Some("Alice Martin".to_string()),
email: Some("alice@example.test".to_string()),
}))
}
}
#[tokio::main]
async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
println!("🪪 OpenID Connect — authorization code + PKCE\n");
println!("{}", "=".repeat(60));
println!("\n📝 Step 1: Register a client");
let store = Arc::new(RwLock::new(InMemoryOAuthStore::new()));
let keys = Arc::new(KeyManager::generate());
let service = OAuthService::new(store, keys.clone(), "https://id.example.test".to_string())
.with_user_claims(Arc::new(Directory))
.with_explicit_typ();
let secret = generate_client_secret();
let client = build_client(
"gallery".to_string(),
&secret,
"Photo Gallery".to_string(),
vec![GrantType::AuthorizationCode, GrantType::RefreshToken],
vec![
"openid".to_string(),
"profile".to_string(),
"email".to_string(),
"photos:read".to_string(),
],
AUDIENCE.to_string(),
vec![REDIRECT.to_string()],
)?;
service.register_client(client).await?;
println!("✅ client_id: gallery");
println!(" redirect: {REDIRECT}");
let verifier = generate_client_secret();
let challenge = CodeChallenge::s256(derive_s256_challenge(&verifier));
let asked = vec![
"openid".to_string(),
"profile".to_string(),
"photos:read".to_string(),
];
let signed_in = AuthenticationEvent {
at: Utc::now() - Duration::hours(2),
acr: Some("urn:mace:incommon:iap:silver".to_string()),
amr: vec!["pwd".to_string(), "hwk".to_string()],
};
let request = || AuthorizationRequest {
client_id: "gallery".to_string(),
user_name: "alice".to_string(), redirect_uri: REDIRECT.to_string(),
scopes: asked.clone(),
challenge: challenge.clone(),
nonce: Some("n-0S6_WzA2Mj".to_string()),
event: Some(signed_in.clone()),
state: Some("opaque-client-state".to_string()),
};
println!("\n🙋 Step 2: Alice has never used this application");
match service.authorize(request()).await? {
Authorization::ConsentRequired { scopes, .. } => {
println!("✅ consent required for: {}", scopes.join(", "));
println!(" (the host shows a screen; nothing was issued)");
service.grant_consent("gallery", "alice", scopes).await?;
println!(" Alice approves");
}
Authorization::Code { .. } => println!("❌ a code was issued without consent!"),
}
let code = match service.authorize(request()).await? {
Authorization::Code { code, state, .. } => {
println!("✅ code issued, state handed back untouched: {state:?}");
code
}
Authorization::ConsentRequired { .. } => unreachable!("consent was just granted"),
};
println!("\n🔄 Step 3: The client redeems the code with its verifier");
let tokens = service
.exchange_code(CodeExchange {
client_id: "gallery".to_string(),
client_secret: secret.clone(),
code: code.clone(),
redirect_uri: REDIRECT.to_string(),
code_verifier: verifier.clone(),
})
.await?;
println!("✅ access + refresh + id token, scope: {}", tokens.scope);
let replay = service
.exchange_code(CodeExchange {
client_id: "gallery".to_string(),
client_secret: secret.clone(),
code,
redirect_uri: REDIRECT.to_string(),
code_verifier: verifier,
})
.await;
println!("✅ replaying the code: {}", refused(&replay));
println!("\n🎭 Step 4: What each token is for");
let id: IdTokenClaims = keys.verify_claims(tokens.id_token.as_ref().unwrap(), "gallery")?;
println!(" ID token → who signed in");
println!(" sub={} name={:?}", id.sub, id.name);
println!(" nonce echoed: {:?}", id.nonce);
println!(" acr={:?} amr={:?}", id.acr, id.amr);
println!(
" email={:?} (the `email` scope was never asked for)",
id.email
);
let access: OAuthClaims = keys.verify_claims_as(
&tokens.access_token,
AUDIENCE,
TokenType::AccessToken,
TypePolicy::Strict,
)?;
println!(" Access token → what may be done");
println!(" sub={} client_id={}", access.sub, access.client_id);
println!(" scope={}", access.scope);
let confused: std::result::Result<IdTokenClaims, _> =
keys.verify_claims(tokens.id_token.as_ref().unwrap(), AUDIENCE);
println!(
"✅ the API refuses the ID token as authorisation: {}",
confused.is_err()
);
println!(
" headers say typ={:?} (access) vs {:?} (id)",
jsonwebtoken::decode_header(&tokens.access_token)?.typ,
jsonwebtoken::decode_header(tokens.id_token.as_ref().unwrap())?.typ,
);
let mislabelled: std::result::Result<IdTokenClaims, _> = keys.verify_claims_as(
&tokens.access_token,
AUDIENCE,
TokenType::Jwt,
TypePolicy::Lenient,
);
println!(
" and the label alone refuses it as an ID token: {}",
mislabelled.is_err()
);
let info = service.user_info(&tokens.access_token, AUDIENCE).await?;
println!(" /userinfo → sub={} name={:?}", info.sub, info.name);
println!("\n♻️ Step 5: Refreshing, and a leak");
let next = service
.refresh_tokens("gallery", &secret, &tokens.refresh_token)
.await?;
println!("✅ refreshed — the old token is spent, a new one took its place");
let refreshed: IdTokenClaims =
keys.verify_claims(next.id_token.as_ref().unwrap(), "gallery")?;
println!(
" auth_time unchanged: {} — the session did not get younger",
if refreshed.auth_time == Some(signed_in.at.timestamp()) {
"yes"
} else {
"NO — §12.2 violated!"
}
);
let stolen = service
.refresh_tokens("gallery", &secret, &tokens.refresh_token)
.await;
println!("🚨 reuse detected: {}", refused(&stolen));
let legitimate = service
.refresh_tokens("gallery", &secret, &next.refresh_token)
.await;
println!(
" the honest client is locked out too: {}",
refused(&legitimate)
);
println!(" (that is the point — Alice signs in again, the thief cannot)");
println!("\n🚪 Step 6: Alice changes her mind");
service.withdraw_consent("gallery", "alice").await?;
println!("✅ consent withdrawn, and every refresh token with it");
match service.authorize(request()).await? {
Authorization::ConsentRequired { .. } => println!(" she is asked again next time"),
Authorization::Code { .. } => println!("❌ a code was issued after withdrawal!"),
}
println!("\n🧭 Step 7: What a relying party discovers");
let doc = service.discovery("https://id.example.test");
println!(" issuer: {}", doc.issuer);
println!(" token: {}", doc.token_endpoint);
println!(" jwks: {}", doc.jwks_uri);
println!(" pkce: {:?}", doc.code_challenge_methods_supported);
println!(" grants: {}", doc.grant_types_supported.join(", "));
println!("\n{}", "=".repeat(60));
println!("⚠️ What wami does not do");
println!("{}", "=".repeat(60));
println!("It never authenticates the user, and it serves no HTTP. The host");
println!("proves who is at the keyboard, mounts the endpoints discovery");
println!("advertises, and hands the browser its redirects. wami decides what");
println!("may be issued, and signs it.");
println!("\n✅ Example completed successfully!");
Ok(())
}
fn refused<T>(outcome: &std::result::Result<T, wami_core::error::AmiError>) -> String {
match outcome {
Err(e) => e.to_string(),
Ok(_) => "❌ ACCEPTED — this should have been refused!".to_string(),
}
}