use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use crate::adapters::secondary::oauth::http_body::{describe, read_capped};
use crate::application::oauth::{Refusal, SourceError, TokenClaims, TokenSource};
#[derive(Debug, Clone)]
pub struct IntrospectionSource {
endpoint: Arc<str>,
client_id: Arc<str>,
client_secret: Arc<str>,
http: reqwest::Client,
}
impl IntrospectionSource {
#[must_use]
pub fn new(
endpoint: impl Into<Arc<str>>,
client_id: impl Into<Arc<str>>,
client_secret: impl Into<Arc<str>>,
) -> Self {
Self {
endpoint: endpoint.into(),
client_id: client_id.into(),
client_secret: client_secret.into(),
http: reqwest::Client::new(),
}
}
#[must_use]
pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
self.http = http;
self
}
#[must_use]
pub fn endpoint(&self) -> &str {
&self.endpoint
}
}
impl TokenSource for IntrospectionSource {
async fn claims(&self, presented: &str) -> Result<TokenClaims, SourceError> {
let form = [("token", presented), ("token_type_hint", "access_token")];
let response = self
.http
.post(&*self.endpoint)
.basic_auth(&*self.client_id, Some(&*self.client_secret))
.form(&form)
.send()
.await
.map_err(|failure| SourceError::Unavailable(describe(failure)))?;
let status = response.status();
if !status.is_success() {
return Err(SourceError::Unavailable(format!(
"introspection responded {}",
status.as_u16(),
)));
}
let body = read_capped(response)
.await
.map_err(SourceError::Unavailable)?;
let parsed: IntrospectionResponse = serde_json::from_slice(&body)
.map_err(|failure| SourceError::Unavailable(describe(failure)))?;
claims_from(parsed)
}
}
#[derive(Debug, Deserialize)]
struct IntrospectionResponse {
active: Option<bool>,
#[serde(default)]
scope: Option<String>,
#[serde(default)]
username: Option<String>,
#[serde(default)]
iss: Option<String>,
#[serde(default)]
sub: Option<String>,
#[serde(default)]
aud: Option<Audience>,
#[serde(default)]
exp: Option<u64>,
#[serde(default)]
nbf: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum Audience {
One(String),
Many(Vec<String>),
}
fn claims_from(response: IntrospectionResponse) -> Result<TokenClaims, SourceError> {
match response.active {
Some(true) => {}
Some(false) => {
return Err(SourceError::Refused(Refusal::Unscoped));
}
None => {
return Err(SourceError::Unavailable(
"the introspection response has no `active` field".to_owned(),
));
}
}
let mut claims = TokenClaims::new();
if let Some(subject) = response.sub.or(response.username) {
claims = claims.with_subject(subject);
}
if let Some(issuer) = response.iss {
claims = claims.with_issuer(issuer);
}
match response.aud {
Some(Audience::One(one)) => claims = claims.with_audience_string(one),
Some(Audience::Many(many)) => {
for audience in many {
claims = claims.with_audience_string(audience);
}
}
None => {}
}
if let Some(exp) = response.exp.and_then(system_time) {
claims = claims.with_expiry(exp);
}
if let Some(nbf) = response.nbf.and_then(system_time) {
claims = claims.with_not_before(nbf);
}
if let Some(scope) = response.scope {
for scope in scope.split(' ') {
if !scope.is_empty() {
claims = claims.with_scope(scope);
}
}
}
Ok(claims)
}
fn system_time(seconds: u64) -> Option<SystemTime> {
UNIX_EPOCH.checked_add(Duration::from_secs(seconds))
}
#[cfg(test)]
#[path = "introspection/tests.rs"]
mod tests;