mod jwk;
mod jws;
mod misc;
mod store;
use misc::DynErr;
use serde::Deserialize;
use std::{
sync::Arc,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use thiserror::Error;
use url::Url;
use crate::misc::DiscoveryDoc;
pub use crate::{misc::ResponseMode, store::*};
#[derive(Debug, Error)]
pub enum BuildError {
#[error("the configured server URL cannot be used")]
InvalidServer,
#[error("the configured redirect URI cannot be used")]
InvalidRedirectUri,
#[error("the configured server is not an origin (contains additional components)")]
ServerNotAnOrigin,
#[cfg(not(feature = "simple-store"))]
#[error("no default store is available")]
NoDefaultStore,
}
#[derive(Debug, Error)]
pub enum StartAuthError {
#[error("could not fetch discovery document: {0}")]
FetchDiscovery(#[source] FetchError),
#[error("could not parse discovery document: {0}")]
ParseDiscovery(#[source] serde_json::Error),
#[error("could not generate nonce: {0}")]
GenerateNonce(#[source] DynErr),
}
#[derive(Debug, Error)]
pub enum VerifyError {
#[error("could not fetch discovery document: {0}")]
FetchDiscovery(#[source] FetchError),
#[error("could not parse discovery document: {0}")]
ParseDiscovery(#[source] serde_json::Error),
#[error("could not fetch keys document: {0}")]
FetchJwks(#[source] FetchError),
#[error("could not parse keys document: {0}")]
ParseJwks(#[source] serde_json::Error),
#[error("could not verify token signature: {0}")]
Signature(#[from] jws::VerifyError),
#[error("invalid token payload: {0}")]
InvalidPayload(#[source] serde_json::Error),
#[error("the token issuer did not match")]
IssuerInvalid,
#[error("the token audience did not match")]
AudienceInvalid,
#[error("the token has expired")]
TokenExpired,
#[error("the token issue time is in the future")]
IssuedInTheFuture,
#[error("the server changed the email address, but is not trusted")]
UntrustedServerChangedEmail,
#[error("could not verify the session: {0}")]
VerifySession(#[source] DynErr),
#[error("the session is invalid or has expired")]
InvalidSession,
}
#[derive(Clone)]
pub struct Builder {
store: Option<Arc<dyn Store>>,
server: Option<Url>,
trusted: bool,
redirect_uri: Url,
response_mode: ResponseMode,
leeway: Duration,
}
impl Builder {
fn new(redirect_uri: Url) -> Self {
Builder {
store: None,
server: None,
trusted: true,
redirect_uri,
response_mode: ResponseMode::default(),
leeway: Duration::from_secs(180),
}
}
pub fn store(mut self, store: Arc<dyn Store>) -> Self {
self.store = Some(store);
self
}
pub fn broker(mut self, url: Url) -> Self {
self.server = Some(url);
self.trusted = true;
self
}
pub fn idp(mut self, url: Url) -> Self {
self.server = Some(url);
self.trusted = false;
self
}
pub fn response_mode(mut self, mode: ResponseMode) -> Self {
self.response_mode = mode;
self
}
pub fn leeway(mut self, dur: Duration) -> Self {
self.leeway = dur;
self
}
pub fn build(self) -> Result<Client, BuildError> {
let store = match self.store {
Some(store) => store,
#[cfg(feature = "simple-store")]
None => Arc::new(MemoryStore::default()),
#[cfg(not(feature = "simple-store"))]
None => return Err(BuildError::NoDefaultStore),
};
let server = self
.server
.unwrap_or_else(|| "https://broker.portier.io".parse().unwrap());
let server_origin = server.origin();
if !server_origin.is_tuple() {
return Err(BuildError::InvalidServer);
}
let client_origin = self.redirect_uri.origin();
if !client_origin.is_tuple() {
return Err(BuildError::InvalidRedirectUri);
}
let client_id = client_origin.ascii_serialization();
let server_id = server_origin.ascii_serialization();
let server_str = server.as_str();
if !(server_str == server_id
|| (server_str.len() == server_id.len() + 1
&& server_str.starts_with(&server_id)
&& server_str.ends_with('/')))
{
return Err(BuildError::ServerNotAnOrigin);
}
let mut discovery_url = server;
discovery_url.set_path("/.well-known/openid-configuration");
Ok(Client {
store,
server_id,
discovery_url,
trusted: self.trusted,
redirect_uri: self.redirect_uri,
client_id,
response_mode: self.response_mode,
leeway: self.leeway,
})
}
}
#[derive(Clone)]
pub struct Client {
store: Arc<dyn Store>,
server_id: String,
discovery_url: Url,
trusted: bool,
redirect_uri: Url,
client_id: String,
response_mode: ResponseMode,
leeway: Duration,
}
impl Client {
pub fn builder(redirect_uri: Url) -> Builder {
Builder::new(redirect_uri)
}
#[cfg(feature = "simple-store")]
pub fn new(redirect_uri: Url) -> Self {
Builder::new(redirect_uri).build().unwrap()
}
pub async fn start_auth(&self, email: &str) -> Result<Url, StartAuthError> {
let discovery = self
.store
.fetch(self.discovery_url.clone())
.await
.map_err(StartAuthError::FetchDiscovery)?;
let discovery: DiscoveryDoc =
serde_json::from_slice(&discovery).map_err(StartAuthError::ParseDiscovery)?;
let nonce = self
.store
.new_nonce(email.to_owned())
.await
.map_err(StartAuthError::GenerateNonce)?;
let mut auth_url = discovery.authorization_endpoint;
auth_url
.query_pairs_mut()
.append_pair("login_hint", email)
.append_pair("scope", "openid email")
.append_pair("nonce", &nonce)
.append_pair("response_type", "id_token")
.append_pair("response_mode", self.response_mode.as_str())
.append_pair("client_id", &self.client_id)
.append_pair("redirect_uri", self.redirect_uri.as_str());
Ok(auth_url)
}
pub async fn verify(&self, token: &str) -> Result<String, VerifyError> {
let discovery = self
.store
.fetch(self.discovery_url.clone())
.await
.map_err(VerifyError::FetchDiscovery)?;
let discovery: DiscoveryDoc =
serde_json::from_slice(&discovery).map_err(VerifyError::ParseDiscovery)?;
let jwks = self
.store
.fetch(discovery.jwks_uri)
.await
.map_err(VerifyError::FetchJwks)?;
let jwks: jwk::KeySet = serde_json::from_slice(&jwks).map_err(VerifyError::ParseJwks)?;
#[derive(Deserialize)]
struct Payload {
iss: String,
aud: String,
email: String,
email_original: Option<String>,
iat: u64,
exp: u64,
nonce: String,
}
let payload = jws::verify(token, &jwks.keys)?;
let payload: Payload =
serde_json::from_slice(&payload).map_err(VerifyError::InvalidPayload)?;
if payload.iss != self.server_id {
return Err(VerifyError::IssuerInvalid);
}
if payload.aud != self.client_id {
return Err(VerifyError::AudienceInvalid);
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("current system time is before Unix epoch")
.as_secs();
let exp_stretched = payload
.exp
.checked_add(self.leeway.as_secs())
.unwrap_or(u64::min_value());
if exp_stretched < now {
return Err(VerifyError::TokenExpired);
}
let iat_stretched = payload
.iat
.checked_sub(self.leeway.as_secs())
.unwrap_or(u64::max_value());
if now < iat_stretched {
return Err(VerifyError::IssuedInTheFuture);
}
if !self.trusted {
match payload.email_original {
None => {}
Some(ref orig) if orig == &payload.email => {}
Some(_) => return Err(VerifyError::UntrustedServerChangedEmail),
}
}
let email_original = match payload.email_original {
Some(email) => email,
None => payload.email.clone(),
};
if !self
.store
.consume_nonce(payload.nonce, email_original)
.await
.map_err(VerifyError::VerifySession)?
{
return Err(VerifyError::InvalidSession);
}
Ok(payload.email)
}
}