feat: verify discoverable passkeys against their saved owners

This commit is contained in:
2026-10-16 19:01:43 +09:00
parent 3c18916118
commit 9c12fb4372
15 changed files with 819 additions and 239 deletions
Generated
+2
View File
@@ -6713,6 +6713,7 @@ dependencies = [
"sha2 0.11.0",
"tempfile",
"webauthn-authenticator-rs",
"webauthn-rs-proto",
]
[[package]]
@@ -6773,6 +6774,7 @@ dependencies = [
"uuid",
"webauthn-authenticator-rs",
"webauthn-rs",
"webauthn-rs-proto",
"wip-protocol",
"workdir",
"worker",
+5 -4
View File
@@ -4088,10 +4088,11 @@ pub struct PasskeyRegistrationCompleteRequest {
pub credential: RegisterPublicKeyCredential,
}
/// Identified-account login. Missing handles are rejected, not resolved to an implicit user.
///
/// The nullable wire field permits an explicit authentication error for old callers; it does not
/// advertise a discoverable-login ceremony.
/// Normal discoverable login omits the handle: the authenticator selects a credential.
/// An explicit handle selects legacy identified-account login, for non-resident keys or
/// keys registered with an old random WebAuthn user handle. This never adds a key or
/// 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)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
+2 -1
View File
@@ -53,7 +53,8 @@ ts-rs = "12.0.1"
url.workspace = true
uuid = { workspace = true, features = ["v7"] }
zeroize.workspace = true
webauthn-rs = { workspace = true }
webauthn-rs = { workspace = true, features = ["conditional-ui"] }
webauthn-rs-proto = "0.5.5"
[dev-dependencies]
wip-protocol.workspace = true
+320 -180
View File
@@ -29686,11 +29686,13 @@ struct PasskeyRegistrationEnvelope {
}
/// An account gets one stable WebAuthn user handle, without storing a new schema
/// field. Migration leaves all existing credentials, their random registration
/// handles and key material unchanged. Credential-ID-scoped authentication must
/// accept signed assertions both without userHandle and with the original random
/// or newly registered stable userHandle; it is not discoverable-handle lookup.
/// The real-verifier compatibility tests exercise each representation explicitly.
/// field. Legacy identified-account login accepts original random handles or no
/// returned handle, always requiring the saved credential's signature. Normal
/// discoverable login additionally requires the saved owner's stable handle.
/// Random registration handles were never stored, so they cannot be recovered
/// 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 {
let digest = Sha256::digest(account_id.as_bytes());
Uuid::from_bytes(
@@ -29794,7 +29796,7 @@ fn start_passkey_registration(
let header_origin = request_origin(headers);
let auth = auth_config_for_origin(&api.config, browser_origin.or(header_origin.as_deref()))?;
let webauthn = webauthn_for_auth(&auth)?;
let (public_key, verifier) = webauthn
let (mut public_key, verifier) = webauthn
.start_passkey_registration(
passkey_user_handle(&user.account_id),
&user.handle,
@@ -29802,6 +29804,21 @@ fn start_passkey_registration(
Some(exclude_credentials),
)
.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");
// 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.
@@ -29966,6 +29983,18 @@ fn complete_passkey_enrollment(
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(
State(api): State<ServerAuthApi>,
headers: HeaderMap,
@@ -29974,48 +30003,66 @@ async fn post_passkey_login_options(
api.store
.db_executor()
.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 requested_origin = request
.browser_origin
.as_deref()
.or(header_origin.as_deref());
let auth = auth_config_for_origin(&api.config, requested_origin)?;
let auth = auth_config_for_origin(
&api.config,
request
.browser_origin
.as_deref()
.or(header_origin.as_deref()),
)?;
let webauthn = webauthn_for_auth(&auth)?;
let (public_key, state) = webauthn
.start_passkey_authentication(&passkeys)
.map_err(|error| auth_error("webauthn_login_options_failed", &error.to_string()))?;
let (public_key, state, user_id) =
if let Some(handle) = request.handle.as_deref() {
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");
api.store.put_auth_challenge(&AuthChallengeRecord {
challenge_id: challenge_id.clone(),
ceremony: "passkey_login".to_string(),
ceremony: "passkey_login".into(),
challenge: challenge_id.clone(),
user_id: Some(user.user_id),
user_id,
rp_id: auth.rp_id,
origin: auth.origin,
state_json: Some(serde_json::to_string(&state).map_err(|error| {
@@ -30057,64 +30104,114 @@ fn complete_passkey_login(
)
.into());
}
let user_id = challenge.user_id.clone().ok_or_else(|| {
auth_error(
"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(|| {
let state: PasskeyLoginState =
serde_json::from_str(challenge.state_json.as_deref().ok_or_else(|| {
auth_error(
"invalid_webauthn_credential_id",
"verified credential id was not serializable",
"missing_webauthn_state",
"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
.store
.get_passkey_credential(&credential_id)?
.ok_or_else(|| {
auth_error(
"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(
"passkey_user_mismatch",
"verified passkey credential does not belong to the challenged user",
"passkey_activation_required",
"initial passkey activation is required",
)
.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
.upsert_passkey_credential(&PasskeyCredentialRecord {
credential_id,
user_id: user.user_id.clone(),
public_key_cose: stored.public_key_cose,
public_key_cose,
transports_json: stored.transports_json,
sign_count: u64::from(auth_result.counter()),
created_at: stored.created_at,
last_used_at: Some(crate::auth::now_rfc3339()),
})?;
issue_browser_session(&api, user)
issue_browser_session(api, user)
}
fn issue_browser_session(
@@ -51692,16 +51789,20 @@ mod tests {
}
/// 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.
mod passkey_security {
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_discoverable_tests.rs");
const ORIGIN_URL: &str = "https://workspace.test";
type Authenticator = WebauthnAuthenticator<SoftPasskey>;
type Authenticator = ResidentAuthenticator;
struct Fixture {
_root: tempfile::TempDir,
@@ -52029,7 +52130,7 @@ mod tests {
}
fn authenticator() -> Authenticator {
WebauthnAuthenticator::new(SoftPasskey::new(true))
ResidentAuthenticator::new(true)
}
fn rejected(result: (StatusCode, HeaderMap, Value), status: StatusCode, code: &str) {
@@ -52220,17 +52321,17 @@ mod tests {
.unwrap()
.is_none()
);
rejected(
f.post(
let (status, _, options) = f
.post(
"/api/auth/passkeys/login/options",
None,
Some(ORIGIN_URL),
serde_json::json!({}),
)
.await,
StatusCode::BAD_REQUEST,
"passkey_handle_required",
);
.await;
assert_eq!(status, StatusCode::OK, "{options}");
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);
}
@@ -53075,113 +53176,152 @@ mod tests {
#[tokio::test]
async fn legacy_random_user_handle_credential_authenticates_before_and_after_stable_handle_addition()
{
let f = Fixture::new().await;
let api = ServerAuthApi::from(&f.api);
let user = allocate_new_user(&api, "legacy", None).unwrap();
let random_handle = Uuid::now_v7();
assert_ne!(random_handle, passkey_user_handle(&user.account_id));
let webauthn = webauthn_for_auth(&auth_public_config(&api.config)).unwrap();
let (options, state) = webauthn
.start_passkey_registration(random_handle, &user.handle, &user.display_name, None)
.unwrap();
let legacy_user_handle = options.public_key.user.id.clone();
assert_eq!(legacy_user_handle.as_slice(), random_handle.as_bytes());
let mut legacy_authenticator = authenticator();
let credential = legacy_authenticator
.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,
for resident in [false, true] {
let f = Fixture::new().await;
let api = ServerAuthApi::from(&f.api);
let user = allocate_new_user(&api, "legacy", None).unwrap();
let random_handle = Uuid::now_v7();
assert_ne!(random_handle, passkey_user_handle(&user.account_id));
let webauthn = webauthn_for_auth(&auth_public_config(&api.config)).unwrap();
let (mut options, state) = webauthn
.start_passkey_registration(
random_handle,
&user.handle,
&user.display_name,
None,
)
.await;
assert_eq!(logged_in, expected);
}
let (cookie, _) = f
.login_session_with_user_handle(
"legacy",
&mut legacy_authenticator,
Some(legacy_user_handle.as_slice()),
)
.await;
let before_add = f.credentials(&expected);
let mut added = authenticator();
let options = f.options("addition", Some(&cookie), "").await;
let stable_user_handle = options.public_key.public_key.user.id.clone();
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;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(
serde_json::from_value::<AuthUserResponse>(body)
.unwrap()
.user,
expected
);
assert_eq!(
.unwrap();
if resident {
let selection = options.public_key.authenticator_selection.as_mut().unwrap();
selection.require_resident_key = true;
selection.resident_key =
Some(webauthn_rs_proto::ResidentKeyRequirement::Required);
}
let legacy_user_handle = options.public_key.user.id.clone();
assert_eq!(legacy_user_handle.as_slice(), random_handle.as_bytes());
let mut legacy_authenticator = authenticator();
let credential = legacy_authenticator
.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
.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
.register_verified_passkey(
&PasskeyRegistrationTarget::NewAccount { user: user.clone() },
&record,
)
.unwrap();
let expected = user_response(user.clone());
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(
"legacy",
&mut legacy_authenticator,
returned_handle,
Some(legacy_user_handle.as_slice()),
)
.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)
let before_add = f.credentials(&expected);
let mut added = authenticator();
let options = f.options("addition", Some(&cookie), "").await;
let stable_user_handle = options.public_key.public_key.user.id.clone();
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;
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]
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 mut api = test_api(workspace.path()).await;
@@ -53198,7 +53338,7 @@ mod tests {
let origin = origin.clone();
let cookie_name = cookie_name.clone();
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 registration_options_response = app
@@ -1,5 +1,5 @@
// 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.
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()
);
}
+2 -2
View File
@@ -6661,7 +6661,7 @@
},
"PasskeyLoginOptionsRequest": {
"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": {
"browser_origin": {
"type": [
@@ -17831,7 +17831,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8"
"x-yoi-source-digest": "sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d"
},
"openapi": "3.1.0",
"paths": {
+1
View File
@@ -20,6 +20,7 @@ tempfile.workspace = true
[dev-dependencies]
server-api.workspace = true
webauthn-rs-proto = "0.5.5"
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
[[test]]
+99
View File
@@ -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)
}
}
+47 -46
View File
@@ -1,6 +1,6 @@
//! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、
//! 明示handle login、本人の鍵追加、限定activation、Workspace所有・catalog選択境界を検証する。DB・認証・routerは本番実装を使い、
//! authenticatorだけをSoftPasskeyに置換する(browser UI/OS authenticatorは対象外)。
//! ID不要login、本人の鍵追加、限定activation、Workspace所有・catalog選択境界を検証する。DB・認証・routerは本番実装を使い、
//! authenticatorはresident store/選択を持つsoftware model、署名エンジンのみSoftPasskeyとする(browser UI/OS認証器は対象外)。
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。
//! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。
//! HTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。
@@ -24,7 +24,9 @@ use server_api::{
PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest,
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;
type Result<T = ()> = std::result::Result<T, Box<dyn Error>>;
@@ -99,7 +101,7 @@ fn register(
base: &str,
artifacts: &Path,
handle: &str,
authenticator: &mut WebauthnAuthenticator<SoftPasskey>,
authenticator: &mut ResidentAuthenticator,
) -> Result<(AuthUserResponse, String, Value)> {
let options: PasskeyRegistrationOptionsResponse = decode(send(
client.post(format!("{base}/api/auth/passkeys/registration/options"))
@@ -130,19 +132,19 @@ fn login(
base: &str,
artifacts: &Path,
handle: &str,
authenticator: &mut WebauthnAuthenticator<SoftPasskey>,
authenticator: &mut ResidentAuthenticator,
) -> Result<(AuthUserResponse, String)> {
let options: PasskeyLoginOptionsResponse = decode(send(
client
.post(format!("{base}/api/auth/passkeys/login/options"))
.header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"handle": handle, "browser_origin": BROWSER_ORIGIN})),
"explicit handle login options",
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
"discoverable login options",
artifacts,
)?)?;
let credential =
authenticator.do_authentication(BROWSER_ORIGIN.parse()?, options.public_key)?;
session(send(
let result = session(send(
client
.post(format!("{base}/api/auth/passkeys/login/complete"))
.header(ORIGIN, BROWSER_ORIGIN)
@@ -152,7 +154,9 @@ fn login(
}),
"verified login complete",
artifacts,
)?)
)?)?;
assert_eq!(result.0.user.handle, handle);
Ok(result)
}
fn http_client() -> Result<Client> {
@@ -265,7 +269,7 @@ fn logout(client: &Client, base: &str, artifacts: &Path, cookie: &str) -> Result
fn host_admin_login(
environment: &WorkerE2eEnvironment,
client: &Client,
key: &mut WebauthnAuthenticator<SoftPasskey>,
key: &mut ResidentAuthenticator,
) -> Result<(AuthUserResponse, String)> {
let base = environment
.server_url
@@ -338,7 +342,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
let artifacts = &environment.artifacts_dir;
let client = http_client()?;
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)?;
policy(&client, base, artifacts, false)?;
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",
)?;
let mut original_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
let mut original_key = ResidentAuthenticator::new(true);
let (registered, registration_cookie, user_handle) = register(
&client,
base,
@@ -418,7 +422,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
)?, StatusCode::BAD_REQUEST, "auth_handle_exists")?;
// 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, _, other_user_handle) =
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)?;
set_policy(&client, base, artifacts, &admin_cookie, false)?;
// 以下の本人鍵追加と両方の鍵によるloginは、自己登録停止中にも必要。
rejected(
send(
client
.post(format!("{base}/api/auth/passkeys/login/options"))
.header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
"omitted handle cannot select first account",
artifacts,
)?,
StatusCode::BAD_REQUEST,
"passkey_handle_required",
)?;
let options: PasskeyLoginOptionsResponse = decode(send(
client
.post(format!("{base}/api/auth/passkeys/login/options"))
.header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
"normal login has no account inventory",
artifacts,
)?)?;
assert!(options.public_key.public_key.allow_credentials.is_empty());
for (field, value) in [
("handle", &other.user.handle),
@@ -494,7 +495,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
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 complete = PasskeyRegistrationCompleteRequest {
challenge_id: options.challenge_id,
@@ -725,10 +726,10 @@ fn workspace_catalog_zero_one_multiple_preserves_explicit_selection_and_actor_ow
StatusCode::FORBIDDEN,
"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)?;
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(
&client,
base,
@@ -881,7 +882,7 @@ fn ordinary_device_login_requires_explicit_approval_and_does_not_grant_hosting_a
base: &str,
artifacts: &Path,
user: &AuthUserResponse,
key: &mut WebauthnAuthenticator<SoftPasskey>,
key: &mut ResidentAuthenticator,
) -> Result {
let flow: Value = decode(send(
client
@@ -1007,7 +1008,7 @@ fn hosting_policy_rejects_spoofing_inflight_registration_and_survives_restart()
let artifacts = environment.artifacts_dir.clone();
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)?;
policy(&client, &base, &artifacts, false)?;
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)?;
// 拒否されたhandleも予約されておらず、許可後には通常の新規登録ができる。
let mut ordinary_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
let mut ordinary_key = ResidentAuthenticator::new(true);
let (ordinary, ordinary_cookie, _) = register(
&client,
&base,
@@ -1172,7 +1173,7 @@ fn hosting_policy_rejects_spoofing_inflight_registration_and_survives_restart()
"registration starts while enabled",
&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 complete = PasskeyRegistrationCompleteRequest {
challenge_id: options.challenge_id,
@@ -1396,7 +1397,7 @@ fn activation_options(
fn activation_credential(
options: PasskeyRegistrationOptionsResponse,
key: &mut WebauthnAuthenticator<SoftPasskey>,
key: &mut ResidentAuthenticator,
) -> Result<PasskeyRegistrationCompleteRequest> {
Ok(PasskeyRegistrationCompleteRequest {
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 artifacts = environment.artifacts_dir.clone();
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)?;
policy(&client, &base, &artifacts, false)?;
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")?;
policy(&client, &base, &artifacts, false)?;
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 (activated, cookie) = session(activation_success(send(
activation_complete_request(&client, &base, &complete),
@@ -1558,7 +1559,7 @@ fn activation_issuance_requires_live_admin_and_cookie_origin_not_identity_claims
.server_url
.as_deref()
.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 ordinary = create_pending(
&client,
@@ -1567,7 +1568,7 @@ fn activation_issuance_requires_live_admin_and_cookie_origin_not_identity_claims
&admin_cookie,
&environment.account_handle,
)?;
let mut ordinary_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
let mut ordinary_key = ResidentAuthenticator::new(true);
let complete = activation_credential(
activation_options(&client, base, artifacts, &ordinary)?,
&mut ordinary_key,
@@ -1721,7 +1722,7 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
.as_deref()
.ok_or("missing server URL")?;
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 issue = create_pending(
&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 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)?;
// 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を消費していない。
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(
activation_options(&client, base, artifacts, &replacement)?,
&mut replacement_key,
@@ -1884,7 +1885,7 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
"start distinct public registration ceremony",
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)?;
activation_denied(send(
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",
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)?;
activation_denied(send(
activation_complete_request(&client, base, &addition_complete),
@@ -1932,7 +1933,7 @@ fn activation_bad_attestation_stays_pending_and_concurrent_ceremonies_store_only
.server_url
.as_deref()
.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 issue = create_pending(
&client,
@@ -1941,7 +1942,7 @@ fn activation_bad_attestation_stays_pending_and_concurrent_ceremonies_store_only
&admin_cookie,
&environment.account_handle,
)?;
let mut bad_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
let mut bad_key = ResidentAuthenticator::new(true);
let bad_complete = activation_credential(
activation_options(&client, base, artifacts, &issue)?,
&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",
artifacts,
)?)?;
let mut first_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
let mut first_key = ResidentAuthenticator::new(true);
let first = activation_credential(
activation_options(&client, base, artifacts, &issue)?,
&mut first_key,
)?;
let mut second_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
let mut second_key = ResidentAuthenticator::new(true);
let second = activation_credential(
activation_options(&client, base, artifacts, &issue)?,
&mut second_key,
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// 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
// Output: web/workspace/src/lib/generated/companion-api.ts
// 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.
// 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
// Output: web/workspace/src/lib/generated/repository-api.ts
// 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.
// 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
// Output: web/workspace/src/lib/generated/runtime-api.ts
// 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.
// 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
// Output: web/workspace/src/lib/generated/ticket-api.ts
// 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.
// 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
// 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