diff --git a/src/api/v4/auth/passkey/finish.rs b/src/api/v4/auth/passkey/finish.rs index c5a6c29a..e9dd0cf4 100644 --- a/src/api/v4/auth/passkey/finish.rs +++ b/src/api/v4/auth/passkey/finish.rs @@ -13,10 +13,13 @@ // SPDX-License-Identifier: Apache-2.0 use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; -use serde_json::Value; +use base64::{Engine as _, engine::general_purpose::URL_SAFE}; use tracing::debug; -use webauthn_rs::prelude::*; +use crate::api::v4::auth::passkey::types::{ + AuthenticationExtensionsClientOutputs, AuthenticatorAssertionResponseRaw, HmacGetSecretOutput, + PasskeyAuthenticationFinishRequest, +}; use crate::api::{ error::{KeystoneApiError, WebauthnError}, v4::auth::token::types::{Token as ApiToken, TokenResponse as ApiTokenResponse}, @@ -34,6 +37,7 @@ use crate::token::TokenApi; post, path = "/finish", operation_id = "/auth/passkey/finish:post", + request_body = PasskeyAuthenticationFinishRequest, responses( (status = OK, description = "Authentication Token object", body = ApiTokenResponse, headers( @@ -50,13 +54,9 @@ use crate::token::TokenApi; )] pub(super) async fn finish( State(state): State, - Json(req): Json, + Json(req): Json, ) -> Result { - let user_id = req - .get("user_id") - .and_then(|uid| uid.as_str()) - .ok_or_else(|| KeystoneApiError::Unauthorized)? - .to_string(); + let user_id = req.user_id.clone(); // TODO: Wrap all errors into the Unauthorized, but log the error if let Some(s) = state .provider @@ -66,8 +66,10 @@ pub(super) async fn finish( { // We explicitly try to deserealize the request data directly into the underlying // webauthn_rs type. - let v: PublicKeyCredential = serde_json::from_value(req)?; - match state.webauthn.finish_passkey_authentication(&v, &s) { + match state + .webauthn + .finish_passkey_authentication(&req.try_into()?, &s) + { Ok(_auth_result) => { // Here should the DB update happen (last_used, ...) } @@ -118,3 +120,60 @@ pub(super) async fn finish( ) .into_response()) } + +impl TryFrom for webauthn_rs_proto::extensions::HmacGetSecretOutput { + type Error = KeystoneApiError; + fn try_from(val: HmacGetSecretOutput) -> Result { + Ok(Self { + output1: URL_SAFE.decode(val.output1)?.into(), + output2: val + .output2 + .map(|s2| URL_SAFE.decode(s2)) + .transpose()? + .map(Into::into), + }) + } +} + +impl TryFrom + for webauthn_rs_proto::extensions::AuthenticationExtensionsClientOutputs +{ + type Error = KeystoneApiError; + fn try_from(val: AuthenticationExtensionsClientOutputs) -> Result { + Ok(Self { + appid: val.appid, + hmac_get_secret: val.hmac_get_secret.map(TryInto::try_into).transpose()?, + }) + } +} + +impl TryFrom + for webauthn_rs_proto::auth::AuthenticatorAssertionResponseRaw +{ + type Error = KeystoneApiError; + fn try_from(val: AuthenticatorAssertionResponseRaw) -> Result { + Ok(Self { + authenticator_data: URL_SAFE.decode(val.authenticator_data)?.into(), + client_data_json: URL_SAFE.decode(val.client_data_json)?.into(), + signature: URL_SAFE.decode(val.signature)?.into(), + user_handle: val + .user_handle + .map(|uh| URL_SAFE.decode(uh)) + .transpose()? + .map(Into::into), + }) + } +} + +impl TryFrom for webauthn_rs::prelude::PublicKeyCredential { + type Error = KeystoneApiError; + fn try_from(req: PasskeyAuthenticationFinishRequest) -> Result { + Ok(webauthn_rs::prelude::PublicKeyCredential { + id: req.id, + extensions: req.extensions.try_into()?, + raw_id: URL_SAFE.decode(req.raw_id)?.into(), + response: req.response.try_into()?, + type_: req.type_, + }) + } +} diff --git a/src/api/v4/auth/passkey/start.rs b/src/api/v4/auth/passkey/start.rs index 7f98dcd4..9912cba8 100644 --- a/src/api/v4/auth/passkey/start.rs +++ b/src/api/v4/auth/passkey/start.rs @@ -13,6 +13,7 @@ // SPDX-License-Identifier: Apache-2.0 use axum::{Json, extract::State, response::IntoResponse}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE}; use tracing::debug; use webauthn_rs::prelude::*; @@ -78,3 +79,122 @@ pub(super) async fn start( Ok(res) } + +impl From for HmacGetSecretInput { + fn from(val: webauthn_rs_proto::extensions::HmacGetSecretInput) -> Self { + Self { + output1: URL_SAFE.encode(val.output1), + output2: val.output2.map(|s2| URL_SAFE.encode(s2)), + } + } +} + +impl From + for RequestAuthenticationExtensions +{ + fn from(val: webauthn_rs_proto::extensions::RequestAuthenticationExtensions) -> Self { + Self { + appid: val.appid, + hmac_get_secret: val.hmac_get_secret.map(Into::into), + uvm: val.uvm, + } + } +} + +impl From for AuthenticatorTransport { + fn from(val: webauthn_rs_proto::options::AuthenticatorTransport) -> Self { + match val { + webauthn_rs_proto::options::AuthenticatorTransport::Ble => AuthenticatorTransport::Ble, + webauthn_rs_proto::options::AuthenticatorTransport::Hybrid => { + AuthenticatorTransport::Hybrid + } + webauthn_rs_proto::options::AuthenticatorTransport::Internal => { + AuthenticatorTransport::Internal + } + webauthn_rs_proto::options::AuthenticatorTransport::Nfc => AuthenticatorTransport::Nfc, + webauthn_rs_proto::options::AuthenticatorTransport::Test => { + AuthenticatorTransport::Test + } + webauthn_rs_proto::options::AuthenticatorTransport::Unknown => { + AuthenticatorTransport::Unknown + } + webauthn_rs_proto::options::AuthenticatorTransport::Usb => AuthenticatorTransport::Usb, + } + } +} +impl From for UserVerificationPolicy { + fn from(val: webauthn_rs_proto::options::UserVerificationPolicy) -> Self { + match val { + webauthn_rs_proto::options::UserVerificationPolicy::Required => { + UserVerificationPolicy::Required + } + webauthn_rs_proto::options::UserVerificationPolicy::Preferred => { + UserVerificationPolicy::Preferred + } + webauthn_rs_proto::options::UserVerificationPolicy::Discouraged_DO_NOT_USE => { + UserVerificationPolicy::DiscouragedDoNotUse + } + } + } +} + +impl From for PublicKeyCredentialHint { + fn from(val: webauthn_rs_proto::options::PublicKeyCredentialHints) -> Self { + match val { + webauthn_rs_proto::options::PublicKeyCredentialHints::ClientDevice => { + PublicKeyCredentialHint::ClientDevice + } + webauthn_rs_proto::options::PublicKeyCredentialHints::Hybrid => { + PublicKeyCredentialHint::Hybrid + } + webauthn_rs_proto::options::PublicKeyCredentialHints::SecurityKey => { + PublicKeyCredentialHint::SecurityKey + } + } + } +} + +impl From for AllowCredentials { + fn from(val: webauthn_rs_proto::options::AllowCredentials) -> Self { + Self { + id: URL_SAFE.encode(val.id), + transports: val + .transports + .map(|tr| tr.into_iter().map(Into::into).collect::>()), + type_: val.type_, + } + } +} + +impl From + for PublicKeyCredentialRequestOptions +{ + fn from(val: webauthn_rs_proto::auth::PublicKeyCredentialRequestOptions) -> Self { + Self { + allow_credentials: val + .allow_credentials + .into_iter() + .map(Into::into) + .collect::>(), + challenge: URL_SAFE.encode(val.challenge), + extensions: val.extensions.map(Into::into), + hints: val + .hints + .map(|hints| hints.into_iter().map(Into::into).collect::>()), + rp_id: val.rp_id, + timeout: val.timeout, + user_verification: val.user_verification.into(), + } + } +} + +impl From for PasskeyAuthenticationStartResponse { + fn from(val: webauthn_rs::prelude::RequestChallengeResponse) -> Self { + Self { + public_key: val.public_key.into(), + mediation: val.mediation.map(|med| match med { + webauthn_rs_proto::auth::Mediation::Conditional => Mediation::Conditional, + }), + } + } +} diff --git a/src/api/v4/auth/passkey/types.rs b/src/api/v4/auth/passkey/types.rs index 003ea0e9..8fff008b 100644 --- a/src/api/v4/auth/passkey/types.rs +++ b/src/api/v4/auth/passkey/types.rs @@ -12,21 +12,20 @@ // // SPDX-License-Identifier: Apache-2.0 -//! Embedded type webauthn-rs::auth::PublickeyCredentialRequest. +//! Passkey authentication types. use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +/// Request for initialization of the passkey authentication. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] pub struct PasskeyAuthenticationStartRequest { - /// The ID of the user that is trying to authenticate + /// The ID of the user that is authenticating. pub user_id: String, } + /// Passkey Authorization challenge. /// -/// This is an embedded version of the -/// [webauthn-rs::auth::PublickeyCredentialRequest](https://docs.rs/webauthn-rs-proto/0.5.2/webauthn_rs_proto/auth/struct.PublicKeyCredentialRequestOptions.html) -/// /// A JSON serializable challenge which is issued to the user’s webbrowser for handling. This is /// meant to be opaque, that is, you should not need to inspect or alter the content of the struct /// - you should serialise it and transmit it to the client only. @@ -35,66 +34,80 @@ pub struct PasskeyAuthenticationStartResponse { /// The options. pub public_key: PublicKeyCredentialRequestOptions, /// The mediation requested. + #[schema(nullable = false)] + #[serde(skip_serializing_if = "Option::is_none")] pub mediation: Option, } /// The requested options for the authentication. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] pub struct PublicKeyCredentialRequestOptions { + /// The set of credentials that are allowed to sign this challenge. + pub allow_credentials: Vec, /// The challenge that should be signed by the authenticator. - pub challenge: Base64UrlSafeData, - /// The timeout for the authenticator in case of no interaction. - pub timeout: Option, + #[schema(value_type = String, format = Binary, content_encoding = "base64")] + pub challenge: String, + /// extensions. + #[schema(nullable = false)] + #[serde(skip_serializing_if = "Option::is_none")] + pub extensions: Option, + /// Hints defining which types credentials may be used in this operation. + #[schema(nullable = false)] + #[serde(skip_serializing_if = "Option::is_none")] + pub hints: Option>, /// The relying party ID. pub rp_id: String, - /// The set of credentials that are allowed to sign this challenge. - pub allow_credentials: Vec, + /// The timeout for the authenticator in case of no interaction. + pub timeout: Option, /// The verification policy the browser will request. pub user_verification: UserVerificationPolicy, - /// Hints defining which types credentials may be used in this operation. - pub hints: Option>, - /// extensions. - pub extensions: Option, } /// Request in residentkey workflows that conditional mediation should be used in the UI, or not. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] pub enum Mediation { - /// Discovered credentials are presented to the user in a dialog. Conditional UI is used. See https://github.com/w3c/webauthn/wiki/Explainer:-WebAuthn-Conditional-UI https://w3c.github.io/webappsec-credential-management/#enumdef-credentialmediationrequirement + /// Discovered credentials are presented to the user in a dialog. Conditional UI is used. See + /// https://github.com/w3c/webauthn/wiki/Explainer:-WebAuthn-Conditional-UI + /// https://w3c.github.io/webappsec-credential-management/#enumdef-credentialmediationrequirement Conditional, } /// A descriptor of a credential that can be used. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] pub struct AllowCredentials { - /// The type of credential. - pub type_: String, /// The id of the credential. - pub id: Base64UrlSafeData, + #[schema(value_type = String, format = Binary, content_encoding = "base64")] + pub id: String, /// https://www.w3.org/TR/webauthn/#transport may be usb, nfc, ble, internal + #[schema(nullable = false)] + #[serde(skip_serializing_if = "Option::is_none")] pub transports: Option>, + /// The type of credential. + pub type_: String, } /// https://www.w3.org/TR/webauthn/#enumdef-authenticatortransport #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] pub enum AuthenticatorTransport { - /// https://www.w3.org/TR/webauthn/#dom-authenticatortransport-usb - Usb, - /// https://www.w3.org/TR/webauthn/#dom-authenticatortransport-nfc - Nfc, /// https://www.w3.org/TR/webauthn/#dom-authenticatortransport-ble Ble, - /// https://www.w3.org/TR/webauthn/#dom-authenticatortransport-internal - Internal, /// Hybrid transport, formerly caBLE. Part of the level 3 draft specification. https://w3c.github.io/webauthn/#dom-authenticatortransport-hybrid Hybrid, + /// https://www.w3.org/TR/webauthn/#dom-authenticatortransport-internal + Internal, + /// https://www.w3.org/TR/webauthn/#dom-authenticatortransport-nfc + Nfc, /// Test transport; used for Windows 10. Test, /// An unknown transport was provided - it will be ignored. Unknown, + /// https://www.w3.org/TR/webauthn/#dom-authenticatortransport-usb + Usb, } -/// Defines the User Authenticator Verification policy. This is documented https://w3c.github.io/webauthn/#enumdef-userverificationrequirement, and each variant lists it’s effects. +/// Defines the User Authenticator Verification policy. This is documented +/// https://w3c.github.io/webauthn/#enumdef-userverificationrequirement, and each variant lists +/// it’s effects. /// /// To be clear, Verification means that the Authenticator perform extra or supplementary /// interaction with the user to verify who they are. An example of this is Apple Touch Id required @@ -140,19 +153,22 @@ pub enum UserVerificationPolicy { /// However, in some cases use of this policy can lead to some credentials failing to verify /// correctly due to browser peripheral exchange bypasses. Preferred, + /// Discourage - but do not prevent - user verification from being supplied. Many CTAP devices + /// will attempt UV during registration but not authentication leading to user confusion. + DiscouragedDoNotUse, } /// A hint as to the class of device that is expected to fufil this operation. /// /// https://www.w3.org/TR/webauthn-3/#enumdef-publickeycredentialhints #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] -pub enum PublicKeyCredentialHints { - /// The credential is a removable security key. - SecurityKey, +pub enum PublicKeyCredentialHint { /// The credential is a platform authenticator. ClientDevice, /// The credential will come from an external device. Hybrid, + /// The credential is a removable security key. + SecurityKey, } /// Extension option inputs for PublicKeyCredentialRequestOptions @@ -161,12 +177,18 @@ pub enum PublicKeyCredentialHints { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] pub struct RequestAuthenticationExtensions { /// The appid extension options. + #[schema(nullable = false)] + #[serde(skip_serializing_if = "Option::is_none")] pub appid: Option, - /// ⚠️ - Browsers do not support this! Uvm. - pub uvm: Option, /// ⚠️ - Browsers do not support this! /// https://bugs.chromium.org/p/chromium/issues/detail?id=1023225 Hmac get secret. + #[schema(nullable = false)] + #[serde(skip_serializing_if = "Option::is_none")] pub hmac_get_secret: Option, + /// ⚠️ - Browsers do not support this! Uvm. + #[schema(nullable = false)] + #[serde(skip_serializing_if = "Option::is_none")] + pub uvm: Option, } /// The inputs to the hmac secret if it was created during registration. @@ -175,24 +197,14 @@ pub struct RequestAuthenticationExtensions { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] pub struct HmacGetSecretInput { /// Retrieve a symmetric secrets from the authenticator with this input. - pub output1: Base64UrlSafeData, + #[schema(value_type = String, format = Binary, content_encoding = "base64")] + pub output1: String, /// Rotate the secret in the same operation. - pub output2: Option, + #[schema(value_type = String, format = Binary, content_encoding = "base64")] + #[serde(skip_serializing_if = "Option::is_none")] + pub output2: Option, } -/// Serde wrapper for Vec which always emits URL-safe, non-padded Base64, and accepts Base64 -/// and binary formats. -/// -/// Serialisation always emits URL-safe, non-padded Base64 (per RFC 4648 §5). -/// -/// Unlike HumanBinaryData, this happens regardless of whether the underlying serialisation -/// format is human readable. If you’re serialising to non-human-readable formats, you should -/// consider migrating to HumanBinaryData. -/// -/// Otherwise, this type should work as much like a Vec as possible. -#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] -pub struct Base64UrlSafeData(Vec); - /// A client response to an authentication challenge. This contains all required information to /// asses and assert trust in a credentials legitimacy, followed by authentication to a user. /// @@ -200,31 +212,36 @@ pub struct Base64UrlSafeData(Vec); /// the correctly handling function of Webauthn only. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] pub struct PasskeyAuthenticationFinishRequest { - /// The ID of the user. - pub user_id: String, /// The credential Id, likely base64. pub id: String, + /// Unsigned Client processed extensions. + pub extensions: AuthenticationExtensionsClientOutputs, /// The binary of the credential id. - pub raw_id: Base64UrlSafeData, + #[schema(value_type = String, format = Binary, content_encoding = "base64")] + pub raw_id: String, /// The authenticator response. pub response: AuthenticatorAssertionResponseRaw, - /// Unsigned Client processed extensions. - pub extensions: AuthenticationExtensionsClientOutputs, /// The authenticator type. pub type_: String, + /// The ID of the user. + pub user_id: String, } /// [AuthenticatorAssertionResponseRaw](https://w3c.github.io/webauthn/#authenticatorassertionresponse) #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] pub struct AuthenticatorAssertionResponseRaw { /// Raw authenticator data. - pub authenticator_data: Base64UrlSafeData, + #[schema(value_type = String, format = Binary, content_encoding = "base64")] + pub authenticator_data: String, /// Signed client data. - pub client_data_json: Base64UrlSafeData, + #[schema(value_type = String, format = Binary, content_encoding = "base64")] + pub client_data_json: String, /// Signature. - pub signature: Base64UrlSafeData, + #[schema(value_type = String, format = Binary, content_encoding = "base64")] + pub signature: String, + #[schema(value_type = String, format = Binary, content_encoding = "base64")] /// Optional userhandle. - pub user_handle: Option, + pub user_handle: Option, } /// [AuthenticationExtensionsClientOutputs](https://w3c.github.io/webauthn/#dictdef-authenticationextensionsclientoutputs) @@ -233,8 +250,12 @@ pub struct AuthenticatorAssertionResponseRaw { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] pub struct AuthenticationExtensionsClientOutputs { /// Indicates whether the client used the provided appid extension. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub appid: Option, /// The response to a hmac get secret request. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] pub hmac_get_secret: Option, } @@ -242,7 +263,10 @@ pub struct AuthenticationExtensionsClientOutputs { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] pub struct HmacGetSecretOutput { /// Output of HMAC(Salt 1 || Client Secret). - pub output1: Base64UrlSafeData, + #[schema(value_type = String, format = Binary, content_encoding = "base64")] + pub output1: String, /// Output of HMAC(Salt 2 || Client Secret). - pub output2: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false, value_type = String, format = Binary, content_encoding = "base64")] + pub output2: Option, } diff --git a/src/api/v4/user/passkey/register_finish.rs b/src/api/v4/user/passkey/register_finish.rs index cf725eae..cec9e3db 100644 --- a/src/api/v4/user/passkey/register_finish.rs +++ b/src/api/v4/user/passkey/register_finish.rs @@ -25,7 +25,8 @@ use tracing::debug; use crate::api::auth::Auth; use crate::api::error::{KeystoneApiError, WebauthnError}; use crate::api::v4::user::types::passkey::{ - AuthenticatorTransport, CredentialProtectionPolicy, UserPasskeyRegistrationFinishRequest, + AuthenticatorTransport, CredentialProtectionPolicy, PasskeyResponse, + UserPasskeyRegistrationFinishRequest, }; use crate::identity::IdentityApi; use crate::keystone::ServiceState; @@ -42,7 +43,7 @@ use crate::policy::Policy; ("user_id" = String, Path, description = "The ID of the user.") ), responses( - (status = CREATED, description = "Passkey successfully registered"), + (status = CREATED, description = "Passkey successfully registered", body = PasskeyResponse), (status = 500, description = "Internal error", example = json!(KeystoneApiError::InternalError(String::from("id = 1")))) ), tags = ["users", "passkey"] @@ -129,13 +130,13 @@ impl TryFrom transports: val.response.transports.map(|i| { i.into_iter() .map(|t| match t { - AuthenticatorTransport::Usb => webauthn_rs_proto::options::AuthenticatorTransport::Usb, - AuthenticatorTransport::Nfc => webauthn_rs_proto::options::AuthenticatorTransport::Nfc, AuthenticatorTransport::Ble => webauthn_rs_proto::options::AuthenticatorTransport::Ble, - AuthenticatorTransport::Internal => webauthn_rs_proto::options::AuthenticatorTransport::Internal, AuthenticatorTransport::Hybrid => webauthn_rs_proto::options::AuthenticatorTransport::Hybrid, + AuthenticatorTransport::Internal => webauthn_rs_proto::options::AuthenticatorTransport::Internal, + AuthenticatorTransport::Nfc => webauthn_rs_proto::options::AuthenticatorTransport::Nfc, AuthenticatorTransport::Test => webauthn_rs_proto::options::AuthenticatorTransport::Test, AuthenticatorTransport::Unknown => webauthn_rs_proto::options::AuthenticatorTransport::Unknown, + AuthenticatorTransport::Usb => webauthn_rs_proto::options::AuthenticatorTransport::Usb, }) .collect::>() diff --git a/src/api/v4/user/types/passkey.rs b/src/api/v4/user/types/passkey.rs index 778d4eda..b54e3e7e 100644 --- a/src/api/v4/user/types/passkey.rs +++ b/src/api/v4/user/types/passkey.rs @@ -22,6 +22,34 @@ use utoipa::ToSchema; #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] pub struct UserPasskeyRegistrationStartRequest { /// The description for the passkey (name). + pub passkey: PasskeyCreate, +} + +/// Passkey information. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct PasskeyCreate { + /// Passkey description + #[schema(nullable = false)] + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +/// Passkey. +/// +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct PasskeyResponse { + /// The description for the passkey (name). + pub passkey: Passkey, +} + +/// Passkey information. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct Passkey { + /// Credential ID. + pub credential_id: String, + /// Credential description. + #[schema(nullable = false)] + #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, } @@ -86,6 +114,7 @@ pub struct RelyingParty { /// User Entity. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +#[schema(as = PasskeyUser)] pub struct User { /// The user’s id in base64 form. This MUST be a unique id, and must NOT contain personally /// identifying information, as this value can NEVER be changed. If in doubt, use a UUID.