1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375
//! A client for the Portier protocol.
//!
//! The primary interface of this package is the `Client`. Construct one using `Client::builder` or
//! `Client::new`. See also the short example using the Rocket framework in
//! [`example/src/main.rs`](https://github.com/portier/portier-rs/blob/main/example/src/main.rs).
//!
//! Some data storage is needed to implement the protocol. This is used for tracking short-lived
//! login sessions, and caching of basic HTTP GET requests. The `Store` trait facilitates this, and
//! by default, an in-memory store is used. This will work fine for simple single-process
//! applications, but if you intend to run multiple workers, an alternative Store must be
//! implemented. (In the future, we may offer some alternatives for common databases.
//! Contributions are welcome!)
//!
//! Some applications may need multiple configurations and `Client` instances, for example because
//! they serve multiple domains. In this case, we recommended creating short-lived `Client`s and
//! sharing the `Store` between them.
//!
//! The crate feature `simple-store` is enabled by default, but can be disabled to remove the Tokio
//! and Hyper dependencies. When disabled, the default `MemoryStore` will also not be available,
//! and a custom `Store` implementation must be provided.
//!
//! The minimum required Rust version is 1.46.
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::*};
/// Errors that can result from `Builder::build`.
#[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,
}
/// Errors that can result from `Client::start_auth`.
#[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),
}
/// Errors that can result from `Client::verify`.
#[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,
}
/// A builder to configure a `Client`.
#[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),
}
}
/// Use the given `Store` for cache and session storage.
///
/// If no store is specified, a default `MemoryStore` is created. This type of store has some
/// limitations. See the documentation for `MemoryStore` for details.
pub fn store(mut self, store: Arc<dyn Store>) -> Self {
self.store = Some(store);
self
}
/// Configure the client to use a trusted broker.
///
/// This allows you to override the default broker `https://broker.portier.io` with your own.
/// The `url` must be an origin only. (Only scheme, host, and optionally port. No path, query
/// string, etc.)
pub fn broker(mut self, url: Url) -> Self {
self.server = Some(url);
self.trusted = true;
self
}
/// Configure the client to use an untrusted identity provider.
///
/// This is usually only used when implementing a broker. For configuring a relying party to
/// use a custom broker, see `Builder::broker` instead.
pub fn idp(mut self, url: Url) -> Self {
self.server = Some(url);
self.trusted = false;
self
}
/// Configure the response mode to use. The default is `FormPost`.
pub fn response_mode(mut self, mode: ResponseMode) -> Self {
self.response_mode = mode;
self
}
/// Configure the leeway to allow for timestamps in tokens. The default is 3 minutes.
pub fn leeway(mut self, dur: Duration) -> Self {
self.leeway = dur;
self
}
/// Verify the configuration and build the client.
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();
// Verify server URL is an origin only. We can compare it with the ASCII origin, because
// `Url` is internally ASCII as well. It may contain a `/` path, though.
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,
})
}
}
/// A client for performing Portier authentication.
///
/// Create a client using either `Client::builder` or `Client::new`. Sharing a client can be done
/// simply by reference, even across threads. All methods take an immutable reference to `self`
/// only.
///
/// If necessary, a client can also be cloned. This is not cheap, however, because settings within
/// are also cloned. The exception is the store, which is shared between clones.
#[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 {
/// Create a builder-style struct to configure a Client.
pub fn builder(redirect_uri: Url) -> Builder {
Builder::new(redirect_uri)
}
/// Create a client with default settings.
///
/// This uses a `MemoryStore`, which has some limitations. See the documentation for
/// `MemoryStore` for details.
#[cfg(feature = "simple-store")]
pub fn new(redirect_uri: Url) -> Self {
Builder::new(redirect_uri).build().unwrap()
}
/// Create a login session for the given email, and return a URL to redirect the user agent
/// (browser) to so authentication can continue.
///
/// If performing the redirect in the HTTP response, the recommended method is to send a 303
/// HTTP status code with the `Location` header set to the URL. But other solutions are
/// possible, such as fetching this URL using a request from client-side JavaScript.
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)
}
/// Verify `token` and return a verified email address.
///
/// The token is delivered by the user agent (browser) directly according to the `redirect_uri`
/// and `response_mode` configured when the `Client` was created.
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)?;
// Basic token signature verification, parsing, and claim validation.
#[derive(Deserialize)]
struct Payload {
iss: String,
aud: String,
email: String,
email_original: Option<String>,
#[serde(deserialize_with = "misc::deserialize_timestamp")]
iat: u64,
#[serde(deserialize_with = "misc::deserialize_timestamp")]
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 verifying an IdP token, it can't change the email address per spec. The spec assumes
// the client is a Broker, in this case, and has already done normalization.
if !self.trusted {
match payload.email_original {
None => {}
Some(ref orig) if orig == &payload.email => {}
Some(_) => return Err(VerifyError::UntrustedServerChangedEmail),
}
}
// Check the pair (nonce, email_original) exists in the store.
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)
}
}