pub mod did_document;
pub mod error;
pub mod holder;
mod issuer;
mod metadata;
pub mod presentation;
use std::fmt;
use std::time::{Duration, Instant};
use http::header::{ACCEPT, CONTENT_TYPE};
use http::{Method, StatusCode};
use jsonwebtoken::Header;
use secrecy::{ExposeSecret, SecretString};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use url::{Url, form_urlencoded};
use error::{InvalidInput, Malformation, NutsAuthError, Step, bounded};
use holder::Holder;
use presentation::PresentationDefinition;
use crate::nuts_auth::issuer::{Issuer, repeats_no_name};
pub const GRANT_TYPE: &str = "vp_token-bearer";
pub const CREDENTIALS_CONTEXT: &str = "https://www.w3.org/2018/credentials/v1";
pub const PRESENTATION_TYPE: &str = "VerifiablePresentation";
pub const DPOP_HEADER: &str = "DPoP";
pub const DPOP_NONCE_HEADER: &str = "DPoP-Nonce";
pub const USE_DPOP_NONCE: &str = "use_dpop_nonce";
pub const DPOP_TOKEN_TYPE: &str = "DPoP";
pub const PRESENTATION_LIFETIME: Duration = Duration::from_secs(5);
pub const RESPONSE_LIMIT: usize = 64 * 1024;
const JSON: &str = "application/json";
pub trait DpopProver: Send + Sync {
fn algorithm(&self) -> &'static str;
fn proof(&self, method: &Method, url: &Url) -> Result<String, error::ProofError>;
fn nonce(&self, url: &Url, nonce: &str);
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Grant {
issuer: Issuer,
scope: String,
client_id: Option<String>,
}
impl Grant {
pub fn new(authorization_server: &str, scope: &str) -> Result<Self, InvalidInput> {
let issuer = Issuer::parse(authorization_server)
.map_err(|_invalid| InvalidInput::AuthorizationServer)?;
let token = |token: &str| {
!token.is_empty()
&& token
.bytes()
.all(|b| b == 0x21 || (0x23..=0x5B).contains(&b) || (0x5D..=0x7E).contains(&b))
};
if !scope.split(' ').all(token) {
return Err(InvalidInput::Scope);
}
Ok(Self {
issuer,
scope: scope.to_owned(),
client_id: None,
})
}
pub fn with_client_id(mut self, client_id: impl Into<String>) -> Result<Self, InvalidInput> {
let client_id = client_id.into();
if client_id.is_empty() || client_id.chars().any(char::is_control) {
return Err(InvalidInput::ClientId);
}
self.client_id = Some(client_id);
Ok(self)
}
#[must_use]
pub fn authorization_server(&self) -> &str {
self.issuer.as_str()
}
#[must_use]
pub fn scope(&self) -> &str {
&self.scope
}
#[must_use]
pub fn client_id(&self) -> Option<&str> {
self.client_id.as_deref()
}
}
#[derive(Clone)]
pub struct AccessToken {
token: SecretString,
expires_in: Option<Duration>,
scope: Option<String>,
}
impl AccessToken {
#[must_use]
pub fn token(&self) -> &SecretString {
&self.token
}
#[must_use]
pub fn expires_in(&self) -> Option<Duration> {
self.expires_in
}
#[must_use]
pub fn scope(&self) -> Option<&str> {
self.scope.as_deref()
}
}
impl fmt::Debug for AccessToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AccessToken")
.field("expires_in", &self.expires_in)
.field("scope", &self.scope)
.finish_non_exhaustive()
}
}
#[derive(Clone)]
pub struct NutsClient {
http: reqwest::Client,
}
impl fmt::Debug for NutsClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NutsClient").finish_non_exhaustive()
}
}
#[derive(Serialize)]
struct PresentationClaims<'a> {
iss: &'a str,
sub: &'a str,
aud: &'a str,
nbf: i64,
exp: i64,
jti: String,
nonce: String,
vp: Presentation<'a>,
}
#[derive(Serialize)]
struct Presentation<'a> {
#[serde(rename = "@context")]
context: [&'static str; 1],
#[serde(rename = "type")]
types: [&'static str; 1],
#[serde(rename = "verifiableCredential")]
credentials: Vec<&'a str>,
}
#[derive(Deserialize)]
struct TokenResponse {
access_token: String,
token_type: String,
expires_in: Option<u64>,
scope: Option<String>,
}
#[derive(Deserialize)]
struct ErrorResponse {
error: String,
error_description: Option<String>,
}
struct Answer {
status: StatusCode,
nonce: Option<String>,
json: bool,
body: Vec<u8>,
}
impl NutsClient {
#[must_use]
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
pub async fn request_access_token(
&self,
grant: &Grant,
holder: &Holder,
prover: &dyn DpopProver,
timeout: Duration,
) -> Result<AccessToken, NutsAuthError> {
let deadline = Instant::now().checked_add(timeout);
let algorithm = holder.key().algorithm_name();
let raw: metadata::Raw = self
.get(grant.issuer.metadata_url(), Step::Metadata, deadline)
.await?;
let metadata = metadata::read(&raw, &grant.issuer, algorithm, prover.algorithm())?;
let mut definition_url = metadata.definition_endpoint.clone();
definition_url
.query_pairs_mut()
.append_pair("scope", &grant.scope);
let definition: PresentationDefinition =
self.get(definition_url, Step::Definition, deadline).await?;
let submission =
presentation::submission(&definition, holder, uuid::Uuid::new_v4().to_string())?;
let submission = serde_json::to_string(&submission).map_err(NutsAuthError::Submission)?;
let now = jiff::Timestamp::now().as_second();
if let Some(expired) = holder
.credentials()
.iter()
.find(|credential| credential.expires().is_some_and(|exp| exp <= now))
{
return Err(NutsAuthError::Expired {
descriptor: expired.descriptor().to_owned(),
});
}
let mut resend = true;
loop {
let assertion = sign(holder, grant)?;
let body = token_form(grant, &assertion, &submission);
let proof = prover.proof(&Method::POST, &metadata.token_endpoint)?;
let request = self
.http
.post(metadata.token_endpoint.clone())
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(ACCEPT, JSON)
.header(DPOP_HEADER, proof)
.body(body);
let answer = send(request, Step::Token, deadline).await?;
if let Some(nonce) = &answer.nonce {
prover.nonce(&metadata.token_endpoint, nonce);
}
if resend && answer.nonce.is_some() && demands_nonce(&answer) {
resend = false;
continue;
}
return token(&answer);
}
}
async fn get<T: DeserializeOwned>(
&self,
url: Url,
step: Step,
deadline: Option<Instant>,
) -> Result<T, NutsAuthError> {
let answer = send(self.http.get(url).header(ACCEPT, JSON), step, deadline).await?;
if answer.status != StatusCode::OK {
return Err(NutsAuthError::Status {
step,
status: answer.status,
});
}
json(&answer, step)
}
}
async fn send(
request: reqwest::RequestBuilder,
step: Step,
deadline: Option<Instant>,
) -> Result<Answer, NutsAuthError> {
let remaining = deadline
.and_then(|deadline| deadline.checked_duration_since(Instant::now()))
.filter(|remaining| !remaining.is_zero())
.ok_or(NutsAuthError::Timeout { step })?;
let mut response = request
.timeout(remaining)
.send()
.await
.map_err(|error| NutsAuthError::transport(step, error))?;
let status = response.status();
let headers = response.headers();
let nonce = headers
.get(DPOP_NONCE_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let json = headers
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(';').next())
.is_some_and(|media| media.trim().eq_ignore_ascii_case(JSON));
let mut body = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|error| NutsAuthError::transport(step, error))?
{
if body.len().saturating_add(chunk.len()) > RESPONSE_LIMIT {
return Err(NutsAuthError::Malformed {
step,
malformation: Malformation::TooLarge {
limit: RESPONSE_LIMIT,
},
});
}
body.extend_from_slice(&chunk);
}
Ok(Answer {
status,
nonce,
json,
body,
})
}
fn json<T: DeserializeOwned>(answer: &Answer, step: Step) -> Result<T, NutsAuthError> {
let malformed = |malformation| NutsAuthError::Malformed { step, malformation };
if !answer.json {
return Err(malformed(Malformation::MediaType));
}
repeats_no_name(&answer.body).map_err(|error| {
malformed(if error.is_data() {
Malformation::RepeatedName {
line: error.line(),
column: error.column(),
}
} else {
Malformation::of(&error)
})
})?;
serde_json::from_slice(&answer.body).map_err(|error| malformed(Malformation::of(&error)))
}
fn sign(holder: &Holder, grant: &Grant) -> Result<SecretString, NutsAuthError> {
let nbf = jiff::Timestamp::now().as_second();
let lifetime = i64::try_from(PRESENTATION_LIFETIME.as_secs()).unwrap_or(i64::MAX);
let did = holder.did().as_str();
let claims = PresentationClaims {
iss: did,
sub: did,
aud: grant.issuer.as_str(),
nbf,
exp: nbf.saturating_add(lifetime),
jti: format!("urn:uuid:{}", uuid::Uuid::new_v4()),
nonce: uuid::Uuid::new_v4().simple().to_string(),
vp: Presentation {
context: [CREDENTIALS_CONTEXT],
types: [PRESENTATION_TYPE],
credentials: holder
.credentials()
.iter()
.map(|credential| credential.jwt().expose_secret())
.collect(),
},
};
let mut header = Header::new(holder.key().algorithm());
header.kid = Some(holder.key().kid().to_owned());
jsonwebtoken::encode(&header, &claims, holder.key().private())
.map(SecretString::from)
.map_err(NutsAuthError::Sign)
}
fn token_form(grant: &Grant, assertion: &SecretString, submission: &str) -> String {
let mut form = form_urlencoded::Serializer::new(String::new());
form.append_pair("grant_type", GRANT_TYPE)
.append_pair("assertion", assertion.expose_secret())
.append_pair("presentation_submission", submission)
.append_pair("scope", &grant.scope);
if let Some(client_id) = &grant.client_id {
form.append_pair("client_id", client_id);
}
form.finish()
}
fn demands_nonce(answer: &Answer) -> bool {
answer.status == StatusCode::BAD_REQUEST
&& serde_json::from_slice::<ErrorResponse>(&answer.body)
.is_ok_and(|refused| refused.error == USE_DPOP_NONCE)
}
fn token(answer: &Answer) -> Result<AccessToken, NutsAuthError> {
let status = answer.status;
if status == StatusCode::OK {
let token: TokenResponse = json(answer, Step::Token)?;
if !token.token_type.eq_ignore_ascii_case(DPOP_TOKEN_TYPE) {
return Err(NutsAuthError::TokenType {
token_type: bounded(&token.token_type),
});
}
return Ok(AccessToken {
token: SecretString::from(token.access_token),
expires_in: token.expires_in.map(Duration::from_secs),
scope: token.scope,
});
}
if (status == StatusCode::BAD_REQUEST || status == StatusCode::UNAUTHORIZED)
&& let Ok(refused) = serde_json::from_slice::<ErrorResponse>(&answer.body)
{
return Err(NutsAuthError::Refused {
status,
error: bounded(&refused.error),
description: refused.error_description.as_deref().map(bounded),
});
}
Err(NutsAuthError::Status {
step: Step::Token,
status,
})
}