feat: verify discoverable passkeys against their saved owners
This commit is contained in:
Generated
+2
@@ -6713,6 +6713,7 @@ dependencies = [
|
|||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"webauthn-authenticator-rs",
|
"webauthn-authenticator-rs",
|
||||||
|
"webauthn-rs-proto",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6773,6 +6774,7 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
"webauthn-authenticator-rs",
|
"webauthn-authenticator-rs",
|
||||||
"webauthn-rs",
|
"webauthn-rs",
|
||||||
|
"webauthn-rs-proto",
|
||||||
"wip-protocol",
|
"wip-protocol",
|
||||||
"workdir",
|
"workdir",
|
||||||
"worker",
|
"worker",
|
||||||
|
|||||||
@@ -4088,10 +4088,11 @@ pub struct PasskeyRegistrationCompleteRequest {
|
|||||||
pub credential: RegisterPublicKeyCredential,
|
pub credential: RegisterPublicKeyCredential,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identified-account login. Missing handles are rejected, not resolved to an implicit user.
|
/// Normal discoverable login omits the handle: the authenticator selects a credential.
|
||||||
///
|
/// An explicit handle selects legacy identified-account login, for non-resident keys or
|
||||||
/// The nullable wire field permits an explicit authentication error for old callers; it does not
|
/// keys registered with an old random WebAuthn user handle. This never adds a key or
|
||||||
/// advertise a discoverable-login ceremony.
|
/// falls back to another identity. Authenticate first, then enroll an RK from Account.
|
||||||
|
/// Existing challenge states are short-lived and are not migrated to the new ceremony.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ ts-rs = "12.0.1"
|
|||||||
url.workspace = true
|
url.workspace = true
|
||||||
uuid = { workspace = true, features = ["v7"] }
|
uuid = { workspace = true, features = ["v7"] }
|
||||||
zeroize.workspace = true
|
zeroize.workspace = true
|
||||||
webauthn-rs = { workspace = true }
|
webauthn-rs = { workspace = true, features = ["conditional-ui"] }
|
||||||
|
webauthn-rs-proto = "0.5.5"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
wip-protocol.workspace = true
|
wip-protocol.workspace = true
|
||||||
|
|||||||
@@ -29686,11 +29686,13 @@ struct PasskeyRegistrationEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// An account gets one stable WebAuthn user handle, without storing a new schema
|
/// An account gets one stable WebAuthn user handle, without storing a new schema
|
||||||
/// field. Migration leaves all existing credentials, their random registration
|
/// field. Legacy identified-account login accepts original random handles or no
|
||||||
/// handles and key material unchanged. Credential-ID-scoped authentication must
|
/// returned handle, always requiring the saved credential's signature. Normal
|
||||||
/// accept signed assertions both without userHandle and with the original random
|
/// discoverable login additionally requires the saved owner's stable handle.
|
||||||
/// or newly registered stable userHandle; it is not discoverable-handle lookup.
|
/// Random registration handles were never stored, so they cannot be recovered
|
||||||
/// The real-verifier compatibility tests exercise each representation explicitly.
|
/// or safely mapped by trusting the unsigned assertion. Preserve these keys and
|
||||||
|
/// use explicit legacy login to authenticate before adding a stable-handle RK.
|
||||||
|
/// Merely having a stable handle does not prove the old key is discoverable.
|
||||||
fn passkey_user_handle(account_id: &str) -> Uuid {
|
fn passkey_user_handle(account_id: &str) -> Uuid {
|
||||||
let digest = Sha256::digest(account_id.as_bytes());
|
let digest = Sha256::digest(account_id.as_bytes());
|
||||||
Uuid::from_bytes(
|
Uuid::from_bytes(
|
||||||
@@ -29794,7 +29796,7 @@ fn start_passkey_registration(
|
|||||||
let header_origin = request_origin(headers);
|
let header_origin = request_origin(headers);
|
||||||
let auth = auth_config_for_origin(&api.config, browser_origin.or(header_origin.as_deref()))?;
|
let auth = auth_config_for_origin(&api.config, browser_origin.or(header_origin.as_deref()))?;
|
||||||
let webauthn = webauthn_for_auth(&auth)?;
|
let webauthn = webauthn_for_auth(&auth)?;
|
||||||
let (public_key, verifier) = webauthn
|
let (mut public_key, verifier) = webauthn
|
||||||
.start_passkey_registration(
|
.start_passkey_registration(
|
||||||
passkey_user_handle(&user.account_id),
|
passkey_user_handle(&user.account_id),
|
||||||
&user.handle,
|
&user.handle,
|
||||||
@@ -29802,6 +29804,21 @@ fn start_passkey_registration(
|
|||||||
Some(exclude_credentials),
|
Some(exclude_credentials),
|
||||||
)
|
)
|
||||||
.map_err(|error| auth_error("webauthn_registration_options_failed", &error.to_string()))?;
|
.map_err(|error| auth_error("webauthn_registration_options_failed", &error.to_string()))?;
|
||||||
|
// webauthn-rs passkeys require UV but do not request residency by default.
|
||||||
|
// These public protocol fields request an RK on conforming clients; residency
|
||||||
|
// itself is not remotely attestable. Keep the library's UV/verifier state.
|
||||||
|
let selection = public_key
|
||||||
|
.public_key
|
||||||
|
.authenticator_selection
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
auth_error(
|
||||||
|
"webauthn_registration_options_failed",
|
||||||
|
"authenticator selection is missing",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
selection.resident_key = Some(webauthn_rs_proto::ResidentKeyRequirement::Required);
|
||||||
|
selection.require_resident_key = true;
|
||||||
let challenge_id = new_id("webauthn-registration");
|
let challenge_id = new_id("webauthn-registration");
|
||||||
// New users do not exist yet, so the nullable FK remains empty. The complete
|
// New users do not exist yet, so the nullable FK remains empty. The complete
|
||||||
// target (including its allocated user ID) is inside the purpose-tagged state.
|
// target (including its allocated user ID) is inside the purpose-tagged state.
|
||||||
@@ -29966,6 +29983,18 @@ fn complete_passkey_enrollment(
|
|||||||
issue_browser_session(api, user)
|
issue_browser_session(api, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Normal login has no preselected identity. Explicit handle requests are the
|
||||||
|
/// legacy migration path, not an automatic fallback. Non-resident keys and keys
|
||||||
|
/// with old random user handles can authenticate there, then add an RK from the
|
||||||
|
/// live account. No saved key or owner is rewritten. Existing stable-handle RKs
|
||||||
|
/// can already use normal login; discoverability cannot be inferred from storage.
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "mode", content = "verifier", rename_all = "snake_case")]
|
||||||
|
enum PasskeyLoginState {
|
||||||
|
Discoverable(webauthn_rs::prelude::DiscoverableAuthentication),
|
||||||
|
Legacy(PasskeyAuthentication),
|
||||||
|
}
|
||||||
|
|
||||||
async fn post_passkey_login_options(
|
async fn post_passkey_login_options(
|
||||||
State(api): State<ServerAuthApi>,
|
State(api): State<ServerAuthApi>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -29974,48 +30003,66 @@ async fn post_passkey_login_options(
|
|||||||
api.store
|
api.store
|
||||||
.db_executor()
|
.db_executor()
|
||||||
.run(move || {
|
.run(move || {
|
||||||
let user = match request.handle.as_deref() {
|
|
||||||
Some(handle) => api.store.get_user_by_handle(&normalize_handle(handle)?)?,
|
|
||||||
None => {
|
|
||||||
return Err(auth_error(
|
|
||||||
"passkey_handle_required",
|
|
||||||
"login requires an explicit handle",
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.ok_or_else(|| auth_error("unknown_auth_user", "no matching user account exists"))?;
|
|
||||||
if api.store.is_user_pending_activation(&user.user_id)? {
|
|
||||||
return Err(auth_error(
|
|
||||||
"passkey_activation_required",
|
|
||||||
"initial passkey activation is required",
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
let passkeys = passkeys_for_user(&api, &user.user_id)?;
|
|
||||||
if passkeys.is_empty() {
|
|
||||||
return Err(auth_error(
|
|
||||||
"passkey_not_registered",
|
|
||||||
"user has no registered passkey credentials",
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
let header_origin = request_origin(&headers);
|
let header_origin = request_origin(&headers);
|
||||||
let requested_origin = request
|
let auth = auth_config_for_origin(
|
||||||
.browser_origin
|
&api.config,
|
||||||
.as_deref()
|
request
|
||||||
.or(header_origin.as_deref());
|
.browser_origin
|
||||||
let auth = auth_config_for_origin(&api.config, requested_origin)?;
|
.as_deref()
|
||||||
|
.or(header_origin.as_deref()),
|
||||||
|
)?;
|
||||||
let webauthn = webauthn_for_auth(&auth)?;
|
let webauthn = webauthn_for_auth(&auth)?;
|
||||||
let (public_key, state) = webauthn
|
let (public_key, state, user_id) =
|
||||||
.start_passkey_authentication(&passkeys)
|
if let Some(handle) = request.handle.as_deref() {
|
||||||
.map_err(|error| auth_error("webauthn_login_options_failed", &error.to_string()))?;
|
let user = api
|
||||||
|
.store
|
||||||
|
.get_user_by_handle(&normalize_handle(handle)?)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
auth_error("unknown_auth_user", "no matching user account exists")
|
||||||
|
})?;
|
||||||
|
if api.store.is_user_pending_activation(&user.user_id)? {
|
||||||
|
return Err(auth_error(
|
||||||
|
"passkey_activation_required",
|
||||||
|
"initial passkey activation is required",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let passkeys = passkeys_for_user(&api, &user.user_id)?;
|
||||||
|
if passkeys.is_empty() {
|
||||||
|
return Err(auth_error(
|
||||||
|
"passkey_not_registered",
|
||||||
|
"user has no registered passkey credentials",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let (options, state) = webauthn
|
||||||
|
.start_passkey_authentication(&passkeys)
|
||||||
|
.map_err(|error| {
|
||||||
|
auth_error("webauthn_login_options_failed", &error.to_string())
|
||||||
|
})?;
|
||||||
|
(
|
||||||
|
options,
|
||||||
|
PasskeyLoginState::Legacy(state),
|
||||||
|
Some(user.user_id),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let (mut options, state) = webauthn
|
||||||
|
.start_discoverable_authentication()
|
||||||
|
.map_err(|error| {
|
||||||
|
auth_error("webauthn_login_options_failed", &error.to_string())
|
||||||
|
})?;
|
||||||
|
// The feature exposes a conditional-UI builder. Use its empty allow
|
||||||
|
// list and required UV verifier with an explicit user gesture, not
|
||||||
|
// conditional/autofill mediation. No account inventory is returned.
|
||||||
|
options.mediation = None;
|
||||||
|
(options, PasskeyLoginState::Discoverable(state), None)
|
||||||
|
};
|
||||||
let challenge_id = new_id("webauthn-login");
|
let challenge_id = new_id("webauthn-login");
|
||||||
api.store.put_auth_challenge(&AuthChallengeRecord {
|
api.store.put_auth_challenge(&AuthChallengeRecord {
|
||||||
challenge_id: challenge_id.clone(),
|
challenge_id: challenge_id.clone(),
|
||||||
ceremony: "passkey_login".to_string(),
|
ceremony: "passkey_login".into(),
|
||||||
challenge: challenge_id.clone(),
|
challenge: challenge_id.clone(),
|
||||||
user_id: Some(user.user_id),
|
user_id,
|
||||||
rp_id: auth.rp_id,
|
rp_id: auth.rp_id,
|
||||||
origin: auth.origin,
|
origin: auth.origin,
|
||||||
state_json: Some(serde_json::to_string(&state).map_err(|error| {
|
state_json: Some(serde_json::to_string(&state).map_err(|error| {
|
||||||
@@ -30057,64 +30104,114 @@ fn complete_passkey_login(
|
|||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
let user_id = challenge.user_id.clone().ok_or_else(|| {
|
let state: PasskeyLoginState =
|
||||||
auth_error(
|
serde_json::from_str(challenge.state_json.as_deref().ok_or_else(|| {
|
||||||
"invalid_passkey_challenge",
|
|
||||||
"passkey login challenge is not bound to a user",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let user = api
|
|
||||||
.store
|
|
||||||
.get_user(&user_id)?
|
|
||||||
.ok_or_else(|| auth_error("unknown_auth_user", "passkey user does not exist"))?;
|
|
||||||
let state_json = challenge.state_json.clone().ok_or_else(|| {
|
|
||||||
auth_error(
|
|
||||||
"missing_webauthn_state",
|
|
||||||
"passkey login state was not persisted",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let state: PasskeyAuthentication = serde_json::from_str(&state_json)
|
|
||||||
.map_err(|error| auth_error("webauthn_state_deserialize_failed", &error.to_string()))?;
|
|
||||||
let webauthn = webauthn_for_challenge(&api.config, &challenge)?;
|
|
||||||
let auth_result = webauthn
|
|
||||||
.finish_passkey_authentication(&request.credential, &state)
|
|
||||||
.map_err(|error| auth_error("webauthn_login_verification_failed", &error.to_string()))?;
|
|
||||||
let credential_id = serde_json::to_value(auth_result.cred_id())
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
|
||||||
.ok_or_else(|| {
|
|
||||||
auth_error(
|
auth_error(
|
||||||
"invalid_webauthn_credential_id",
|
"missing_webauthn_state",
|
||||||
"verified credential id was not serializable",
|
"passkey login state was not persisted",
|
||||||
)
|
)
|
||||||
})?;
|
})?)
|
||||||
|
.map_err(|error| auth_error("webauthn_state_deserialize_failed", &error.to_string()))?;
|
||||||
|
let credential_id = serde_json::to_value(webauthn_rs::prelude::Base64UrlSafeData::from(
|
||||||
|
request.credential.get_credential_id().to_vec(),
|
||||||
|
))
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
||||||
|
.ok_or_else(|| auth_error("invalid_webauthn_credential_id", "credential id is invalid"))?;
|
||||||
let stored = api
|
let stored = api
|
||||||
.store
|
.store
|
||||||
.get_passkey_credential(&credential_id)?
|
.get_passkey_credential(&credential_id)?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
auth_error(
|
auth_error(
|
||||||
"unknown_passkey_credential",
|
"unknown_passkey_credential",
|
||||||
"verified passkey credential is not registered",
|
"passkey credential is not registered",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
if stored.user_id != user.user_id {
|
let user = api
|
||||||
|
.store
|
||||||
|
.get_user(&stored.user_id)?
|
||||||
|
.ok_or_else(|| auth_error("unknown_auth_user", "passkey user does not exist"))?;
|
||||||
|
if api.store.is_user_pending_activation(&user.user_id)? {
|
||||||
return Err(auth_error(
|
return Err(auth_error(
|
||||||
"passkey_user_mismatch",
|
"passkey_activation_required",
|
||||||
"verified passkey credential does not belong to the challenged user",
|
"initial passkey activation is required",
|
||||||
)
|
)
|
||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
let mut passkey: Passkey = serde_json::from_str(&stored.public_key_cose)
|
||||||
|
.map_err(|error| auth_error("webauthn_passkey_deserialize_failed", &error.to_string()))?;
|
||||||
|
if passkey_credential_id(&passkey)? != credential_id {
|
||||||
|
return Err(auth_error(
|
||||||
|
"passkey_user_mismatch",
|
||||||
|
"stored credential id does not match key material",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let webauthn = webauthn_for_challenge(&api.config, &challenge)?;
|
||||||
|
let auth_result = match state {
|
||||||
|
PasskeyLoginState::Discoverable(state) => {
|
||||||
|
// userHandle is unsigned routing data, never identity authority. The
|
||||||
|
// candidate owner comes only from the exact saved credential ID;
|
||||||
|
// its stable handle must agree, and the saved key must verify below.
|
||||||
|
if challenge.user_id.is_some()
|
||||||
|
|| request.credential.get_user_unique_id()
|
||||||
|
!= Some(passkey_user_handle(&user.account_id).as_bytes().as_slice())
|
||||||
|
{
|
||||||
|
return Err(auth_error(
|
||||||
|
"passkey_user_mismatch",
|
||||||
|
"credential owner/user handle mismatch; use explicit legacy login for old keys",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
webauthn.finish_discoverable_authentication(
|
||||||
|
&request.credential,
|
||||||
|
state,
|
||||||
|
&[passkey.clone().into()],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PasskeyLoginState::Legacy(state) => {
|
||||||
|
if challenge.user_id.as_deref() != Some(user.user_id.as_str()) {
|
||||||
|
return Err(auth_error(
|
||||||
|
"passkey_user_mismatch",
|
||||||
|
"credential does not belong to the challenged user",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
webauthn.finish_passkey_authentication(&request.credential, &state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map_err(|error| auth_error("webauthn_login_verification_failed", &error.to_string()))?;
|
||||||
|
if auth_result.cred_id().as_slice() != request.credential.get_credential_id() {
|
||||||
|
return Err(auth_error(
|
||||||
|
"passkey_user_mismatch",
|
||||||
|
"verified credential differs from saved credential",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
if stored.sign_count > 0
|
||||||
|
&& auth_result.counter() > 0
|
||||||
|
&& u64::from(auth_result.counter()) <= stored.sign_count
|
||||||
|
{
|
||||||
|
return Err(auth_error(
|
||||||
|
"webauthn_login_verification_failed",
|
||||||
|
"credential counter did not advance",
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
passkey.update_credential(&auth_result);
|
||||||
|
let public_key_cose = serde_json::to_string(&passkey)
|
||||||
|
.map_err(|error| auth_error("webauthn_state_serialize_failed", &error.to_string()))?;
|
||||||
api.store
|
api.store
|
||||||
.upsert_passkey_credential(&PasskeyCredentialRecord {
|
.upsert_passkey_credential(&PasskeyCredentialRecord {
|
||||||
credential_id,
|
credential_id,
|
||||||
user_id: user.user_id.clone(),
|
user_id: user.user_id.clone(),
|
||||||
public_key_cose: stored.public_key_cose,
|
public_key_cose,
|
||||||
transports_json: stored.transports_json,
|
transports_json: stored.transports_json,
|
||||||
sign_count: u64::from(auth_result.counter()),
|
sign_count: u64::from(auth_result.counter()),
|
||||||
created_at: stored.created_at,
|
created_at: stored.created_at,
|
||||||
last_used_at: Some(crate::auth::now_rfc3339()),
|
last_used_at: Some(crate::auth::now_rfc3339()),
|
||||||
})?;
|
})?;
|
||||||
issue_browser_session(&api, user)
|
issue_browser_session(api, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn issue_browser_session(
|
fn issue_browser_session(
|
||||||
@@ -51692,16 +51789,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Generated HTTP admission, real SQLite and WebAuthn verification are connected
|
/// Generated HTTP admission, real SQLite and WebAuthn verification are connected
|
||||||
/// here. Only the physical authenticator is replaced with SoftPasskey; all
|
/// here. The authenticator is a resident software model with a SoftPasskey signing engine; all
|
||||||
/// identities used for addition come from cookies/tokens issued by real login.
|
/// identities used for addition come from cookies/tokens issued by real login.
|
||||||
mod passkey_security {
|
mod passkey_security {
|
||||||
use super::*;
|
use super::*;
|
||||||
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
|
mod resident_model {
|
||||||
|
include!("../../../tests/e2e/src/resident_authenticator.rs");
|
||||||
|
}
|
||||||
|
use resident_model::ResidentAuthenticator;
|
||||||
|
|
||||||
include!("server_activation_tests.rs");
|
include!("server_activation_tests.rs");
|
||||||
|
include!("server_discoverable_tests.rs");
|
||||||
|
|
||||||
const ORIGIN_URL: &str = "https://workspace.test";
|
const ORIGIN_URL: &str = "https://workspace.test";
|
||||||
type Authenticator = WebauthnAuthenticator<SoftPasskey>;
|
type Authenticator = ResidentAuthenticator;
|
||||||
|
|
||||||
struct Fixture {
|
struct Fixture {
|
||||||
_root: tempfile::TempDir,
|
_root: tempfile::TempDir,
|
||||||
@@ -52029,7 +52130,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn authenticator() -> Authenticator {
|
fn authenticator() -> Authenticator {
|
||||||
WebauthnAuthenticator::new(SoftPasskey::new(true))
|
ResidentAuthenticator::new(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rejected(result: (StatusCode, HeaderMap, Value), status: StatusCode, code: &str) {
|
fn rejected(result: (StatusCode, HeaderMap, Value), status: StatusCode, code: &str) {
|
||||||
@@ -52220,17 +52321,17 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
rejected(
|
let (status, _, options) = f
|
||||||
f.post(
|
.post(
|
||||||
"/api/auth/passkeys/login/options",
|
"/api/auth/passkeys/login/options",
|
||||||
None,
|
None,
|
||||||
Some(ORIGIN_URL),
|
Some(ORIGIN_URL),
|
||||||
serde_json::json!({}),
|
serde_json::json!({}),
|
||||||
)
|
)
|
||||||
.await,
|
.await;
|
||||||
StatusCode::BAD_REQUEST,
|
assert_eq!(status, StatusCode::OK, "{options}");
|
||||||
"passkey_handle_required",
|
let options: PasskeyLoginOptionsResponse = serde_json::from_value(options).unwrap();
|
||||||
);
|
assert!(options.public_key.public_key.allow_credentials.is_empty());
|
||||||
assert_eq!(f.credentials(&user), originals);
|
assert_eq!(f.credentials(&user), originals);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53075,113 +53176,152 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn legacy_random_user_handle_credential_authenticates_before_and_after_stable_handle_addition()
|
async fn legacy_random_user_handle_credential_authenticates_before_and_after_stable_handle_addition()
|
||||||
{
|
{
|
||||||
let f = Fixture::new().await;
|
for resident in [false, true] {
|
||||||
let api = ServerAuthApi::from(&f.api);
|
let f = Fixture::new().await;
|
||||||
let user = allocate_new_user(&api, "legacy", None).unwrap();
|
let api = ServerAuthApi::from(&f.api);
|
||||||
let random_handle = Uuid::now_v7();
|
let user = allocate_new_user(&api, "legacy", None).unwrap();
|
||||||
assert_ne!(random_handle, passkey_user_handle(&user.account_id));
|
let random_handle = Uuid::now_v7();
|
||||||
let webauthn = webauthn_for_auth(&auth_public_config(&api.config)).unwrap();
|
assert_ne!(random_handle, passkey_user_handle(&user.account_id));
|
||||||
let (options, state) = webauthn
|
let webauthn = webauthn_for_auth(&auth_public_config(&api.config)).unwrap();
|
||||||
.start_passkey_registration(random_handle, &user.handle, &user.display_name, None)
|
let (mut options, state) = webauthn
|
||||||
.unwrap();
|
.start_passkey_registration(
|
||||||
let legacy_user_handle = options.public_key.user.id.clone();
|
random_handle,
|
||||||
assert_eq!(legacy_user_handle.as_slice(), random_handle.as_bytes());
|
&user.handle,
|
||||||
let mut legacy_authenticator = authenticator();
|
&user.display_name,
|
||||||
let credential = legacy_authenticator
|
None,
|
||||||
.do_registration(Url::parse(ORIGIN_URL).unwrap(), options)
|
|
||||||
.unwrap();
|
|
||||||
let passkey = webauthn
|
|
||||||
.finish_passkey_registration(&credential, &state)
|
|
||||||
.unwrap();
|
|
||||||
let record = PasskeyCredentialRecord {
|
|
||||||
credential_id: passkey_credential_id(&passkey).unwrap(),
|
|
||||||
user_id: user.user_id.clone(),
|
|
||||||
public_key_cose: serde_json::to_string(&passkey).unwrap(),
|
|
||||||
transports_json: None,
|
|
||||||
sign_count: 0,
|
|
||||||
created_at: crate::auth::now_rfc3339(),
|
|
||||||
last_used_at: None,
|
|
||||||
};
|
|
||||||
f.api
|
|
||||||
.store
|
|
||||||
.register_verified_passkey(
|
|
||||||
&PasskeyRegistrationTarget::NewAccount { user: user.clone() },
|
|
||||||
&record,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let expected = user_response(user.clone());
|
|
||||||
for returned_handle in [None, Some(legacy_user_handle.as_slice())] {
|
|
||||||
let (_, logged_in) = f
|
|
||||||
.login_session_with_user_handle(
|
|
||||||
"legacy",
|
|
||||||
&mut legacy_authenticator,
|
|
||||||
returned_handle,
|
|
||||||
)
|
)
|
||||||
.await;
|
.unwrap();
|
||||||
assert_eq!(logged_in, expected);
|
if resident {
|
||||||
}
|
let selection = options.public_key.authenticator_selection.as_mut().unwrap();
|
||||||
let (cookie, _) = f
|
selection.require_resident_key = true;
|
||||||
.login_session_with_user_handle(
|
selection.resident_key =
|
||||||
"legacy",
|
Some(webauthn_rs_proto::ResidentKeyRequirement::Required);
|
||||||
&mut legacy_authenticator,
|
}
|
||||||
Some(legacy_user_handle.as_slice()),
|
let legacy_user_handle = options.public_key.user.id.clone();
|
||||||
)
|
assert_eq!(legacy_user_handle.as_slice(), random_handle.as_bytes());
|
||||||
.await;
|
let mut legacy_authenticator = authenticator();
|
||||||
let before_add = f.credentials(&expected);
|
let credential = legacy_authenticator
|
||||||
let mut added = authenticator();
|
.do_registration(Url::parse(ORIGIN_URL).unwrap(), options)
|
||||||
let options = f.options("addition", Some(&cookie), "").await;
|
.unwrap();
|
||||||
let stable_user_handle = options.public_key.public_key.user.id.clone();
|
let passkey = webauthn
|
||||||
assert_eq!(
|
.finish_passkey_registration(&credential, &state)
|
||||||
stable_user_handle.as_slice(),
|
.unwrap();
|
||||||
passkey_user_handle(&user.account_id).as_bytes()
|
let record = PasskeyCredentialRecord {
|
||||||
);
|
credential_id: passkey_credential_id(&passkey).unwrap(),
|
||||||
assert_ne!(stable_user_handle, legacy_user_handle);
|
user_id: user.user_id.clone(),
|
||||||
let request = Fixture::credential(options, &mut added);
|
public_key_cose: serde_json::to_string(&passkey).unwrap(),
|
||||||
let (status, _, body) = f
|
transports_json: None,
|
||||||
.post(
|
sign_count: 0,
|
||||||
"/api/auth/passkeys/addition/complete",
|
created_at: crate::auth::now_rfc3339(),
|
||||||
Some(&cookie),
|
last_used_at: None,
|
||||||
Some(ORIGIN_URL),
|
};
|
||||||
request,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(status, StatusCode::OK, "{body}");
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_value::<AuthUserResponse>(body)
|
|
||||||
.unwrap()
|
|
||||||
.user,
|
|
||||||
expected
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
f.api
|
f.api
|
||||||
.store
|
.store
|
||||||
.get_passkey_credential(&record.credential_id)
|
.register_verified_passkey(
|
||||||
.unwrap()
|
&PasskeyRegistrationTarget::NewAccount { user: user.clone() },
|
||||||
.as_ref(),
|
&record,
|
||||||
Some(&before_add[0])
|
)
|
||||||
);
|
.unwrap();
|
||||||
for returned_handle in [None, Some(legacy_user_handle.as_slice())] {
|
let expected = user_response(user.clone());
|
||||||
let (_, logged_in) = f
|
if resident {
|
||||||
|
let normal = f.discoverable_request(&mut legacy_authenticator).await;
|
||||||
|
rejected(
|
||||||
|
f.post(
|
||||||
|
"/api/auth/passkeys/login/complete",
|
||||||
|
None,
|
||||||
|
Some(ORIGIN_URL),
|
||||||
|
serde_json::to_value(normal).unwrap(),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"passkey_user_mismatch",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let normal = f.discoverable_options().await;
|
||||||
|
assert!(
|
||||||
|
legacy_authenticator
|
||||||
|
.do_authentication(Url::parse(ORIGIN_URL).unwrap(), normal.public_key)
|
||||||
|
.is_err(),
|
||||||
|
"a non-resident key cannot be discovered without an allow list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for returned_handle in [None, Some(legacy_user_handle.as_slice())] {
|
||||||
|
let (_, logged_in) = f
|
||||||
|
.login_session_with_user_handle(
|
||||||
|
"legacy",
|
||||||
|
&mut legacy_authenticator,
|
||||||
|
returned_handle,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(logged_in, expected);
|
||||||
|
}
|
||||||
|
let (cookie, _) = f
|
||||||
.login_session_with_user_handle(
|
.login_session_with_user_handle(
|
||||||
"legacy",
|
"legacy",
|
||||||
&mut legacy_authenticator,
|
&mut legacy_authenticator,
|
||||||
returned_handle,
|
Some(legacy_user_handle.as_slice()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(logged_in, expected);
|
let before_add = f.credentials(&expected);
|
||||||
}
|
let mut added = authenticator();
|
||||||
for returned_handle in [None, Some(stable_user_handle.as_slice())] {
|
let options = f.options("addition", Some(&cookie), "").await;
|
||||||
let (_, logged_in) = f
|
let stable_user_handle = options.public_key.public_key.user.id.clone();
|
||||||
.login_session_with_user_handle("legacy", &mut added, returned_handle)
|
assert_eq!(
|
||||||
|
stable_user_handle.as_slice(),
|
||||||
|
passkey_user_handle(&user.account_id).as_bytes()
|
||||||
|
);
|
||||||
|
assert_ne!(stable_user_handle, legacy_user_handle);
|
||||||
|
let request = Fixture::credential(options, &mut added);
|
||||||
|
let (status, _, body) = f
|
||||||
|
.post(
|
||||||
|
"/api/auth/passkeys/addition/complete",
|
||||||
|
Some(&cookie),
|
||||||
|
Some(ORIGIN_URL),
|
||||||
|
request,
|
||||||
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(logged_in, expected);
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<AuthUserResponse>(body)
|
||||||
|
.unwrap()
|
||||||
|
.user,
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
f.api
|
||||||
|
.store
|
||||||
|
.get_passkey_credential(&record.credential_id)
|
||||||
|
.unwrap()
|
||||||
|
.as_ref(),
|
||||||
|
Some(&before_add[0])
|
||||||
|
);
|
||||||
|
for returned_handle in [None, Some(legacy_user_handle.as_slice())] {
|
||||||
|
let (_, logged_in) = f
|
||||||
|
.login_session_with_user_handle(
|
||||||
|
"legacy",
|
||||||
|
&mut legacy_authenticator,
|
||||||
|
returned_handle,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(logged_in, expected);
|
||||||
|
}
|
||||||
|
for returned_handle in [None, Some(stable_user_handle.as_slice())] {
|
||||||
|
let (_, logged_in) = f
|
||||||
|
.login_session_with_user_handle("legacy", &mut added, returned_handle)
|
||||||
|
.await;
|
||||||
|
assert_eq!(logged_in, expected);
|
||||||
|
}
|
||||||
|
assert_eq!(f.discoverable_login(&mut added).await, expected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn generated_passkey_routes_complete_registration_login_and_logout_cookie_lifecycle() {
|
async fn generated_passkey_routes_complete_registration_login_and_logout_cookie_lifecycle() {
|
||||||
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
|
mod resident_model {
|
||||||
|
include!("../../../tests/e2e/src/resident_authenticator.rs");
|
||||||
|
}
|
||||||
|
use resident_model::ResidentAuthenticator;
|
||||||
|
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
let mut api = test_api(workspace.path()).await;
|
let mut api = test_api(workspace.path()).await;
|
||||||
@@ -53198,7 +53338,7 @@ mod tests {
|
|||||||
let origin = origin.clone();
|
let origin = origin.clone();
|
||||||
let cookie_name = cookie_name.clone();
|
let cookie_name = cookie_name.clone();
|
||||||
let app = generated_auth_contract_router(ServerApiContractService::Workspace(api));
|
let app = generated_auth_contract_router(ServerApiContractService::Workspace(api));
|
||||||
let mut authenticator = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut authenticator = ResidentAuthenticator::new(true);
|
||||||
let authenticator_origin = url::Url::parse(&origin).unwrap();
|
let authenticator_origin = url::Url::parse(&origin).unwrap();
|
||||||
|
|
||||||
let registration_options_response = app
|
let registration_options_response = app
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Production generated routes, SQLite and WebAuthn verifier share the passkey
|
// Production generated routes, SQLite and WebAuthn verifier share the passkey
|
||||||
// security fixture. Only the authenticator is SoftPasskey, not a browser/OS key.
|
// security fixture. The authenticator is a resident model, not a browser/OS key.
|
||||||
// The ordinary Server test target selects these isolated integration cases.
|
// The ordinary Server test target selects these isolated integration cases.
|
||||||
|
|
||||||
impl Fixture {
|
impl Fixture {
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
// The generated HTTP router, real SQLite and production verifier/session issuer
|
||||||
|
// are connected. Only the authenticator is the shared resident software model;
|
||||||
|
// SoftPasskey alone is not resident-capable and browser-native UX is not claimed.
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
async fn discoverable_options(&self) -> PasskeyLoginOptionsResponse {
|
||||||
|
let (status, _, body) = self
|
||||||
|
.post(
|
||||||
|
"/api/auth/passkeys/login/options",
|
||||||
|
None,
|
||||||
|
Some(ORIGIN_URL),
|
||||||
|
serde_json::json!({}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
let options: PasskeyLoginOptionsResponse = serde_json::from_value(body).unwrap();
|
||||||
|
assert!(options.public_key.public_key.allow_credentials.is_empty());
|
||||||
|
assert!(options.public_key.mediation.is_none());
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&options.public_key).unwrap()["publicKey"]["userVerification"],
|
||||||
|
"required"
|
||||||
|
);
|
||||||
|
let owner: Option<String> = self
|
||||||
|
.api
|
||||||
|
.config_store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
Ok(conn.query_row(
|
||||||
|
"SELECT user_id FROM auth_challenges WHERE challenge_id=?1",
|
||||||
|
[&options.challenge_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
owner.is_none(),
|
||||||
|
"normal challenge must not preselect any account"
|
||||||
|
);
|
||||||
|
options
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discoverable_request(&self, key: &mut Authenticator) -> PasskeyLoginCompleteRequest {
|
||||||
|
let options = self.discoverable_options().await;
|
||||||
|
let credential = key
|
||||||
|
.do_authentication(Url::parse(ORIGIN_URL).unwrap(), options.public_key)
|
||||||
|
.unwrap();
|
||||||
|
PasskeyLoginCompleteRequest {
|
||||||
|
challenge_id: options.challenge_id,
|
||||||
|
credential,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discoverable_login(&self, key: &mut Authenticator) -> AuthenticatedUser {
|
||||||
|
let request = self.discoverable_request(key).await;
|
||||||
|
let (status, headers, body) = self
|
||||||
|
.post(
|
||||||
|
"/api/auth/passkeys/login/complete",
|
||||||
|
None,
|
||||||
|
Some(ORIGIN_URL),
|
||||||
|
serde_json::to_value(request).unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
let user: AuthUserResponse = serde_json::from_value(body).unwrap();
|
||||||
|
let cookie = headers[axum::http::header::SET_COOKIE]
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.unwrap();
|
||||||
|
let token = cookie.split_once('=').unwrap().1;
|
||||||
|
let live = crate::auth::resolve_request_actor_sync(
|
||||||
|
self.api.store.as_ref(),
|
||||||
|
None,
|
||||||
|
Some(token.to_string()),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(live.user_id, user.user.user_id);
|
||||||
|
assert_eq!(live.account_id, user.user.account_id);
|
||||||
|
user.user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resident_selection_with_multiple_accounts_and_all_enrollment_purposes_issues_only_owner_session()
|
||||||
|
{
|
||||||
|
let f = Fixture::new().await;
|
||||||
|
let mut device = authenticator();
|
||||||
|
let (alice_cookie, alice) = f.enroll("registration", None, "alice", &mut device).await;
|
||||||
|
let alice_id = f.credentials(&alice)[0].credential_id.clone();
|
||||||
|
let (_, bob) = f.enroll("registration", None, "bob", &mut device).await;
|
||||||
|
let bob_id = f.credentials(&bob)[0].credential_id.clone();
|
||||||
|
for (id, expected) in [(alice_id, alice.clone()), (bob_id, bob)] {
|
||||||
|
let id: webauthn_rs::prelude::Base64UrlSafeData =
|
||||||
|
serde_json::from_value(Value::String(id)).unwrap();
|
||||||
|
device.select(&id);
|
||||||
|
assert_eq!(f.discoverable_login(&mut device).await, expected);
|
||||||
|
}
|
||||||
|
let mut added = authenticator();
|
||||||
|
f.enroll("addition", Some(&alice_cookie), "", &mut added)
|
||||||
|
.await;
|
||||||
|
assert_eq!(f.discoverable_login(&mut added).await, alice);
|
||||||
|
// Registration policy controls account creation, not login or own addition.
|
||||||
|
let admin = f.activation_admin().await;
|
||||||
|
assert_eq!(f.discoverable_login(&mut added).await, alice);
|
||||||
|
let issued = f.issue_activation(&admin, "pending-discoverable").await;
|
||||||
|
let mut activated = authenticator();
|
||||||
|
let options = f.activation_options(&issued).await;
|
||||||
|
let body = Fixture::credential(options, &mut activated);
|
||||||
|
let (status, _, body) = f
|
||||||
|
.post(
|
||||||
|
"/api/auth/activation/complete",
|
||||||
|
None,
|
||||||
|
Some(ORIGIN_URL),
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, StatusCode::OK, "{body}");
|
||||||
|
assert_eq!(f.discoverable_login(&mut activated).await, issued.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn discoverable_verifier_rejects_unknown_owner_signature_origin_rp_uv_expiry_reuse_and_revocation()
|
||||||
|
{
|
||||||
|
let f = Fixture::new().await;
|
||||||
|
let mut key = authenticator();
|
||||||
|
let (_, alice) = f.enroll("registration", None, "alice", &mut key).await;
|
||||||
|
for defect in [
|
||||||
|
"unknown",
|
||||||
|
"handle",
|
||||||
|
"signature",
|
||||||
|
"origin",
|
||||||
|
"rp",
|
||||||
|
"uv",
|
||||||
|
"expired",
|
||||||
|
"consumed",
|
||||||
|
"stored_id",
|
||||||
|
"stored_owner",
|
||||||
|
] {
|
||||||
|
let mut request = f.discoverable_request(&mut key).await;
|
||||||
|
let original = f.credentials(&alice)[0].clone();
|
||||||
|
let change_byte =
|
||||||
|
|value: &mut webauthn_rs::prelude::Base64UrlSafeData, index: usize, bit: u8| {
|
||||||
|
let mut bytes = value.to_vec();
|
||||||
|
bytes[index] ^= bit;
|
||||||
|
*value = bytes.into();
|
||||||
|
};
|
||||||
|
match defect {
|
||||||
|
"unknown" => change_byte(&mut request.credential.raw_id, 0, 1),
|
||||||
|
"handle" => request.credential.response.user_handle = Some(vec![0; 16].into()),
|
||||||
|
"signature" => change_byte(&mut request.credential.response.signature, 0, 1),
|
||||||
|
"origin" => {
|
||||||
|
let mut data: Value =
|
||||||
|
serde_json::from_slice(&request.credential.response.client_data_json).unwrap();
|
||||||
|
data["origin"] = Value::String("https://other.test".into());
|
||||||
|
request.credential.response.client_data_json =
|
||||||
|
serde_json::to_vec(&data).unwrap().into();
|
||||||
|
}
|
||||||
|
"rp" => change_byte(&mut request.credential.response.authenticator_data, 0, 1),
|
||||||
|
"uv" => change_byte(&mut request.credential.response.authenticator_data, 32, 4),
|
||||||
|
"expired" => f.expire_challenge(&request.challenge_id),
|
||||||
|
"consumed" => {
|
||||||
|
f.api
|
||||||
|
.store
|
||||||
|
.consume_auth_challenge_by_id(
|
||||||
|
&request.challenge_id,
|
||||||
|
"passkey_login",
|
||||||
|
&crate::auth::now_rfc3339(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
"stored_id" => {
|
||||||
|
let mut corrupt = original.clone();
|
||||||
|
let mut value: Value = serde_json::from_str(&corrupt.public_key_cose).unwrap();
|
||||||
|
// Keep the indexed ID, replace the embedded verified credential ID.
|
||||||
|
value["cred"]["cred_id"] = Value::String("YW5vdGhlci1jcmVkZW50aWFs".into());
|
||||||
|
corrupt.public_key_cose = serde_json::to_string(&value).unwrap();
|
||||||
|
f.api.store.upsert_passkey_credential(&corrupt).unwrap();
|
||||||
|
}
|
||||||
|
"stored_owner" => {
|
||||||
|
let (_, bob) = f
|
||||||
|
.enroll("registration", None, "bob", &mut authenticator())
|
||||||
|
.await;
|
||||||
|
f.api
|
||||||
|
.config_store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE passkey_credentials SET user_id=?1 WHERE credential_id=?2",
|
||||||
|
[&bob.user_id, &original.credential_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
let (status, headers, body) = f
|
||||||
|
.post(
|
||||||
|
"/api/auth/passkeys/login/complete",
|
||||||
|
None,
|
||||||
|
Some(ORIGIN_URL),
|
||||||
|
serde_json::to_value(&request).unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(status.is_client_error(), "{defect}: {body}");
|
||||||
|
assert!(
|
||||||
|
!headers.contains_key(axum::http::header::SET_COOKIE),
|
||||||
|
"{defect}"
|
||||||
|
);
|
||||||
|
let (status, headers, _) = f
|
||||||
|
.post(
|
||||||
|
"/api/auth/passkeys/login/complete",
|
||||||
|
None,
|
||||||
|
Some(ORIGIN_URL),
|
||||||
|
serde_json::to_value(request).unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
status.is_client_error(),
|
||||||
|
"failed attempts must consume their ceremony: {defect}"
|
||||||
|
);
|
||||||
|
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
|
||||||
|
f.api
|
||||||
|
.config_store
|
||||||
|
.with_conn(|conn| {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE passkey_credentials SET user_id=?1 WHERE credential_id=?2",
|
||||||
|
[&original.user_id, &original.credential_id],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
f.api.store.upsert_passkey_credential(&original).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(f.discoverable_login(&mut key).await, alice);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn signed_assertions_with_foreign_origin_rp_or_without_uv_are_rejected_by_required_verifier()
|
||||||
|
{
|
||||||
|
let f = Fixture::new().await;
|
||||||
|
let mut key = authenticator();
|
||||||
|
let (_, alice) = f.enroll("registration", None, "alice", &mut key).await;
|
||||||
|
let id: webauthn_rs::prelude::Base64UrlSafeData = serde_json::from_value(Value::String(
|
||||||
|
f.credentials(&alice)[0].credential_id.clone(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
for defect in ["origin", "rp", "uv"] {
|
||||||
|
let mut options = f.discoverable_options().await;
|
||||||
|
let mut origin = ORIGIN_URL;
|
||||||
|
match defect {
|
||||||
|
"origin" => origin = "https://sub.workspace.test",
|
||||||
|
"rp" => {
|
||||||
|
origin = "https://other.test";
|
||||||
|
options.public_key.public_key.rp_id = "other.test".into();
|
||||||
|
// Explicit signer selection produces a genuinely signed assertion
|
||||||
|
// for the wrong RP; normal Server options remain empty/unchanged.
|
||||||
|
options.public_key.public_key.allow_credentials =
|
||||||
|
vec![webauthn_rs_proto::AllowCredentials {
|
||||||
|
type_: "public-key".into(),
|
||||||
|
id: id.clone(),
|
||||||
|
transports: None,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
"uv" => {
|
||||||
|
options.public_key.public_key.user_verification =
|
||||||
|
webauthn_rs_proto::UserVerificationPolicy::Preferred
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
let mut credential = key
|
||||||
|
.do_authentication(Url::parse(origin).unwrap(), options.public_key)
|
||||||
|
.unwrap();
|
||||||
|
if defect == "uv" {
|
||||||
|
assert_eq!(credential.response.authenticator_data[32] & 4, 0);
|
||||||
|
}
|
||||||
|
credential.response.user_handle = Some(
|
||||||
|
passkey_user_handle(&alice.account_id)
|
||||||
|
.as_bytes()
|
||||||
|
.to_vec()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
let request = PasskeyLoginCompleteRequest {
|
||||||
|
challenge_id: options.challenge_id,
|
||||||
|
credential,
|
||||||
|
};
|
||||||
|
rejected(
|
||||||
|
f.post(
|
||||||
|
"/api/auth/passkeys/login/complete",
|
||||||
|
None,
|
||||||
|
Some(ORIGIN_URL),
|
||||||
|
serde_json::to_value(request).unwrap(),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"webauthn_login_verification_failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(f.discoverable_login(&mut key).await, alice);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn non_resident_library_authenticator_refuses_registration_without_creating_identity() {
|
||||||
|
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
|
||||||
|
let f = Fixture::new().await;
|
||||||
|
let options = f.options("registration", None, "unsupported-key").await;
|
||||||
|
let selection = options
|
||||||
|
.public_key
|
||||||
|
.public_key
|
||||||
|
.authenticator_selection
|
||||||
|
.as_ref()
|
||||||
|
.unwrap();
|
||||||
|
assert!(selection.require_resident_key);
|
||||||
|
assert_eq!(
|
||||||
|
selection.resident_key,
|
||||||
|
Some(webauthn_rs_proto::ResidentKeyRequirement::Required)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
selection.user_verification,
|
||||||
|
webauthn_rs_proto::UserVerificationPolicy::Required
|
||||||
|
);
|
||||||
|
let result = WebauthnAuthenticator::new(SoftPasskey::new(true))
|
||||||
|
.do_registration(Url::parse(ORIGIN_URL).unwrap(), options.public_key);
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"SoftPasskey itself must not be reported as resident-capable"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
f.api
|
||||||
|
.store
|
||||||
|
.get_user_by_handle("unsupported-key")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6661,7 +6661,7 @@
|
|||||||
},
|
},
|
||||||
"PasskeyLoginOptionsRequest": {
|
"PasskeyLoginOptionsRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"description": "Identified-account login. Missing handles are rejected, not resolved to an implicit user.\n\nThe nullable wire field permits an explicit authentication error for old callers; it does not\nadvertise a discoverable-login ceremony.",
|
"description": "Normal discoverable login omits the handle: the authenticator selects a credential.\nAn explicit handle selects legacy identified-account login, for non-resident keys or\nkeys registered with an old random WebAuthn user handle. This never adds a key or\nfalls back to another identity. Authenticate first, then enroll an RK from Account.\nExisting challenge states are short-lived and are not migrated to the new ceremony.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"browser_origin": {
|
"browser_origin": {
|
||||||
"type": [
|
"type": [
|
||||||
@@ -17831,7 +17831,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8"
|
"x-yoi-source-digest": "sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ tempfile.workspace = true
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
server-api.workspace = true
|
server-api.workspace = true
|
||||||
|
webauthn-rs-proto = "0.5.5"
|
||||||
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
|
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
|
||||||
|
|
||||||
[[test]]
|
[[test]]
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
// 共有テスト用のresident認証器モデル。ブラウザ/OSの実装ではない。
|
||||||
|
// SoftPasskey 0.5.5自体はRK要求を拒否しdiscoverできないため、このモデルが
|
||||||
|
// RP・credential ID・user handleのresident storeと明示選択を所有し、
|
||||||
|
// SoftPasskeyはattestation/署名エンジンとしてだけ使う。本番verifier、
|
||||||
|
// challenge、session発行を固定成功fakeへ置換しない。UVはテスト用bitの
|
||||||
|
// 模擬であり、物理的PIN/生体確認やbrowser-nativeの互換性は未検証。
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use webauthn_authenticator_rs::prelude::{Url, WebauthnCError};
|
||||||
|
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
|
||||||
|
use webauthn_rs_proto::{
|
||||||
|
AllowCredentials, CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential,
|
||||||
|
RequestChallengeResponse, ResidentKeyRequirement,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct ResidentAuthenticator {
|
||||||
|
signer: WebauthnAuthenticator<SoftPasskey>,
|
||||||
|
resident: BTreeMap<Vec<u8>, (String, Vec<u8>)>,
|
||||||
|
selected: Option<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResidentAuthenticator {
|
||||||
|
pub fn new(simulate_uv: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
signer: WebauthnAuthenticator::new(SoftPasskey::new(simulate_uv)),
|
||||||
|
resident: BTreeMap::new(),
|
||||||
|
selected: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select(&mut self, credential_id: &[u8]) {
|
||||||
|
assert!(
|
||||||
|
self.resident.contains_key(credential_id),
|
||||||
|
"selection must be resident on this authenticator"
|
||||||
|
);
|
||||||
|
self.selected = Some(credential_id.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn do_registration(
|
||||||
|
&mut self,
|
||||||
|
origin: Url,
|
||||||
|
mut options: CreationChallengeResponse,
|
||||||
|
) -> Result<RegisterPublicKeyCredential, WebauthnCError> {
|
||||||
|
let rp = options.public_key.rp.id.clone();
|
||||||
|
let user = options.public_key.user.id.to_vec();
|
||||||
|
let resident = options
|
||||||
|
.public_key
|
||||||
|
.authenticator_selection
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|selection| {
|
||||||
|
selection.require_resident_key
|
||||||
|
|| selection.resident_key == Some(ResidentKeyRequirement::Required)
|
||||||
|
});
|
||||||
|
if resident {
|
||||||
|
// 要求されたresident保存はこのモデルが担当し、署名だけを
|
||||||
|
// non-residentエンジンへ渡す。Serverの要求を黙って無視しない。
|
||||||
|
let selection = options.public_key.authenticator_selection.as_mut().unwrap();
|
||||||
|
selection.require_resident_key = false;
|
||||||
|
selection.resident_key = Some(ResidentKeyRequirement::Discouraged);
|
||||||
|
}
|
||||||
|
let credential = self.signer.do_registration(origin, options)?;
|
||||||
|
if resident {
|
||||||
|
self.resident.insert(credential.raw_id.to_vec(), (rp, user));
|
||||||
|
self.selected = None;
|
||||||
|
}
|
||||||
|
Ok(credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn do_authentication(
|
||||||
|
&mut self,
|
||||||
|
origin: Url,
|
||||||
|
mut options: RequestChallengeResponse,
|
||||||
|
) -> Result<PublicKeyCredential, WebauthnCError> {
|
||||||
|
let discovered = options.public_key.allow_credentials.is_empty();
|
||||||
|
let user_handle = if discovered {
|
||||||
|
let candidates: Vec<_> = self
|
||||||
|
.resident
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, (rp, _))| rp == &options.public_key.rp_id)
|
||||||
|
.collect();
|
||||||
|
let (id, (_, handle)) = match self.selected.as_ref() {
|
||||||
|
Some(selected) => candidates.into_iter().find(|(id, _)| *id == selected),
|
||||||
|
None if candidates.len() == 1 => candidates.into_iter().next(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
.ok_or(WebauthnCError::NotSupported)?;
|
||||||
|
options.public_key.allow_credentials = vec![AllowCredentials {
|
||||||
|
type_: "public-key".into(),
|
||||||
|
id: id.clone().into(),
|
||||||
|
transports: None,
|
||||||
|
}];
|
||||||
|
Some(handle.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut credential = self.signer.do_authentication(origin, options)?;
|
||||||
|
credential.response.user_handle = user_handle.map(Into::into);
|
||||||
|
Ok(credential)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、
|
//! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、
|
||||||
//! 明示handle login、本人の鍵追加、限定activation、Workspace所有・catalog選択境界を検証する。DB・認証・routerは本番実装を使い、
|
//! ID不要login、本人の鍵追加、限定activation、Workspace所有・catalog選択境界を検証する。DB・認証・routerは本番実装を使い、
|
||||||
//! authenticatorだけをSoftPasskeyに置換する(browser UI/OS authenticatorは対象外)。
|
//! authenticatorはresident store/選択を持つsoftware model、署名エンジンのみSoftPasskeyとする(browser UI/OS認証器は対象外)。
|
||||||
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。
|
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。
|
||||||
//! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。
|
//! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。
|
||||||
//! HTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。
|
//! HTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。
|
||||||
@@ -24,7 +24,9 @@ use server_api::{
|
|||||||
PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest,
|
PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest,
|
||||||
PasskeyRegistrationOptionsResponse, WhoamiResponse,
|
PasskeyRegistrationOptionsResponse, WhoamiResponse,
|
||||||
};
|
};
|
||||||
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
|
#[path = "../src/resident_authenticator.rs"]
|
||||||
|
mod resident_model;
|
||||||
|
use resident_model::ResidentAuthenticator;
|
||||||
use yoi_e2e::WorkerE2eEnvironment;
|
use yoi_e2e::WorkerE2eEnvironment;
|
||||||
|
|
||||||
type Result<T = ()> = std::result::Result<T, Box<dyn Error>>;
|
type Result<T = ()> = std::result::Result<T, Box<dyn Error>>;
|
||||||
@@ -99,7 +101,7 @@ fn register(
|
|||||||
base: &str,
|
base: &str,
|
||||||
artifacts: &Path,
|
artifacts: &Path,
|
||||||
handle: &str,
|
handle: &str,
|
||||||
authenticator: &mut WebauthnAuthenticator<SoftPasskey>,
|
authenticator: &mut ResidentAuthenticator,
|
||||||
) -> Result<(AuthUserResponse, String, Value)> {
|
) -> Result<(AuthUserResponse, String, Value)> {
|
||||||
let options: PasskeyRegistrationOptionsResponse = decode(send(
|
let options: PasskeyRegistrationOptionsResponse = decode(send(
|
||||||
client.post(format!("{base}/api/auth/passkeys/registration/options"))
|
client.post(format!("{base}/api/auth/passkeys/registration/options"))
|
||||||
@@ -130,19 +132,19 @@ fn login(
|
|||||||
base: &str,
|
base: &str,
|
||||||
artifacts: &Path,
|
artifacts: &Path,
|
||||||
handle: &str,
|
handle: &str,
|
||||||
authenticator: &mut WebauthnAuthenticator<SoftPasskey>,
|
authenticator: &mut ResidentAuthenticator,
|
||||||
) -> Result<(AuthUserResponse, String)> {
|
) -> Result<(AuthUserResponse, String)> {
|
||||||
let options: PasskeyLoginOptionsResponse = decode(send(
|
let options: PasskeyLoginOptionsResponse = decode(send(
|
||||||
client
|
client
|
||||||
.post(format!("{base}/api/auth/passkeys/login/options"))
|
.post(format!("{base}/api/auth/passkeys/login/options"))
|
||||||
.header(ORIGIN, BROWSER_ORIGIN)
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
.json(&json!({"handle": handle, "browser_origin": BROWSER_ORIGIN})),
|
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||||
"explicit handle login options",
|
"discoverable login options",
|
||||||
artifacts,
|
artifacts,
|
||||||
)?)?;
|
)?)?;
|
||||||
let credential =
|
let credential =
|
||||||
authenticator.do_authentication(BROWSER_ORIGIN.parse()?, options.public_key)?;
|
authenticator.do_authentication(BROWSER_ORIGIN.parse()?, options.public_key)?;
|
||||||
session(send(
|
let result = session(send(
|
||||||
client
|
client
|
||||||
.post(format!("{base}/api/auth/passkeys/login/complete"))
|
.post(format!("{base}/api/auth/passkeys/login/complete"))
|
||||||
.header(ORIGIN, BROWSER_ORIGIN)
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
@@ -152,7 +154,9 @@ fn login(
|
|||||||
}),
|
}),
|
||||||
"verified login complete",
|
"verified login complete",
|
||||||
artifacts,
|
artifacts,
|
||||||
)?)
|
)?)?;
|
||||||
|
assert_eq!(result.0.user.handle, handle);
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn http_client() -> Result<Client> {
|
fn http_client() -> Result<Client> {
|
||||||
@@ -265,7 +269,7 @@ fn logout(client: &Client, base: &str, artifacts: &Path, cookie: &str) -> Result
|
|||||||
fn host_admin_login(
|
fn host_admin_login(
|
||||||
environment: &WorkerE2eEnvironment,
|
environment: &WorkerE2eEnvironment,
|
||||||
client: &Client,
|
client: &Client,
|
||||||
key: &mut WebauthnAuthenticator<SoftPasskey>,
|
key: &mut ResidentAuthenticator,
|
||||||
) -> Result<(AuthUserResponse, String)> {
|
) -> Result<(AuthUserResponse, String)> {
|
||||||
let base = environment
|
let base = environment
|
||||||
.server_url
|
.server_url
|
||||||
@@ -338,7 +342,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
|||||||
let artifacts = &environment.artifacts_dir;
|
let artifacts = &environment.artifacts_dir;
|
||||||
let client = http_client()?;
|
let client = http_client()?;
|
||||||
policy(&client, base, artifacts, false)?;
|
policy(&client, base, artifacts, false)?;
|
||||||
let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut admin_key = ResidentAuthenticator::new(true);
|
||||||
let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
||||||
policy(&client, base, artifacts, false)?;
|
policy(&client, base, artifacts, false)?;
|
||||||
set_policy(&client, base, artifacts, &admin_cookie, true)?;
|
set_policy(&client, base, artifacts, &admin_cookie, true)?;
|
||||||
@@ -379,7 +383,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
|||||||
"auth_required",
|
"auth_required",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let mut original_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut original_key = ResidentAuthenticator::new(true);
|
||||||
let (registered, registration_cookie, user_handle) = register(
|
let (registered, registration_cookie, user_handle) = register(
|
||||||
&client,
|
&client,
|
||||||
base,
|
base,
|
||||||
@@ -418,7 +422,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
|||||||
)?, StatusCode::BAD_REQUEST, "auth_handle_exists")?;
|
)?, StatusCode::BAD_REQUEST, "auth_handle_exists")?;
|
||||||
|
|
||||||
// 2 usersを実登録し、handle省略が最古userへfallbackしないことを検証する。
|
// 2 usersを実登録し、handle省略が最古userへfallbackしないことを検証する。
|
||||||
let mut other_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut other_key = ResidentAuthenticator::new(true);
|
||||||
let other_handle = format!("{}-other", environment.account_handle);
|
let other_handle = format!("{}-other", environment.account_handle);
|
||||||
let (other, _, other_user_handle) =
|
let (other, _, other_user_handle) =
|
||||||
register(&client, base, artifacts, &other_handle, &mut other_key)?;
|
register(&client, base, artifacts, &other_handle, &mut other_key)?;
|
||||||
@@ -427,18 +431,15 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
|||||||
access(&client, base, artifacts, &cookie, false)?;
|
access(&client, base, artifacts, &cookie, false)?;
|
||||||
set_policy(&client, base, artifacts, &admin_cookie, false)?;
|
set_policy(&client, base, artifacts, &admin_cookie, false)?;
|
||||||
// 以下の本人鍵追加と両方の鍵によるloginは、自己登録停止中にも必要。
|
// 以下の本人鍵追加と両方の鍵によるloginは、自己登録停止中にも必要。
|
||||||
rejected(
|
let options: PasskeyLoginOptionsResponse = decode(send(
|
||||||
send(
|
client
|
||||||
client
|
.post(format!("{base}/api/auth/passkeys/login/options"))
|
||||||
.post(format!("{base}/api/auth/passkeys/login/options"))
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
.header(ORIGIN, BROWSER_ORIGIN)
|
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
"normal login has no account inventory",
|
||||||
"omitted handle cannot select first account",
|
artifacts,
|
||||||
artifacts,
|
)?)?;
|
||||||
)?,
|
assert!(options.public_key.public_key.allow_credentials.is_empty());
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"passkey_handle_required",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
for (field, value) in [
|
for (field, value) in [
|
||||||
("handle", &other.user.handle),
|
("handle", &other.user.handle),
|
||||||
@@ -494,7 +495,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
|||||||
user_handle,
|
user_handle,
|
||||||
"same account must retain its WebAuthn user handle"
|
"same account must retain its WebAuthn user handle"
|
||||||
);
|
);
|
||||||
let mut added_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut added_key = ResidentAuthenticator::new(true);
|
||||||
let credential = added_key.do_registration(BROWSER_ORIGIN.parse()?, options.public_key)?;
|
let credential = added_key.do_registration(BROWSER_ORIGIN.parse()?, options.public_key)?;
|
||||||
let complete = PasskeyRegistrationCompleteRequest {
|
let complete = PasskeyRegistrationCompleteRequest {
|
||||||
challenge_id: options.challenge_id,
|
challenge_id: options.challenge_id,
|
||||||
@@ -725,10 +726,10 @@ fn workspace_catalog_zero_one_multiple_preserves_explicit_selection_and_actor_ow
|
|||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
"authenticated owner",
|
"authenticated owner",
|
||||||
)?;
|
)?;
|
||||||
let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut admin_key = ResidentAuthenticator::new(true);
|
||||||
let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
||||||
set_policy(&client, base, artifacts, &admin_cookie, true)?;
|
set_policy(&client, base, artifacts, &admin_cookie, true)?;
|
||||||
let mut key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut key = ResidentAuthenticator::new(true);
|
||||||
let (registered, registration_cookie, _) = register(
|
let (registered, registration_cookie, _) = register(
|
||||||
&client,
|
&client,
|
||||||
base,
|
base,
|
||||||
@@ -881,7 +882,7 @@ fn ordinary_device_login_requires_explicit_approval_and_does_not_grant_hosting_a
|
|||||||
base: &str,
|
base: &str,
|
||||||
artifacts: &Path,
|
artifacts: &Path,
|
||||||
user: &AuthUserResponse,
|
user: &AuthUserResponse,
|
||||||
key: &mut WebauthnAuthenticator<SoftPasskey>,
|
key: &mut ResidentAuthenticator,
|
||||||
) -> Result {
|
) -> Result {
|
||||||
let flow: Value = decode(send(
|
let flow: Value = decode(send(
|
||||||
client
|
client
|
||||||
@@ -1007,7 +1008,7 @@ fn hosting_policy_rejects_spoofing_inflight_registration_and_survives_restart()
|
|||||||
let artifacts = environment.artifacts_dir.clone();
|
let artifacts = environment.artifacts_dir.clone();
|
||||||
policy(&client, &base, &artifacts, false)?;
|
policy(&client, &base, &artifacts, false)?;
|
||||||
|
|
||||||
let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut admin_key = ResidentAuthenticator::new(true);
|
||||||
let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
||||||
policy(&client, &base, &artifacts, false)?;
|
policy(&client, &base, &artifacts, false)?;
|
||||||
for path in ["/api/hosting/access", "/api/hosting/settings"] {
|
for path in ["/api/hosting/access", "/api/hosting/settings"] {
|
||||||
@@ -1102,7 +1103,7 @@ fn hosting_policy_rejects_spoofing_inflight_registration_and_survives_restart()
|
|||||||
access(&client, &base, &artifacts, &admin_cookie, true)?;
|
access(&client, &base, &artifacts, &admin_cookie, true)?;
|
||||||
|
|
||||||
// 拒否されたhandleも予約されておらず、許可後には通常の新規登録ができる。
|
// 拒否されたhandleも予約されておらず、許可後には通常の新規登録ができる。
|
||||||
let mut ordinary_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut ordinary_key = ResidentAuthenticator::new(true);
|
||||||
let (ordinary, ordinary_cookie, _) = register(
|
let (ordinary, ordinary_cookie, _) = register(
|
||||||
&client,
|
&client,
|
||||||
&base,
|
&base,
|
||||||
@@ -1172,7 +1173,7 @@ fn hosting_policy_rejects_spoofing_inflight_registration_and_survives_restart()
|
|||||||
"registration starts while enabled",
|
"registration starts while enabled",
|
||||||
&artifacts,
|
&artifacts,
|
||||||
)?)?;
|
)?)?;
|
||||||
let mut pending_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut pending_key = ResidentAuthenticator::new(true);
|
||||||
let credential = pending_key.do_registration(BROWSER_ORIGIN.parse()?, options.public_key)?;
|
let credential = pending_key.do_registration(BROWSER_ORIGIN.parse()?, options.public_key)?;
|
||||||
let complete = PasskeyRegistrationCompleteRequest {
|
let complete = PasskeyRegistrationCompleteRequest {
|
||||||
challenge_id: options.challenge_id,
|
challenge_id: options.challenge_id,
|
||||||
@@ -1396,7 +1397,7 @@ fn activation_options(
|
|||||||
|
|
||||||
fn activation_credential(
|
fn activation_credential(
|
||||||
options: PasskeyRegistrationOptionsResponse,
|
options: PasskeyRegistrationOptionsResponse,
|
||||||
key: &mut WebauthnAuthenticator<SoftPasskey>,
|
key: &mut ResidentAuthenticator,
|
||||||
) -> Result<PasskeyRegistrationCompleteRequest> {
|
) -> Result<PasskeyRegistrationCompleteRequest> {
|
||||||
Ok(PasskeyRegistrationCompleteRequest {
|
Ok(PasskeyRegistrationCompleteRequest {
|
||||||
challenge_id: options.challenge_id,
|
challenge_id: options.challenge_id,
|
||||||
@@ -1461,7 +1462,7 @@ fn activation_with_disabled_self_registration_survives_reopen_and_enables_only_p
|
|||||||
let client = http_client()?;
|
let client = http_client()?;
|
||||||
let artifacts = environment.artifacts_dir.clone();
|
let artifacts = environment.artifacts_dir.clone();
|
||||||
let mut base = environment.server_url.clone().ok_or("missing server URL")?;
|
let mut base = environment.server_url.clone().ok_or("missing server URL")?;
|
||||||
let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut admin_key = ResidentAuthenticator::new(true);
|
||||||
let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
||||||
policy(&client, &base, &artifacts, false)?;
|
policy(&client, &base, &artifacts, false)?;
|
||||||
let issue = create_pending(
|
let issue = create_pending(
|
||||||
@@ -1496,7 +1497,7 @@ fn activation_with_disabled_self_registration_survives_reopen_and_enables_only_p
|
|||||||
.ok_or("missing restarted server URL")?;
|
.ok_or("missing restarted server URL")?;
|
||||||
policy(&client, &base, &artifacts, false)?;
|
policy(&client, &base, &artifacts, false)?;
|
||||||
let options = activation_options(&client, &base, &artifacts, &issue)?;
|
let options = activation_options(&client, &base, &artifacts, &issue)?;
|
||||||
let mut key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut key = ResidentAuthenticator::new(true);
|
||||||
let complete = activation_credential(options, &mut key)?;
|
let complete = activation_credential(options, &mut key)?;
|
||||||
let (activated, cookie) = session(activation_success(send(
|
let (activated, cookie) = session(activation_success(send(
|
||||||
activation_complete_request(&client, &base, &complete),
|
activation_complete_request(&client, &base, &complete),
|
||||||
@@ -1558,7 +1559,7 @@ fn activation_issuance_requires_live_admin_and_cookie_origin_not_identity_claims
|
|||||||
.server_url
|
.server_url
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or("missing server URL")?;
|
.ok_or("missing server URL")?;
|
||||||
let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut admin_key = ResidentAuthenticator::new(true);
|
||||||
let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
||||||
let ordinary = create_pending(
|
let ordinary = create_pending(
|
||||||
&client,
|
&client,
|
||||||
@@ -1567,7 +1568,7 @@ fn activation_issuance_requires_live_admin_and_cookie_origin_not_identity_claims
|
|||||||
&admin_cookie,
|
&admin_cookie,
|
||||||
&environment.account_handle,
|
&environment.account_handle,
|
||||||
)?;
|
)?;
|
||||||
let mut ordinary_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut ordinary_key = ResidentAuthenticator::new(true);
|
||||||
let complete = activation_credential(
|
let complete = activation_credential(
|
||||||
activation_options(&client, base, artifacts, &ordinary)?,
|
activation_options(&client, base, artifacts, &ordinary)?,
|
||||||
&mut ordinary_key,
|
&mut ordinary_key,
|
||||||
@@ -1721,7 +1722,7 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or("missing server URL")?;
|
.ok_or("missing server URL")?;
|
||||||
let artifacts = &environment.artifacts_dir;
|
let artifacts = &environment.artifacts_dir;
|
||||||
let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut admin_key = ResidentAuthenticator::new(true);
|
||||||
let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
||||||
let issue = create_pending(
|
let issue = create_pending(
|
||||||
&client,
|
&client,
|
||||||
@@ -1759,7 +1760,7 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
|
|||||||
)?)?;
|
)?)?;
|
||||||
let options = activation_options(&client, base, artifacts, &issue)?;
|
let options = activation_options(&client, base, artifacts, &issue)?;
|
||||||
let challenge_id = options.challenge_id.clone();
|
let challenge_id = options.challenge_id.clone();
|
||||||
let mut key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut key = ResidentAuthenticator::new(true);
|
||||||
let complete = activation_credential(options, &mut key)?;
|
let complete = activation_credential(options, &mut key)?;
|
||||||
|
|
||||||
// code/限定challenge IDのどちらも通常tokenではない。通常API、Workspace、device approvalに流用しない。
|
// code/限定challenge IDのどちらも通常tokenではない。通常API、Workspace、device approvalに流用しない。
|
||||||
@@ -1860,7 +1861,7 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
|
|||||||
)?)?;
|
)?)?;
|
||||||
// 別IDに対する誤ったcode入力が元のaccountを消費していない。
|
// 別IDに対する誤ったcode入力が元のaccountを消費していない。
|
||||||
let _ = activation_options(&client, base, artifacts, &other)?;
|
let _ = activation_options(&client, base, artifacts, &other)?;
|
||||||
let mut replacement_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut replacement_key = ResidentAuthenticator::new(true);
|
||||||
let replacement_complete = activation_credential(
|
let replacement_complete = activation_credential(
|
||||||
activation_options(&client, base, artifacts, &replacement)?,
|
activation_options(&client, base, artifacts, &replacement)?,
|
||||||
&mut replacement_key,
|
&mut replacement_key,
|
||||||
@@ -1884,7 +1885,7 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
|
|||||||
"start distinct public registration ceremony",
|
"start distinct public registration ceremony",
|
||||||
artifacts,
|
artifacts,
|
||||||
)?)?;
|
)?)?;
|
||||||
let mut public_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut public_key = ResidentAuthenticator::new(true);
|
||||||
let public_complete = activation_credential(public_options, &mut public_key)?;
|
let public_complete = activation_credential(public_options, &mut public_key)?;
|
||||||
activation_denied(send(
|
activation_denied(send(
|
||||||
activation_complete_request(&client, base, &public_complete),
|
activation_complete_request(&client, base, &public_complete),
|
||||||
@@ -1900,7 +1901,7 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
|
|||||||
"start distinct authenticated self-addition ceremony",
|
"start distinct authenticated self-addition ceremony",
|
||||||
artifacts,
|
artifacts,
|
||||||
)?)?;
|
)?)?;
|
||||||
let mut addition_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut addition_key = ResidentAuthenticator::new(true);
|
||||||
let addition_complete = activation_credential(addition_options, &mut addition_key)?;
|
let addition_complete = activation_credential(addition_options, &mut addition_key)?;
|
||||||
activation_denied(send(
|
activation_denied(send(
|
||||||
activation_complete_request(&client, base, &addition_complete),
|
activation_complete_request(&client, base, &addition_complete),
|
||||||
@@ -1932,7 +1933,7 @@ fn activation_bad_attestation_stays_pending_and_concurrent_ceremonies_store_only
|
|||||||
.server_url
|
.server_url
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.ok_or("missing server URL")?;
|
.ok_or("missing server URL")?;
|
||||||
let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut admin_key = ResidentAuthenticator::new(true);
|
||||||
let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
||||||
let issue = create_pending(
|
let issue = create_pending(
|
||||||
&client,
|
&client,
|
||||||
@@ -1941,7 +1942,7 @@ fn activation_bad_attestation_stays_pending_and_concurrent_ceremonies_store_only
|
|||||||
&admin_cookie,
|
&admin_cookie,
|
||||||
&environment.account_handle,
|
&environment.account_handle,
|
||||||
)?;
|
)?;
|
||||||
let mut bad_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut bad_key = ResidentAuthenticator::new(true);
|
||||||
let bad_complete = activation_credential(
|
let bad_complete = activation_credential(
|
||||||
activation_options(&client, base, artifacts, &issue)?,
|
activation_options(&client, base, artifacts, &issue)?,
|
||||||
&mut bad_key,
|
&mut bad_key,
|
||||||
@@ -1965,12 +1966,12 @@ fn activation_bad_attestation_stays_pending_and_concurrent_ceremonies_store_only
|
|||||||
"failed verifier did not activate or save a credential",
|
"failed verifier did not activate or save a credential",
|
||||||
artifacts,
|
artifacts,
|
||||||
)?)?;
|
)?)?;
|
||||||
let mut first_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut first_key = ResidentAuthenticator::new(true);
|
||||||
let first = activation_credential(
|
let first = activation_credential(
|
||||||
activation_options(&client, base, artifacts, &issue)?,
|
activation_options(&client, base, artifacts, &issue)?,
|
||||||
&mut first_key,
|
&mut first_key,
|
||||||
)?;
|
)?;
|
||||||
let mut second_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
let mut second_key = ResidentAuthenticator::new(true);
|
||||||
let second = activation_credential(
|
let second = activation_credential(
|
||||||
activation_options(&client, base, artifacts, &issue)?,
|
activation_options(&client, base, artifacts, &issue)?,
|
||||||
&mut second_key,
|
&mut second_key,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8)
|
// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8)
|
// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8)
|
// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8)
|
// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8)
|
// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
Reference in New Issue
Block a user