use std::{marker::PhantomData, sync::Arc};
use bytes::Bytes;
use http::{HeaderMap, HeaderValue, Method, StatusCode, header::InvalidHeaderValue};
use serde::Deserialize;
use snafu::prelude::*;
use crate::{
core::{
BoxedError, EndpointUrl,
crypto::verifier::{JwsVerifierFactory, JwsVerifierPlatform},
dpop::{AuthorizationServerDPoP, ResourceServerDPoP},
http::{HttpClient, HttpResponse},
jwt::{
JwsParseError, parse_compact_jws,
validator::{ClaimCheck, JwtValidationError, JwtValidator},
},
server_metadata::AuthorizationServerMetadata,
},
grant::core::OAuth2ExchangeGrant,
token::AccessToken,
};
#[derive(Debug)]
pub struct UserInfoClient<
D: ResourceServerDPoP,
Extra: Clone + for<'de> Deserialize<'de> + 'static = (),
> {
userinfo_endpoint: EndpointUrl,
mtls_userinfo_endpoint: Option<EndpointUrl>,
dpop: D,
jwt_validator: Option<JwtValidator>,
_phantom: PhantomData<Extra>,
}
#[bon::bon]
impl<D: ResourceServerDPoP, Extra: Clone + for<'de> Deserialize<'de> + 'static>
UserInfoClient<D, Extra>
{
#[builder(state_mod(name = "builder"))]
pub async fn new(
userinfo_endpoint: EndpointUrl,
mtls_userinfo_endpoint: Option<EndpointUrl>,
dpop: D,
jwks_uri: Option<EndpointUrl>,
jws_verifier_factory: Option<Arc<dyn JwsVerifierFactory>>,
#[cfg(not(feature = "default-jws-verifier-platform"))]
jws_verifier_platform: Option<Arc<dyn JwsVerifierPlatform>>,
#[cfg(feature = "default-jws-verifier-platform")]
#[cfg_attr(feature = "default-jws-verifier-platform", builder(default = crate::DefaultJwsVerifierPlatform::default().into()))]
jws_verifier_platform: Arc<dyn JwsVerifierPlatform>,
#[builder(into)]
issuer: Option<String>,
#[builder(into)]
client_id: Option<String>,
) -> Result<Self, BoxedError> {
#[cfg(feature = "default-jws-verifier-platform")]
let jws_verifier_platform = Some(jws_verifier_platform);
let jwt_validator = if let Some(jws_verifier_platform) = jws_verifier_platform
&& let Some(factory) = jws_verifier_factory
&& jwks_uri.is_some()
{
let issuer = issuer.ok_or_else(|| BoxedError::from_err(MissingIssuerSnafu.build()))?;
let client_id =
client_id.ok_or_else(|| BoxedError::from_err(MissingClientIdSnafu.build()))?;
let verifier = factory
.build(jwks_uri.as_ref(), jws_verifier_platform)
.await?;
Some(
JwtValidator::builder()
.verifier(verifier)
.aud(ClaimCheck::required_value(client_id))
.iss(ClaimCheck::required_value(issuer))
.build(),
)
} else {
None
};
Ok(Self {
userinfo_endpoint,
mtls_userinfo_endpoint,
dpop,
jwt_validator,
_phantom: PhantomData,
})
}
}
impl<D: ResourceServerDPoP> UserInfoClient<D> {
pub fn from_grant<G>(grant: &G, metadata: &AuthorizationServerMetadata) -> Option<Self>
where
G: OAuth2ExchangeGrant,
G::DPoP: AuthorizationServerDPoP<ResourceServerDPoP = D>,
{
let userinfo_endpoint = metadata.userinfo_endpoint.clone()?;
Some(Self {
userinfo_endpoint,
mtls_userinfo_endpoint: metadata
.mtls_endpoint_aliases
.as_ref()
.and_then(|a| a.userinfo_endpoint.clone()),
dpop: grant.dpop().to_resource_server_dpop(),
jwt_validator: None,
_phantom: PhantomData,
})
}
#[allow(clippy::type_complexity)]
pub fn builder_from_metadata(
metadata: &AuthorizationServerMetadata,
) -> Option<
UserInfoClientBuilder<
D,
(),
builder::SetIssuer<
builder::SetJwksUri<
builder::SetMtlsUserinfoEndpoint<builder::SetUserinfoEndpoint<builder::Empty>>,
>,
>,
>,
> {
let userinfo_endpoint = metadata.userinfo_endpoint.clone()?;
Some(
Self::builder()
.userinfo_endpoint(userinfo_endpoint)
.maybe_mtls_userinfo_endpoint(
metadata
.mtls_endpoint_aliases
.as_ref()
.and_then(|a| a.userinfo_endpoint.clone()),
)
.maybe_jwks_uri(metadata.jwks_uri.clone())
.issuer(metadata.issuer.clone()),
)
}
}
impl<D: ResourceServerDPoP, Extra: Clone + for<'de> Deserialize<'de> + 'static>
UserInfoClient<D, Extra>
{
pub async fn get<C: HttpClient>(
&self,
http_client: &C,
access_token: &AccessToken,
expected_sub: &str,
) -> Result<UserInfo<Extra>, UserInfoError<C::Error, C::ResponseError, D::Error>> {
let endpoint = if http_client.uses_mtls() {
self.mtls_userinfo_endpoint
.as_ref()
.unwrap_or(&self.userinfo_endpoint)
} else {
&self.userinfo_endpoint
};
let header_value = access_token
.expose_header_value()
.context(BadAuthorizationHeaderSnafu)?;
let dpop_jkt = access_token.dpop_jkt();
let mut retried = false;
loop {
let mut headers = HeaderMap::new();
headers.insert(http::header::AUTHORIZATION, header_value.clone());
if let Some(jkt) = dpop_jkt
&& let Some(proof) = self
.dpop
.proof(&Method::GET, endpoint.as_uri(), access_token.token(), jkt)
.await
.context(DPoPSnafu)?
{
headers.insert(
"DPoP",
HeaderValue::from_str(proof.expose_secret()).context(DPoPHeaderSnafu)?,
);
}
let (mut parts, ()) = http::Request::new(()).into_parts();
parts.headers = headers;
parts.uri = endpoint.as_uri().clone();
let request = http::Request::from_parts(parts, Bytes::new());
let response = http_client.execute(request).await.context(RequestSnafu)?;
let status = response.status();
let response_headers = response.headers();
let body = response.body().await.context(ResponseBodySnafu)?;
if !retried
&& status == StatusCode::UNAUTHORIZED
&& let Some(nonce) = response_headers
.get("DPoP-Nonce")
.and_then(|v| v.to_str().ok())
{
self.dpop.update_nonce(endpoint.as_uri(), nonce.to_string());
retried = true;
continue;
}
ensure!(
status.is_success(),
BadStatusSnafu {
status,
headers: response_headers,
body: body.to_vec(),
}
);
let ct_header = response_headers
.get(http::header::CONTENT_TYPE)
.context(MissingContentTypeSnafu)?;
let ct_str = ct_header
.to_str()
.ok()
.context(UnexpectedContentTypeSnafu {
content_type: String::from_utf8_lossy(ct_header.as_bytes()),
})?;
let media_type = ct_str.split(';').next().unwrap_or(ct_str).trim();
let is_jwt_response = media_type.eq_ignore_ascii_case("application/jwt");
if !is_jwt_response {
ensure!(
media_type.eq_ignore_ascii_case("application/json"),
UnexpectedContentTypeSnafu {
content_type: media_type,
}
);
}
let user_info: UserInfo<Extra> = if is_jwt_response {
self.decode_jwt_response::<C>(&body).await?
} else {
serde_json::from_slice(&body).context(DeserializeSnafu)?
};
ensure!(
user_info.sub == expected_sub,
SubMismatchSnafu {
expected: expected_sub,
actual: &user_info.sub,
}
);
return Ok(user_info);
}
}
async fn decode_jwt_response<C: HttpClient>(
&self,
body: &[u8],
) -> Result<UserInfo<Extra>, UserInfoError<C::Error, C::ResponseError, D::Error>> {
let jwt_validator = self
.jwt_validator
.as_ref()
.context(JwtResponseNotSupportedSnafu)?;
let jwt_str = std::str::from_utf8(body)
.ok()
.context(MalformedJwtResponseBodySnafu)?;
let parsed =
parse_compact_jws::<(), serde_json::Value>(jwt_str.trim()).context(JwtParseSnafu)?;
let validated = jwt_validator
.validate_parsed_jws(parsed)
.await
.context(JwtValidationSnafu)?;
let mut claims_map = match validated.claims {
serde_json::Value::Object(m) => m,
_ => serde_json::Map::new(),
};
if let Some(sub) = &validated.subject {
claims_map.insert("sub".to_owned(), serde_json::Value::String(sub.clone()));
}
serde_json::from_value(serde_json::Value::Object(claims_map)).context(DeserializeSnafu)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct UserInfo<Extra = ()> {
pub sub: String,
pub name: Option<String>,
pub given_name: Option<String>,
pub family_name: Option<String>,
pub middle_name: Option<String>,
pub nickname: Option<String>,
pub preferred_username: Option<String>,
pub profile: Option<String>,
pub picture: Option<String>,
pub website: Option<String>,
pub email: Option<String>,
pub email_verified: Option<bool>,
pub gender: Option<String>,
pub birthdate: Option<String>,
pub zoneinfo: Option<String>,
pub locale: Option<String>,
pub phone_number: Option<String>,
pub phone_number_verified: Option<bool>,
pub address: Option<Address>,
pub updated_at: Option<i64>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Address {
pub formatted: Option<String>,
pub street_address: Option<String>,
pub locality: Option<String>,
pub region: Option<String>,
pub postal_code: Option<String>,
pub country: Option<String>,
}
#[derive(Debug, Snafu)]
pub enum UserInfoBuildError {
#[snafu(display("issuer is required when JWT validation is configured for UserInfo"))]
MissingIssuer,
#[snafu(display("client_id is required when JWT validation is configured for UserInfo"))]
MissingClientId,
}
impl crate::core::Error for UserInfoBuildError {
fn is_retryable(&self) -> bool {
false
}
}
#[derive(Debug, Snafu)]
pub enum UserInfoError<
HttpReqErr: crate::core::Error,
HttpRespErr: crate::core::Error,
DPoPErr: crate::core::Error,
> {
#[snafu(display("Failed to build Authorization header for UserInfo request"))]
BadAuthorizationHeader {
source: InvalidHeaderValue,
},
#[snafu(display("Failed to generate DPoP proof for UserInfo request"))]
DPoP {
source: DPoPErr,
},
#[snafu(display("Failed to set DPoP proof as HTTP header"))]
DPoPHeader {
source: InvalidHeaderValue,
},
#[snafu(display("UserInfo request failed"))]
Request {
source: HttpReqErr,
},
#[snafu(display("Failed to read UserInfo response body"))]
ResponseBody {
source: HttpRespErr,
},
#[snafu(display(
"UserInfo endpoint returned application/jwt but no JWT validator was configured"
))]
JwtResponseNotSupported,
#[snafu(display("Failed to parse UserInfo JWT response"))]
JwtParse {
source: JwsParseError,
},
#[snafu(display("UserInfo JWT response validation failed"))]
JwtValidation {
source: JwtValidationError,
},
#[snafu(display("UserInfo JWT response body is not valid UTF-8"))]
MalformedJwtResponseBody,
#[snafu(display("UserInfo response is missing the Content-Type header"))]
MissingContentType,
#[snafu(display("UserInfo endpoint returned unexpected Content-Type: {content_type}"))]
UnexpectedContentType {
content_type: String,
},
#[snafu(display("Failed to deserialize UserInfo response"))]
Deserialize {
source: serde_json::Error,
},
#[snafu(display("UserInfo sub mismatch: expected {expected}, got {actual}"))]
SubMismatch {
expected: String,
actual: String,
},
#[snafu(display("UserInfo endpoint returned HTTP {status}"))]
BadStatus {
status: StatusCode,
headers: HeaderMap,
body: Vec<u8>,
},
}
impl<HttpReqErr: crate::core::Error, HttpRespErr: crate::core::Error, DPoPErr: crate::core::Error>
crate::core::Error for UserInfoError<HttpReqErr, HttpRespErr, DPoPErr>
{
fn is_retryable(&self) -> bool {
match self {
Self::BadAuthorizationHeader { .. }
| Self::DPoP { .. }
| Self::DPoPHeader { .. }
| Self::JwtResponseNotSupported
| Self::JwtParse { .. }
| Self::JwtValidation { .. }
| Self::MalformedJwtResponseBody
| Self::MissingContentType
| Self::UnexpectedContentType { .. }
| Self::Deserialize { .. }
| Self::SubMismatch { .. }
| Self::BadStatus { .. } => false,
Self::Request { source } => source.is_retryable(),
Self::ResponseBody { source } => source.is_retryable(),
}
}
}
#[cfg(test)]
mod tests {
use std::convert::Infallible;
use super::*;
use crate::{
core::{
IntoEndpointUrl,
crypto::{
KeyMatchStrength,
verifier::{BoxedJwsVerifier, JwsVerifier, KeyMatch, VerifyError},
},
dpop::NoDPoP,
secrets::SecretString,
},
token::BearerAccessToken,
};
struct MockResponse {
status: StatusCode,
headers: HeaderMap,
body: Bytes,
}
impl HttpResponse for MockResponse {
type Error = Infallible;
fn status(&self) -> StatusCode {
self.status
}
fn headers(&self) -> HeaderMap {
self.headers.clone()
}
async fn body(self) -> Result<Bytes, Infallible> {
Ok(self.body)
}
}
struct MockHttpClient {
response: std::sync::Mutex<Option<MockResponse>>,
}
impl MockHttpClient {
fn new(response: MockResponse) -> Self {
Self {
response: std::sync::Mutex::new(Some(response)),
}
}
}
impl HttpClient for MockHttpClient {
type Response = MockResponse;
type Error = Infallible;
type ResponseError = Infallible;
async fn execute(
&self,
_request: http::Request<Bytes>,
) -> Result<MockResponse, Infallible> {
Ok(self
.response
.lock()
.unwrap()
.take()
.expect("MockHttpClient can only be called once"))
}
}
fn bearer_token(token: &str) -> AccessToken {
AccessToken::Bearer(BearerAccessToken::new(
SecretString::new(token),
crate::core::platform::SystemTime::now(),
None,
))
}
fn json_headers() -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(
http::header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
h
}
fn client() -> UserInfoClient<NoDPoP> {
UserInfoClient {
userinfo_endpoint: "https://op.example.com/userinfo"
.into_endpoint_url()
.unwrap(),
mtls_userinfo_endpoint: None,
dpop: NoDPoP,
jwt_validator: None,
_phantom: PhantomData,
}
}
#[tokio::test]
async fn successful_response() {
let body = serde_json::json!({
"sub": "248289761001",
"name": "Jane Doe",
"given_name": "Jane",
"family_name": "Doe",
"email": "janedoe@example.com",
"email_verified": true,
"picture": "http://example.com/janedoe/me.jpg"
});
let http = MockHttpClient::new(MockResponse {
status: StatusCode::OK,
headers: json_headers(),
body: Bytes::from(serde_json::to_vec(&body).unwrap()),
});
let result = client()
.get(&http, &bearer_token("tok"), "248289761001")
.await
.unwrap();
assert_eq!(result.sub, "248289761001");
assert_eq!(result.name.as_deref(), Some("Jane Doe"));
assert_eq!(result.given_name.as_deref(), Some("Jane"));
assert_eq!(result.family_name.as_deref(), Some("Doe"));
assert_eq!(result.email.as_deref(), Some("janedoe@example.com"));
assert_eq!(result.email_verified, Some(true));
assert_eq!(
result.picture.as_deref(),
Some("http://example.com/janedoe/me.jpg")
);
}
#[tokio::test]
async fn sub_mismatch_returns_error() {
let body = serde_json::json!({ "sub": "wrong-subject" });
let http = MockHttpClient::new(MockResponse {
status: StatusCode::OK,
headers: json_headers(),
body: Bytes::from(serde_json::to_vec(&body).unwrap()),
});
let err = client()
.get(&http, &bearer_token("tok"), "expected-subject")
.await
.unwrap_err();
assert!(matches!(err, UserInfoError::SubMismatch { .. }));
assert!(
err.to_string()
.contains("expected expected-subject, got wrong-subject")
);
}
#[tokio::test]
async fn non_success_status_returns_bad_status() {
let http = MockHttpClient::new(MockResponse {
status: StatusCode::FORBIDDEN,
headers: HeaderMap::new(),
body: Bytes::from_static(b"access denied"),
});
let err = client()
.get(&http, &bearer_token("tok"), "sub")
.await
.unwrap_err();
assert!(
matches!(&err, UserInfoError::BadStatus { status, body, .. }
if *status == StatusCode::FORBIDDEN && body == b"access denied"),
"expected BadStatus with FORBIDDEN, got {err:?}"
);
}
#[tokio::test]
async fn invalid_json_returns_deserialize_error() {
let http = MockHttpClient::new(MockResponse {
status: StatusCode::OK,
headers: json_headers(),
body: Bytes::from_static(b"not json"),
});
let err = client()
.get(&http, &bearer_token("tok"), "sub")
.await
.unwrap_err();
assert!(matches!(err, UserInfoError::Deserialize { .. }));
}
#[tokio::test]
async fn missing_sub_returns_deserialize_error() {
let body = serde_json::json!({ "name": "Jane Doe" });
let http = MockHttpClient::new(MockResponse {
status: StatusCode::OK,
headers: json_headers(),
body: Bytes::from(serde_json::to_vec(&body).unwrap()),
});
let err = client()
.get(&http, &bearer_token("tok"), "sub")
.await
.unwrap_err();
assert!(matches!(err, UserInfoError::Deserialize { .. }));
}
#[tokio::test]
async fn unknown_claims_are_ignored() {
let body = serde_json::json!({
"sub": "user1",
"custom_claim": "custom_value",
"org_id": 42
});
let http = MockHttpClient::new(MockResponse {
status: StatusCode::OK,
headers: json_headers(),
body: Bytes::from(serde_json::to_vec(&body).unwrap()),
});
let result = client()
.get(&http, &bearer_token("tok"), "user1")
.await
.unwrap();
assert_eq!(result.sub, "user1");
assert!(result.name.is_none());
}
#[tokio::test]
async fn typed_extra_claims() {
#[derive(Debug, Clone, Deserialize)]
struct MyClaims {
org_id: u64,
role: String,
}
let body = serde_json::json!({
"sub": "user1",
"org_id": 42,
"role": "admin"
});
let http = MockHttpClient::new(MockResponse {
status: StatusCode::OK,
headers: json_headers(),
body: Bytes::from(serde_json::to_vec(&body).unwrap()),
});
let client: UserInfoClient<NoDPoP, MyClaims> = UserInfoClient {
userinfo_endpoint: "https://op.example.com/userinfo"
.into_endpoint_url()
.unwrap(),
mtls_userinfo_endpoint: None,
dpop: NoDPoP,
jwt_validator: None,
_phantom: PhantomData,
};
let result = client
.get(&http, &bearer_token("tok"), "user1")
.await
.unwrap();
assert_eq!(result.sub, "user1");
assert_eq!(result.extra.org_id, 42);
assert_eq!(result.extra.role, "admin");
}
#[tokio::test]
async fn address_claim_deserialized() {
let body = serde_json::json!({
"sub": "user1",
"address": {
"formatted": "123 Main St\nAnytown, CA 90210",
"street_address": "123 Main St",
"locality": "Anytown",
"region": "CA",
"postal_code": "90210",
"country": "US"
}
});
let http = MockHttpClient::new(MockResponse {
status: StatusCode::OK,
headers: json_headers(),
body: Bytes::from(serde_json::to_vec(&body).unwrap()),
});
let result = client()
.get(&http, &bearer_token("tok"), "user1")
.await
.unwrap();
let addr = result.address.unwrap();
assert_eq!(addr.locality.as_deref(), Some("Anytown"));
assert_eq!(addr.region.as_deref(), Some("CA"));
assert_eq!(addr.postal_code.as_deref(), Some("90210"));
assert_eq!(addr.country.as_deref(), Some("US"));
}
#[tokio::test]
async fn jwt_content_type_returns_not_supported() {
let mut headers = HeaderMap::new();
headers.insert(
http::header::CONTENT_TYPE,
HeaderValue::from_static("application/jwt"),
);
let http = MockHttpClient::new(MockResponse {
status: StatusCode::OK,
headers,
body: Bytes::from_static(b"eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.sig"),
});
let err = client()
.get(&http, &bearer_token("tok"), "user1")
.await
.unwrap_err();
assert!(matches!(err, UserInfoError::JwtResponseNotSupported));
assert!(err.to_string().contains("application/jwt"));
}
#[tokio::test]
async fn jwt_content_type_with_charset_returns_not_supported() {
let mut headers = HeaderMap::new();
headers.insert(
http::header::CONTENT_TYPE,
HeaderValue::from_static("application/jwt; charset=utf-8"),
);
let http = MockHttpClient::new(MockResponse {
status: StatusCode::OK,
headers,
body: Bytes::from_static(b"eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.sig"),
});
let err = client()
.get(&http, &bearer_token("tok"), "user1")
.await
.unwrap_err();
assert!(matches!(err, UserInfoError::JwtResponseNotSupported));
}
#[tokio::test]
async fn unexpected_content_type_returns_error() {
let mut headers = HeaderMap::new();
headers.insert(
http::header::CONTENT_TYPE,
HeaderValue::from_static("text/html"),
);
let http = MockHttpClient::new(MockResponse {
status: StatusCode::OK,
headers,
body: Bytes::from_static(b"<html>not json</html>"),
});
let err = client()
.get(&http, &bearer_token("tok"), "user1")
.await
.unwrap_err();
assert!(matches!(err, UserInfoError::UnexpectedContentType { .. }));
assert!(err.to_string().contains("text/html"));
}
#[tokio::test]
async fn json_content_type_with_charset_succeeds() {
let body = serde_json::json!({ "sub": "user1" });
let mut headers = HeaderMap::new();
headers.insert(
http::header::CONTENT_TYPE,
HeaderValue::from_static("application/json; charset=utf-8"),
);
let http = MockHttpClient::new(MockResponse {
status: StatusCode::OK,
headers,
body: Bytes::from(serde_json::to_vec(&body).unwrap()),
});
let result = client()
.get(&http, &bearer_token("tok"), "user1")
.await
.unwrap();
assert_eq!(result.sub, "user1");
}
#[tokio::test]
async fn all_optional_claims_absent() {
let body = serde_json::json!({ "sub": "minimal" });
let http = MockHttpClient::new(MockResponse {
status: StatusCode::OK,
headers: json_headers(),
body: Bytes::from(serde_json::to_vec(&body).unwrap()),
});
let result = client()
.get(&http, &bearer_token("tok"), "minimal")
.await
.unwrap();
assert_eq!(result.sub, "minimal");
assert!(result.name.is_none());
assert!(result.email.is_none());
assert!(result.address.is_none());
assert!(result.updated_at.is_none());
}
#[tokio::test]
async fn missing_content_type_returns_error() {
let http = MockHttpClient::new(MockResponse {
status: StatusCode::OK,
headers: HeaderMap::new(),
body: Bytes::from_static(b"{\"sub\":\"user1\"}"),
});
let err = client()
.get(&http, &bearer_token("tok"), "user1")
.await
.unwrap_err();
assert!(matches!(err, UserInfoError::MissingContentType));
}
#[derive(Debug)]
struct AcceptAllVerifier;
impl JwsVerifier for AcceptAllVerifier {
type Error = crate::core::BoxedError;
fn key_match(&self, _key_match: &KeyMatch<'_>) -> Option<KeyMatchStrength> {
Some(KeyMatchStrength::ByAlgorithm)
}
async fn verify(
&self,
_input: &[u8],
_signature: &[u8],
_key: &KeyMatch<'_>,
) -> Result<(), VerifyError<Self::Error>> {
Ok(())
}
}
fn build_test_jwt(header: &serde_json::Value, claims: &serde_json::Value) -> String {
use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD};
let h = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(header).unwrap());
let c = BASE64_URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap());
let s = BASE64_URL_SAFE_NO_PAD.encode(b"fake-signature");
format!("{h}.{c}.{s}")
}
fn jwt_client() -> UserInfoClient<NoDPoP> {
let validator = JwtValidator::builder()
.verifier(BoxedJwsVerifier::new(AcceptAllVerifier))
.iss(ClaimCheck::required_value("https://op.example.com"))
.aud(ClaimCheck::required_value("my-client"))
.build();
UserInfoClient {
userinfo_endpoint: "https://op.example.com/userinfo"
.into_endpoint_url()
.unwrap(),
mtls_userinfo_endpoint: None,
dpop: NoDPoP,
jwt_validator: Some(validator),
_phantom: PhantomData,
}
}
fn jwt_response(jwt: &str) -> MockResponse {
let mut headers = HeaderMap::new();
headers.insert(
http::header::CONTENT_TYPE,
HeaderValue::from_static("application/jwt"),
);
MockResponse {
status: StatusCode::OK,
headers,
body: Bytes::from(jwt.to_owned()),
}
}
#[tokio::test]
async fn jwt_response_validated() {
let jwt = build_test_jwt(
&serde_json::json!({"alg": "RS256"}),
&serde_json::json!({
"sub": "user1",
"iss": "https://op.example.com",
"aud": "my-client",
"name": "Jane Doe",
"email": "jane@example.com"
}),
);
let http = MockHttpClient::new(jwt_response(&jwt));
let result = jwt_client()
.get(&http, &bearer_token("tok"), "user1")
.await
.unwrap();
assert_eq!(result.sub, "user1");
assert_eq!(result.name.as_deref(), Some("Jane Doe"));
assert_eq!(result.email.as_deref(), Some("jane@example.com"));
}
#[tokio::test]
async fn jwt_response_without_validator_returns_not_supported() {
let jwt = build_test_jwt(
&serde_json::json!({"alg": "RS256"}),
&serde_json::json!({"sub": "user1"}),
);
let http = MockHttpClient::new(jwt_response(&jwt));
let err = client()
.get(&http, &bearer_token("tok"), "user1")
.await
.unwrap_err();
assert!(matches!(err, UserInfoError::JwtResponseNotSupported));
}
#[tokio::test]
async fn jwt_response_invalid_utf8() {
let mut headers = HeaderMap::new();
headers.insert(
http::header::CONTENT_TYPE,
HeaderValue::from_static("application/jwt"),
);
let http = MockHttpClient::new(MockResponse {
status: StatusCode::OK,
headers,
body: Bytes::from_static(b"\xff\xfe"),
});
let err = jwt_client()
.get(&http, &bearer_token("tok"), "user1")
.await
.unwrap_err();
assert!(matches!(err, UserInfoError::MalformedJwtResponseBody));
}
#[tokio::test]
async fn jwt_response_sub_mismatch() {
let jwt = build_test_jwt(
&serde_json::json!({"alg": "RS256"}),
&serde_json::json!({
"sub": "wrong-user",
"iss": "https://op.example.com",
"aud": "my-client"
}),
);
let http = MockHttpClient::new(jwt_response(&jwt));
let err = jwt_client()
.get(&http, &bearer_token("tok"), "expected-user")
.await
.unwrap_err();
assert!(matches!(err, UserInfoError::SubMismatch { .. }));
}
}