use crate::error::{ConfigReason, ConflictReason, CryptoReason, InvalidRequestReason};
use rand_core::CryptoRng;
use time::{Duration, OffsetDateTime};
use url::Url;
use webauthn_rs::prelude::{
AuthenticationResult, CreationChallengeResponse, DiscoverableAuthentication, DiscoverableKey,
Passkey, PasskeyAuthentication, PasskeyRegistration, PublicKeyCredential,
RegisterPublicKeyCredential, RequestChallengeResponse,
};
use webauthn_rs::{Webauthn, WebauthnBuilder};
use crate::Error;
use crate::account::Account;
use crate::config::Config;
use crate::ids::{CeremonyId, SessionId, UserId};
use crate::token::{self, SecretToken, TokenHash};
pub use webauthn_rs;
pub const CEREMONY_TTL: Duration = Duration::minutes(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Binding {
Session {
session: SessionId,
user: UserId,
},
Browser {
token_hash: TokenHash,
user: Option<UserId>,
},
}
impl Binding {
#[must_use]
pub const fn user(&self) -> Option<UserId> {
match *self {
Binding::Session { user, .. } => Some(user),
Binding::Browser { user, .. } => user,
}
}
#[must_use]
pub const fn session(&self) -> Option<SessionId> {
match *self {
Binding::Session { session, .. } => Some(session),
Binding::Browser { .. } => None,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[must_use = "store the ceremony to finish it later"]
pub struct Ceremony<S> {
id: CeremonyId,
state: S,
binding: Binding,
expires_at: OffsetDateTime,
}
impl<S> Ceremony<S> {
#[must_use]
pub fn id(&self) -> CeremonyId {
self.id
}
#[must_use]
pub fn binding(&self) -> &Binding {
&self.binding
}
#[must_use]
pub fn expires_at(&self) -> OffsetDateTime {
self.expires_at
}
pub fn into_state(
self,
session: SessionId,
user: UserId,
now: OffsetDateTime,
) -> Result<S, Error> {
let bound = self.binding == Binding::Session { session, user };
if self.expires_at <= now || !bound {
return Err(UNKNOWN);
}
Ok(self.state)
}
pub fn into_browser_state(self, binding_token: &str, now: OffsetDateTime) -> Result<S, Error> {
let bound = match self.binding {
Binding::Browser { token_hash, .. } => token_hash.verify(binding_token),
Binding::Session { .. } => false,
};
if self.expires_at <= now || !bound {
return Err(UNKNOWN);
}
Ok(self.state)
}
}
const UNKNOWN: Error = Error::InvalidRequest(InvalidRequestReason::UnknownCeremony);
#[derive(Debug)]
#[must_use = "send the challenge and store the ceremony"]
#[non_exhaustive]
pub struct Started<C, S> {
pub challenge: C,
pub ceremony: Ceremony<S>,
}
#[derive(Debug)]
#[must_use = "send the challenge and the binding cookie, and store the ceremony"]
#[non_exhaustive]
pub struct SignInStarted<S> {
pub challenge: RequestChallengeResponse,
pub ceremony: Ceremony<S>,
pub binding_token: SecretToken,
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct RegistrationRequest<'a> {
pub user: UserId,
pub user_name: &'a str,
pub display_name: &'a str,
pub session: SessionId,
pub existing: &'a [Passkey],
}
impl<'a> RegistrationRequest<'a> {
#[must_use]
pub fn new(
user: UserId,
user_name: &'a str,
display_name: &'a str,
session: SessionId,
) -> Self {
Self {
user,
user_name,
display_name,
session,
existing: &[],
}
}
#[must_use]
pub fn for_account(account: &'a Account, session: SessionId) -> Self {
Self::new(
account.id,
account.email.as_str(),
&account.display_name,
session,
)
}
#[must_use]
pub fn excluding(mut self, existing: &'a [Passkey]) -> Self {
self.existing = existing;
self
}
}
#[derive(Debug)]
pub struct RelyingParty {
webauthn: Webauthn,
}
impl RelyingParty {
pub fn new(config: &Config) -> Result<Self, Error> {
Self::from_parts(config.rp_id(), config.rp_name(), config.origin())
}
pub fn from_parts(rp_id: &str, rp_name: &str, origin: &str) -> Result<Self, Error> {
let origin = Url::parse(origin).map_err(|_| Error::Config(ConfigReason::OriginNotUrl))?;
let webauthn = WebauthnBuilder::new(rp_id, &origin)
.and_then(|builder| builder.rp_name(rp_name).build())
.map_err(|_| Error::Config(ConfigReason::RpIdMismatch))?;
Ok(Self { webauthn })
}
pub fn start_registration<R: CryptoRng + ?Sized>(
&self,
request: RegistrationRequest<'_>,
now: OffsetDateTime,
rng: &mut R,
) -> Result<Started<CreationChallengeResponse, PasskeyRegistration>, Error> {
let RegistrationRequest {
user,
user_name,
display_name,
session,
existing,
} = request;
let exclude = existing.iter().map(|key| key.cred_id().clone()).collect();
let (challenge, state) = self
.webauthn
.start_passkey_registration(user.into_uuid(), user_name, display_name, Some(exclude))
.map_err(|_| Error::Crypto(CryptoReason::WebauthnRegistrationStart))?;
Ok(Started {
challenge,
ceremony: ceremony(state, Binding::Session { session, user }, now, rng),
})
}
pub fn finish_registration(
&self,
credential: &RegisterPublicKeyCredential,
state: &PasskeyRegistration,
existing: &[Passkey],
) -> Result<Passkey, Error> {
let passkey = self
.webauthn
.finish_passkey_registration(credential, state)
.map_err(|_| Error::InvalidRequest(InvalidRequestReason::PasskeyRejected))?;
if existing
.iter()
.any(|key| key.cred_id() == passkey.cred_id())
{
return Err(Error::Conflict(ConflictReason::PasskeyExists));
}
Ok(passkey)
}
pub fn start_authentication<R: CryptoRng + ?Sized>(
&self,
user: UserId,
passkeys: &[Passkey],
session: SessionId,
now: OffsetDateTime,
rng: &mut R,
) -> Result<Started<RequestChallengeResponse, PasskeyAuthentication>, Error> {
if passkeys.is_empty() {
return Err(Error::InvalidRequest(InvalidRequestReason::NoPasskey));
}
let (challenge, state) = self
.webauthn
.start_passkey_authentication(passkeys)
.map_err(|_| Error::Crypto(CryptoReason::WebauthnAuthenticationStart))?;
Ok(Started {
challenge,
ceremony: ceremony(state, Binding::Session { session, user }, now, rng),
})
}
pub fn finish_authentication(
&self,
credential: &PublicKeyCredential,
state: &PasskeyAuthentication,
) -> Result<AuthenticationResult, Error> {
self.webauthn
.finish_passkey_authentication(credential, state)
.map_err(|_| Error::SignInFailed)
}
pub fn start_sign_in<R: CryptoRng + ?Sized>(
&self,
user: UserId,
passkeys: &[Passkey],
now: OffsetDateTime,
rng: &mut R,
) -> Result<SignInStarted<PasskeyAuthentication>, Error> {
if passkeys.is_empty() {
return Err(Error::InvalidRequest(InvalidRequestReason::NoPasskey));
}
let (challenge, state) = self
.webauthn
.start_passkey_authentication(passkeys)
.map_err(|_| Error::Crypto(CryptoReason::WebauthnAuthenticationStart))?;
Ok(browser_ceremony(challenge, state, Some(user), now, rng))
}
pub fn start_discoverable<R: CryptoRng + ?Sized>(
&self,
now: OffsetDateTime,
rng: &mut R,
) -> Result<SignInStarted<DiscoverableAuthentication>, Error> {
let (challenge, state) = self
.webauthn
.start_discoverable_authentication()
.map_err(|_| Error::Crypto(CryptoReason::WebauthnDiscoverableStart))?;
Ok(browser_ceremony(challenge, state, None, now, rng))
}
pub fn identify_discoverable(&self, credential: &PublicKeyCredential) -> Result<UserId, Error> {
self.webauthn
.identify_discoverable_authentication(credential)
.map(|(user, _)| UserId::from_uuid(user))
.map_err(|_| Error::SignInFailed)
}
pub fn finish_discoverable(
&self,
credential: &PublicKeyCredential,
state: DiscoverableAuthentication,
user: UserId,
passkeys: &[Passkey],
) -> Result<AuthenticationResult, Error> {
if self.identify_discoverable(credential)? != user {
return Err(Error::SignInFailed);
}
let keys: Vec<DiscoverableKey> = passkeys.iter().map(DiscoverableKey::from).collect();
self.webauthn
.finish_discoverable_authentication(credential, state, &keys)
.map_err(|_| Error::SignInFailed)
}
}
#[must_use]
pub fn record_use(passkeys: &mut [Passkey], result: &AuthenticationResult) -> Option<usize> {
passkeys
.iter_mut()
.position(|passkey| passkey.update_credential(result).is_some())
}
fn ceremony<S, R: CryptoRng + ?Sized>(
state: S,
binding: Binding,
now: OffsetDateTime,
rng: &mut R,
) -> Ceremony<S> {
Ceremony {
id: CeremonyId::random(rng),
state,
binding,
expires_at: now.saturating_add(CEREMONY_TTL),
}
}
fn browser_ceremony<S, R: CryptoRng + ?Sized>(
challenge: RequestChallengeResponse,
state: S,
user: Option<UserId>,
now: OffsetDateTime,
rng: &mut R,
) -> SignInStarted<S> {
let issued = token::issue(rng);
let binding = Binding::Browser {
token_hash: issued.hash,
user,
};
SignInStarted {
challenge,
ceremony: ceremony(state, binding, now, rng),
binding_token: issued.token,
}
}