use async_trait::async_trait;
use chrono::Utc;
use std::sync::Arc;
use wami_core::error::{AmiError, Result};
use super::{OAuthService, OAuthStore};
use crate::store::traits::oauth::{OAuthAuthorizationStore, OAuthConsentStore, OAuthRefreshStore};
use crate::wami::oauth::{
builder, oidc, AuthenticationEvent, AuthorizationCode, CodeChallenge, DiscoveryDocument,
GrantType, OAuthClaims, OAuthClient, RefreshToken, UserConsent, UserInfo, UserProfile,
AUTHORIZATION_CODE_LIFETIME, REFRESH_TOKEN_LIFETIME,
};
use crate::wami::sts::jwt::{TokenType, TypePolicy};
pub trait OidcStore:
OAuthStore + OAuthAuthorizationStore + OAuthRefreshStore + OAuthConsentStore
{
}
impl<T: OAuthStore + OAuthAuthorizationStore + OAuthRefreshStore + OAuthConsentStore> OidcStore
for T
{
}
#[async_trait]
pub trait UserClaimsSource: Send + Sync {
async fn claims_for(&self, user_name: &str) -> Result<Option<UserProfile>>;
}
#[derive(Debug, Clone)]
pub struct AuthorizationRequest {
pub client_id: String,
pub user_name: String,
pub redirect_uri: String,
pub scopes: Vec<String>,
pub challenge: CodeChallenge,
pub nonce: Option<String>,
pub event: Option<AuthenticationEvent>,
pub state: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Authorization {
Code {
code: String,
redirect_uri: String,
state: Option<String>,
},
ConsentRequired {
client_id: String,
scopes: Vec<String>,
},
}
#[derive(Debug, Clone)]
pub struct CodeExchange {
pub client_id: String,
pub client_secret: String,
pub code: String,
pub redirect_uri: String,
pub code_verifier: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct OidcTokens {
pub access_token: String,
pub token_type: String,
pub expires_in: i64,
pub scope: String,
pub refresh_token: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token: Option<String>,
}
impl<S: OidcStore> OAuthService<S> {
pub fn with_user_claims(mut self, claims: Arc<dyn UserClaimsSource>) -> Self {
self.user_claims = Some(claims);
self
}
pub async fn authorize(&self, request: AuthorizationRequest) -> Result<Authorization> {
let client = self.enabled_client(&request.client_id).await?;
if !client.allows_grant(GrantType::AuthorizationCode) {
return Err(AmiError::AccessDenied {
message: format!(
"client {} may not use the authorization_code grant",
request.client_id
),
});
}
oidc::validate_redirect_uri(&client, &request.redirect_uri)?;
let granted = client.narrow_scopes(&request.scopes).map_err(|refused| {
AmiError::InvalidParameter {
message: format!(
"client {} is not entitled to scope {refused}",
request.client_id
),
}
})?;
let approved = self
.store
.read()
.await
.get_consent(&request.client_id, &request.user_name)
.await?
.is_some_and(|c| c.covers(&granted));
if !approved {
return Ok(Authorization::ConsentRequired {
client_id: request.client_id,
scopes: granted,
});
}
let code = AuthorizationCode {
code: oidc::generate_opaque_value(),
client_id: request.client_id,
user_name: request.user_name,
scopes: granted,
redirect_uri: request.redirect_uri.clone(),
challenge: Some(request.challenge),
nonce: request.nonce,
event: request.event,
expires_at: Utc::now() + AUTHORIZATION_CODE_LIFETIME,
};
let issued = code.code.clone();
self.store
.write()
.await
.store_authorization_code(code)
.await?;
Ok(Authorization::Code {
code: issued,
redirect_uri: request.redirect_uri,
state: request.state,
})
}
pub async fn grant_consent(
&self,
client_id: &str,
user_name: &str,
scopes: Vec<String>,
) -> Result<UserConsent> {
let client = self.enabled_client(client_id).await?;
let scopes =
client
.narrow_scopes(&scopes)
.map_err(|refused| AmiError::InvalidParameter {
message: format!("client {client_id} is not entitled to scope {refused}"),
})?;
self.store
.write()
.await
.record_consent(UserConsent {
user_name: user_name.to_string(),
client_id: client_id.to_string(),
scopes,
granted_at: Utc::now(),
})
.await
}
pub async fn withdraw_consent(&self, client_id: &str, user_name: &str) -> Result<bool> {
let mut store = self.store.write().await;
let withdrawn = store.revoke_consent(client_id, user_name).await?;
store.revoke_refresh_chain(client_id, user_name).await?;
Ok(withdrawn)
}
pub async fn exchange_code(&self, exchange: CodeExchange) -> Result<OidcTokens> {
let client = self
.validate_client(&exchange.client_id, &exchange.client_secret)
.await?;
let code = self
.store
.write()
.await
.consume_authorization_code(&exchange.code)
.await?
.ok_or_else(refused_code)?;
if code.client_id != exchange.client_id
|| code.redirect_uri != exchange.redirect_uri
|| code.expires_at <= Utc::now()
{
return Err(refused_code());
}
match &code.challenge {
Some(challenge) if challenge.verify(&exchange.code_verifier) => {}
_ => return Err(refused_code()),
}
self.mint(
&client,
&code.user_name,
&code.scopes,
code.nonce,
code.event,
)
.await
}
pub async fn refresh_tokens(
&self,
client_id: &str,
client_secret: &str,
refresh_token: &str,
) -> Result<OidcTokens> {
let client = self.validate_client(client_id, client_secret).await?;
let existing = self
.store
.read()
.await
.get_refresh_token(refresh_token)
.await?
.ok_or_else(refused_refresh)?;
if existing.client_id != client_id {
return Err(refused_refresh());
}
let replacement = RefreshToken {
token: oidc::generate_opaque_value(),
client_id: client_id.to_string(),
user_name: existing.user_name.clone(),
scopes: existing.scopes.clone(),
expires_at: Utc::now() + REFRESH_TOKEN_LIFETIME,
used_at: None,
replaced_by: None,
event: existing.event.clone(),
};
let minted = replacement.token.clone();
let spent = self
.store
.write()
.await
.rotate_refresh_token(refresh_token, replacement)
.await?
.ok_or_else(refused_refresh)?;
if spent.replaced_by.as_deref() != Some(minted.as_str()) {
if spent.used_at.is_none() {
return Err(refused_refresh());
}
self.store
.write()
.await
.revoke_refresh_chain(client_id, &spent.user_name)
.await?;
return Err(AmiError::AccessDenied {
message: "refresh token reuse detected; the chain has been revoked".to_string(),
});
}
self.mint(&client, &spent.user_name, &spent.scopes, None, spent.event)
.await
}
pub async fn user_info(&self, access_token: &str, audience: &str) -> Result<UserInfo> {
let refused = || AmiError::AccessDenied {
message: "invalid access token".to_string(),
};
let claims = self
.keys
.verify_claims_as::<OAuthClaims>(
access_token,
audience,
TokenType::AccessToken,
TypePolicy::Lenient,
)
.map_err(|_| refused())?;
let scopes: Vec<String> = claims
.scope
.split_whitespace()
.map(str::to_string)
.collect();
if !scopes.iter().any(|s| s == "openid") {
return Err(refused());
}
let record = self
.store
.read()
.await
.get_oauth_token(&claims.jti)
.await?
.ok_or_else(refused)?;
if !record.is_active_at(Utc::now()) {
return Err(refused());
}
let profile = self.profile_of(&claims.sub).await?;
Ok(oidc::build_user_info(&claims.sub, &scopes, &profile))
}
pub fn discovery(&self, base_url: &str) -> DiscoveryDocument {
oidc::build_discovery_document(&self.issuer, base_url)
}
async fn enabled_client(&self, client_id: &str) -> Result<OAuthClient> {
let refused = || AmiError::AccessDenied {
message: "unknown or disabled client".to_string(),
};
let client = self
.store
.read()
.await
.get_oauth_client(client_id)
.await?
.ok_or_else(refused)?;
if !client.enabled {
return Err(refused());
}
Ok(client)
}
async fn profile_of(&self, user_name: &str) -> Result<UserProfile> {
match &self.user_claims {
Some(source) => Ok(source.claims_for(user_name).await?.unwrap_or_default()),
None => Ok(UserProfile::default()),
}
}
async fn mint(
&self,
client: &OAuthClient,
user_name: &str,
scopes: &[String],
nonce: Option<String>,
event: Option<AuthenticationEvent>,
) -> Result<OidcTokens> {
let now = Utc::now();
let signing_failed = |e: crate::wami::sts::jwt::JwtError| {
AmiError::StoreError(format!("failed to sign: {e}"))
};
let claims =
builder::build_user_claims(client, user_name, scopes, &self.issuer, now, self.lifetime);
let access_token = self
.keys
.sign_claims_as(&claims, self.access_token_type())
.map_err(signing_failed)?;
let id_token = if scopes.iter().any(|s| s == "openid") {
let profile = self.profile_of(user_name).await?;
let id_claims = oidc::build_id_token_claims(
oidc::IdTokenRequest {
user_name,
client_id: &client.client_id,
issuer: &self.issuer,
scopes,
profile: &profile,
nonce,
event: event.as_ref(),
},
now,
self.lifetime,
);
Some(self.keys.sign_claims(&id_claims).map_err(signing_failed)?)
} else {
None
};
let refresh = RefreshToken {
token: oidc::generate_opaque_value(),
client_id: client.client_id.clone(),
user_name: user_name.to_string(),
scopes: scopes.to_vec(),
expires_at: now + REFRESH_TOKEN_LIFETIME,
used_at: None,
replaced_by: None,
event,
};
let refresh_token = refresh.token.clone();
let mut store = self.store.write().await;
store
.record_oauth_token(builder::build_token_record(&claims, now))
.await?;
store.store_refresh_token(refresh).await?;
Ok(OidcTokens {
access_token,
token_type: "Bearer".to_string(),
expires_in: self.lifetime.num_seconds(),
scope: scopes.join(" "),
refresh_token,
id_token,
})
}
}
fn refused_code() -> AmiError {
AmiError::AccessDenied {
message: "invalid authorization code".to_string(),
}
}
fn refused_refresh() -> AmiError {
AmiError::AccessDenied {
message: "invalid refresh token".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::memory::InMemoryOAuthStore;
use crate::wami::oauth::{build_client, derive_s256_challenge, GrantRequest, IdTokenClaims};
use crate::wami::sts::jwt::KeyManager;
use tokio::sync::RwLock;
const AUD: &str = "the-api";
const REDIRECT: &str = "https://app.test/cb";
const VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
struct Directory;
#[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 Example".into()),
email: Some("alice@example.test".into()),
}))
}
}
async fn service_with(
grants: Vec<GrantType>,
redirects: Vec<String>,
) -> OAuthService<InMemoryOAuthStore> {
let service = OAuthService::new(
Arc::new(RwLock::new(InMemoryOAuthStore::new())),
Arc::new(KeyManager::generate()),
"https://id.test".to_string(),
)
.with_user_claims(Arc::new(Directory));
let client = build_client(
"app".into(),
"s3cret",
"The App".into(),
grants,
vec![
"openid".into(),
"profile".into(),
"email".into(),
"reports:read".into(),
],
AUD.to_string(),
redirects,
)
.unwrap();
service.register_client(client).await.unwrap();
service
}
async fn service() -> OAuthService<InMemoryOAuthStore> {
service_with(
vec![GrantType::AuthorizationCode, GrantType::RefreshToken],
vec![REDIRECT.to_string()],
)
.await
}
fn request(scopes: &[&str]) -> AuthorizationRequest {
AuthorizationRequest {
client_id: "app".into(),
user_name: "alice".into(),
redirect_uri: REDIRECT.into(),
scopes: scopes.iter().map(|s| s.to_string()).collect(),
challenge: CodeChallenge::s256(derive_s256_challenge(VERIFIER)),
nonce: Some("n-0S6".into()),
event: None,
state: Some("xyz".into()),
}
}
fn exchange(code: &str, verifier: &str) -> CodeExchange {
CodeExchange {
client_id: "app".into(),
client_secret: "s3cret".into(),
code: code.into(),
redirect_uri: REDIRECT.into(),
code_verifier: verifier.into(),
}
}
async fn a_code(service: &OAuthService<InMemoryOAuthStore>, scopes: &[&str]) -> String {
service
.grant_consent(
"app",
"alice",
scopes.iter().map(|s| s.to_string()).collect(),
)
.await
.unwrap();
match service.authorize(request(scopes)).await.unwrap() {
Authorization::Code { code, .. } => code,
other => panic!("expected a code, got {other:?}"),
}
}
#[tokio::test]
async fn a_user_who_has_not_consented_is_asked_before_any_code_exists() {
let service = service().await;
let outcome = service
.authorize(request(&["openid", "email"]))
.await
.unwrap();
assert_eq!(
outcome,
Authorization::ConsentRequired {
client_id: "app".into(),
scopes: vec!["openid".into(), "email".into()],
}
);
assert!(service
.store
.write()
.await
.consume_authorization_code("anything")
.await
.unwrap()
.is_none());
}
#[tokio::test]
async fn consent_that_does_not_cover_the_request_is_asked_for_again() {
let service = service().await;
service
.grant_consent("app", "alice", vec!["openid".into()])
.await
.unwrap();
let outcome = service
.authorize(request(&["openid", "email"]))
.await
.unwrap();
assert!(matches!(outcome, Authorization::ConsentRequired { .. }));
service
.grant_consent("app", "alice", vec!["openid".into(), "email".into()])
.await
.unwrap();
let outcome = service
.authorize(request(&["openid", "email"]))
.await
.unwrap();
assert!(matches!(outcome, Authorization::Code { .. }));
}
#[tokio::test]
async fn the_whole_flow_yields_an_id_token_addressed_to_the_client() {
let service = service().await;
let code = a_code(&service, &["openid", "profile", "email"]).await;
let tokens = service
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
assert_eq!(tokens.token_type, "Bearer");
assert_eq!(tokens.scope, "openid profile email");
assert!(!tokens.refresh_token.is_empty());
let access: OAuthClaims = service
.keys
.verify_claims(&tokens.access_token, AUD)
.unwrap();
assert_eq!(access.sub, "alice", "the user, not the client");
assert_eq!(access.client_id, "app");
let id: IdTokenClaims = service
.keys
.verify_claims(tokens.id_token.as_ref().unwrap(), "app")
.unwrap();
assert_eq!(id.sub, "alice");
assert_eq!(id.iss, "https://id.test");
assert_eq!(id.nonce.as_deref(), Some("n-0S6"));
assert_eq!(id.name.as_deref(), Some("Alice Example"));
assert_eq!(id.email.as_deref(), Some("alice@example.test"));
assert!(
service
.keys
.verify_claims::<IdTokenClaims>(tokens.id_token.as_ref().unwrap(), AUD)
.is_err(),
"an ID token must not verify as an access token"
);
}
#[tokio::test]
async fn without_openid_there_is_no_id_token() {
let service = service().await;
let code = a_code(&service, &["reports:read"]).await;
let tokens = service
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
assert_eq!(tokens.id_token, None);
assert_eq!(tokens.scope, "reports:read");
}
#[tokio::test]
async fn a_code_cannot_be_redeemed_twice() {
let service = service().await;
let code = a_code(&service, &["openid"]).await;
assert!(service
.exchange_code(exchange(&code, VERIFIER))
.await
.is_ok());
let replay = service.exchange_code(exchange(&code, VERIFIER)).await;
assert!(matches!(replay, Err(AmiError::AccessDenied { .. })));
}
#[tokio::test]
async fn a_wrong_verifier_is_refused_and_burns_the_code() {
let service = service().await;
let code = a_code(&service, &["openid"]).await;
assert!(service
.exchange_code(exchange(&code, "not-the-verifier"))
.await
.is_err());
assert!(
service
.exchange_code(exchange(&code, VERIFIER))
.await
.is_err(),
"the real client should no longer be able to redeem it either"
);
}
#[tokio::test]
async fn a_code_is_bound_to_the_redirect_uri_it_was_issued_against() {
let service = service().await;
let code = a_code(&service, &["openid"]).await;
let mut elsewhere = exchange(&code, VERIFIER);
elsewhere.redirect_uri = "https://app.test/other".into();
assert!(matches!(
service.exchange_code(elsewhere).await,
Err(AmiError::AccessDenied { .. })
));
}
#[tokio::test]
async fn an_unregistered_redirect_uri_never_produces_a_code() {
let service = service().await;
service
.grant_consent("app", "alice", vec!["openid".into()])
.await
.unwrap();
for hostile in [
"https://app.test/cb.attacker.test",
"https://app.test.attacker.test/cb",
"http://app.test/cb",
] {
let mut req = request(&["openid"]);
req.redirect_uri = hostile.into();
assert!(
matches!(
service.authorize(req).await,
Err(AmiError::InvalidParameter { .. })
),
"{hostile} was accepted"
);
}
}
#[tokio::test]
async fn a_client_not_registered_for_the_grant_cannot_start_the_flow() {
let service = service_with(
vec![GrantType::ClientCredentials],
vec![REDIRECT.to_string()],
)
.await;
service
.grant_consent("app", "alice", vec!["openid".into()])
.await
.unwrap();
assert!(matches!(
service.authorize(request(&["openid"])).await,
Err(AmiError::AccessDenied { .. })
));
}
#[tokio::test]
async fn a_scope_outside_the_client_set_is_refused_at_authorization() {
let service = service().await;
let mut req = request(&["openid"]);
req.scopes = vec!["billing:write".into()];
assert!(matches!(
service.authorize(req).await,
Err(AmiError::InvalidParameter { .. })
));
}
#[tokio::test]
async fn an_expired_code_is_refused() {
let service = service().await;
let code = AuthorizationCode {
code: "stale".into(),
client_id: "app".into(),
user_name: "alice".into(),
scopes: vec!["openid".into()],
redirect_uri: REDIRECT.into(),
challenge: Some(CodeChallenge::s256(derive_s256_challenge(VERIFIER))),
nonce: None,
event: None,
expires_at: Utc::now() - chrono::Duration::seconds(1),
};
service
.store
.write()
.await
.store_authorization_code(code)
.await
.unwrap();
assert!(matches!(
service.exchange_code(exchange("stale", VERIFIER)).await,
Err(AmiError::AccessDenied { .. })
));
}
#[tokio::test]
async fn a_code_belonging_to_another_client_cannot_be_redeemed() {
let service = service().await;
let other = build_client(
"other".into(),
"other-secret",
"Other".into(),
vec![GrantType::AuthorizationCode],
vec!["openid".into()],
AUD.to_string(),
vec![REDIRECT.to_string()],
)
.unwrap();
service.register_client(other).await.unwrap();
let code = a_code(&service, &["openid"]).await;
let mut stolen = exchange(&code, VERIFIER);
stolen.client_id = "other".into();
stolen.client_secret = "other-secret".into();
assert!(matches!(
service.exchange_code(stolen).await,
Err(AmiError::AccessDenied { .. })
));
}
#[tokio::test]
async fn wrong_client_credentials_never_reach_the_code() {
let service = service().await;
let code = a_code(&service, &["openid"]).await;
let mut wrong = exchange(&code, VERIFIER);
wrong.client_secret = "guessed".into();
assert!(service.exchange_code(wrong).await.is_err());
assert!(service
.exchange_code(exchange(&code, VERIFIER))
.await
.is_ok());
}
#[tokio::test]
async fn a_refresh_token_works_once_and_yields_a_new_one() {
let service = service().await;
let code = a_code(&service, &["openid", "email"]).await;
let first = service
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
let second = service
.refresh_tokens("app", "s3cret", &first.refresh_token)
.await
.unwrap();
assert_ne!(second.refresh_token, first.refresh_token, "it rotated");
assert_eq!(second.scope, first.scope, "scopes carry over");
let claims: OAuthClaims = service
.keys
.verify_claims(&second.access_token, AUD)
.unwrap();
assert_eq!(claims.sub, "alice");
let id: IdTokenClaims = service
.keys
.verify_claims(second.id_token.as_ref().unwrap(), "app")
.unwrap();
assert_eq!(id.nonce, None);
}
#[tokio::test]
async fn reusing_a_refresh_token_revokes_the_whole_chain() {
let service = service().await;
let code = a_code(&service, &["openid"]).await;
let first = service
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
let second = service
.refresh_tokens("app", "s3cret", &first.refresh_token)
.await
.unwrap();
let err = service
.refresh_tokens("app", "s3cret", &first.refresh_token)
.await
.unwrap_err();
assert!(matches!(err, AmiError::AccessDenied { .. }));
assert!(
service
.refresh_tokens("app", "s3cret", &second.refresh_token)
.await
.is_err(),
"the chain should have been revoked"
);
}
#[tokio::test]
async fn an_unknown_or_foreign_refresh_token_is_refused() {
let service = service().await;
assert!(service
.refresh_tokens("app", "s3cret", "never-issued")
.await
.is_err());
let other = build_client(
"other".into(),
"other-secret",
"Other".into(),
vec![GrantType::RefreshToken],
vec!["openid".into()],
AUD.to_string(),
vec![REDIRECT.to_string()],
)
.unwrap();
service.register_client(other).await.unwrap();
let code = a_code(&service, &["openid"]).await;
let tokens = service
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
assert!(
service
.refresh_tokens("other", "other-secret", &tokens.refresh_token)
.await
.is_err(),
"a refresh token is bound to the client it was issued to"
);
}
#[tokio::test]
async fn withdrawing_consent_stops_the_refresh_tokens_it_backed() {
let service = service().await;
let code = a_code(&service, &["openid"]).await;
let tokens = service
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
assert!(service.withdraw_consent("app", "alice").await.unwrap());
assert!(service
.refresh_tokens("app", "s3cret", &tokens.refresh_token)
.await
.is_err());
assert!(matches!(
service.authorize(request(&["openid"])).await.unwrap(),
Authorization::ConsentRequired { .. }
));
}
#[tokio::test]
async fn userinfo_releases_only_what_the_scopes_granted() {
let service = service().await;
let code = a_code(&service, &["openid", "email"]).await;
let tokens = service
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
let info = service.user_info(&tokens.access_token, AUD).await.unwrap();
assert_eq!(info.sub, "alice");
assert_eq!(info.email.as_deref(), Some("alice@example.test"));
assert_eq!(info.name, None, "profile was not granted");
}
#[tokio::test]
async fn userinfo_refuses_a_token_with_no_user_behind_it() {
let service = service_with(vec![GrantType::ClientCredentials], vec![]).await;
let token = service
.issue_token(GrantRequest::ClientCredentials {
client_id: "app".into(),
client_secret: "s3cret".into(),
scope: vec!["reports:read".into()],
})
.await
.unwrap();
assert!(matches!(
service.user_info(&token.access_token, AUD).await,
Err(AmiError::AccessDenied { .. })
));
}
#[tokio::test]
async fn userinfo_refuses_a_revoked_token() {
let service = service().await;
let code = a_code(&service, &["openid"]).await;
let tokens = service
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
service.revoke_all_for_client("app").await.unwrap();
assert!(service.user_info(&tokens.access_token, AUD).await.is_err());
assert!(service.user_info("not-a-jwt", AUD).await.is_err());
}
#[tokio::test]
async fn a_service_without_a_claims_source_releases_only_the_subject() {
let service = OAuthService::new(
Arc::new(RwLock::new(InMemoryOAuthStore::new())),
Arc::new(KeyManager::generate()),
"https://id.test".to_string(),
);
let client = build_client(
"app".into(),
"s3cret",
"The App".into(),
vec![GrantType::AuthorizationCode],
vec!["openid".into(), "email".into()],
AUD.to_string(),
vec![REDIRECT.to_string()],
)
.unwrap();
service.register_client(client).await.unwrap();
let code = a_code(&service, &["openid", "email"]).await;
let tokens = service
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
let info = service.user_info(&tokens.access_token, AUD).await.unwrap();
assert_eq!(info.sub, "alice");
assert_eq!(info.email, None, "there was nothing to ask");
}
#[tokio::test]
async fn a_disabled_client_can_neither_authorize_nor_consent() {
let service = service().await;
service.disable_client("app").await.unwrap();
assert!(matches!(
service.authorize(request(&["openid"])).await,
Err(AmiError::AccessDenied { .. })
));
assert!(service
.grant_consent("app", "alice", vec!["openid".into()])
.await
.is_err());
}
#[tokio::test]
async fn a_user_cannot_consent_to_a_scope_the_client_never_had() {
let service = service().await;
let err = service
.grant_consent("app", "alice", vec!["billing:write".into()])
.await
.unwrap_err();
assert!(matches!(err, AmiError::InvalidParameter { .. }));
}
#[tokio::test]
async fn an_expired_refresh_token_is_refused_without_revoking_the_chain() {
let service = service().await;
let code = a_code(&service, &["openid"]).await;
let live = service
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
let stale = RefreshToken {
token: "stale".into(),
client_id: "app".into(),
user_name: "alice".into(),
scopes: vec!["openid".into()],
expires_at: Utc::now() - chrono::Duration::seconds(1),
used_at: None,
replaced_by: None,
event: None,
};
service
.store
.write()
.await
.store_refresh_token(stale)
.await
.unwrap();
let err = service
.refresh_tokens("app", "s3cret", "stale")
.await
.unwrap_err();
assert!(matches!(err, AmiError::AccessDenied { .. }));
assert!(
!err.to_string().contains("reuse"),
"an expiry must not be reported as a leak: {err}"
);
assert!(
service
.refresh_tokens("app", "s3cret", &live.refresh_token)
.await
.is_ok(),
"the user's live token should have survived"
);
}
fn an_event() -> AuthenticationEvent {
AuthenticationEvent {
at: Utc::now() - chrono::Duration::hours(2),
acr: Some("urn:mace:incommon:iap:silver".into()),
amr: vec!["pwd".into(), "hwk".into()],
}
}
#[tokio::test]
async fn the_id_token_reports_the_sign_in_the_host_described() {
let service = service().await;
let event = an_event();
service
.grant_consent("app", "alice", vec!["openid".into()])
.await
.unwrap();
let mut req = request(&["openid"]);
req.event = Some(event.clone());
let code = match service.authorize(req).await.unwrap() {
Authorization::Code { code, .. } => code,
other => panic!("expected a code, got {other:?}"),
};
let tokens = service
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
let id: IdTokenClaims = service
.keys
.verify_claims(tokens.id_token.as_ref().unwrap(), "app")
.unwrap();
assert_eq!(id.auth_time, Some(event.at.timestamp()));
assert_eq!(id.acr, event.acr);
assert_eq!(id.amr, vec!["pwd", "hwk"]);
}
#[tokio::test]
async fn a_refreshed_id_token_reports_the_original_sign_in_not_the_refresh() {
let service = service().await;
let event = an_event();
service
.grant_consent("app", "alice", vec!["openid".into()])
.await
.unwrap();
let mut req = request(&["openid"]);
req.event = Some(event.clone());
let code = match service.authorize(req).await.unwrap() {
Authorization::Code { code, .. } => code,
other => panic!("expected a code, got {other:?}"),
};
let first = service
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
let second = service
.refresh_tokens("app", "s3cret", &first.refresh_token)
.await
.unwrap();
let third = service
.refresh_tokens("app", "s3cret", &second.refresh_token)
.await
.unwrap();
for (which, tokens) in [("second", &second), ("third", &third)] {
let id: IdTokenClaims = service
.keys
.verify_claims(tokens.id_token.as_ref().unwrap(), "app")
.unwrap();
assert_eq!(
id.auth_time,
Some(event.at.timestamp()),
"{which} refresh moved auth_time"
);
assert_eq!(id.amr, vec!["pwd", "hwk"], "{which} refresh lost amr");
assert_eq!(id.nonce, None, "{which} refresh must not echo the nonce");
}
}
#[tokio::test]
async fn a_host_that_says_nothing_gets_no_authentication_claims() {
let service = service().await;
let code = a_code(&service, &["openid"]).await;
let tokens = service
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
let id: IdTokenClaims = service
.keys
.verify_claims(tokens.id_token.as_ref().unwrap(), "app")
.unwrap();
assert_eq!(id.auth_time, None);
assert_eq!(id.acr, None);
assert!(id.amr.is_empty());
let json = serde_json::to_value(&id).unwrap();
for absent in ["auth_time", "acr", "amr"] {
assert!(json.get(absent).is_none(), "{absent} was serialised");
}
}
#[tokio::test]
async fn explicit_typing_labels_access_tokens_and_leaves_id_tokens_alone() {
let service = service().await.with_explicit_typ();
let code = a_code(&service, &["openid"]).await;
let tokens = service
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
let access = jsonwebtoken::decode_header(&tokens.access_token).unwrap();
assert_eq!(access.typ.as_deref(), Some("at+jwt"));
let id = jsonwebtoken::decode_header(tokens.id_token.as_ref().unwrap()).unwrap();
assert_eq!(id.typ.as_deref(), Some("JWT"));
assert!(
service
.keys
.verify_claims::<IdTokenClaims>(&tokens.access_token, "app")
.is_err(),
"the audience alone should have refused it"
);
let by_label = service.keys.verify_claims_as::<IdTokenClaims>(
&tokens.access_token,
AUD,
TokenType::Jwt,
TypePolicy::Lenient,
);
assert!(
matches!(
by_label,
Err(crate::wami::sts::jwt::JwtError::TokenTypeMismatch { .. })
),
"the label alone should have refused it, got {by_label:?}"
);
}
#[tokio::test]
async fn turning_typing_on_does_not_invalidate_tokens_already_issued() {
let untyped = service().await;
let code = a_code(&untyped, &["openid"]).await;
let tokens = untyped
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
let typed = OAuthService::new(
untyped.store.clone(),
untyped.keys.clone(),
"https://id.test".to_string(),
)
.with_user_claims(Arc::new(Directory))
.with_explicit_typ();
assert!(typed.user_info(&tokens.access_token, AUD).await.is_ok());
assert!(
typed
.introspect_token(&tokens.access_token, AUD)
.await
.unwrap()
.active
);
}
#[tokio::test]
async fn an_id_token_is_never_accepted_as_a_bearer_token() {
let service = service().await.with_explicit_typ();
let code = a_code(&service, &["openid"]).await;
let tokens = service
.exchange_code(exchange(&code, VERIFIER))
.await
.unwrap();
let id_token = tokens.id_token.unwrap();
assert!(service.user_info(&id_token, AUD).await.is_err());
assert!(
!service
.introspect_token(&id_token, AUD)
.await
.unwrap()
.active
);
}
#[tokio::test]
async fn discovery_reports_this_services_issuer() {
let service = service().await;
let doc = service.discovery("https://id.test");
assert_eq!(doc.issuer, "https://id.test");
assert_eq!(doc.authorization_endpoint, "https://id.test/authorize");
assert_eq!(doc.code_challenge_methods_supported, vec!["S256"]);
}
}