feat: verify discoverable passkeys against their saved owners
This commit is contained in:
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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で、管理者初期設定、登録可否の永続化、
|
||||
//! 明示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
|
||||
|
||||
Reference in New Issue
Block a user