Merge remote-tracking branch 'origin/develop' into work/T-734-journal-recording
This commit is contained in:
Generated
+2
@@ -6709,8 +6709,10 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"server-api",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"webauthn-authenticator-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ impl_openapi_schema!(
|
|||||||
PasskeyLoginOptionsRequest,
|
PasskeyLoginOptionsRequest,
|
||||||
PasskeyLoginOptionsResponse,
|
PasskeyLoginOptionsResponse,
|
||||||
PasskeyLoginCompleteRequest,
|
PasskeyLoginCompleteRequest,
|
||||||
|
PasskeyAdditionOptionsRequest,
|
||||||
PasskeyRegistrationOptionsRequest,
|
PasskeyRegistrationOptionsRequest,
|
||||||
PasskeyRegistrationOptionsResponse,
|
PasskeyRegistrationOptionsResponse,
|
||||||
PasskeyRegistrationCompleteRequest,
|
PasskeyRegistrationCompleteRequest,
|
||||||
@@ -546,6 +547,36 @@ pub trait ServerApi {
|
|||||||
#[body] request: PasskeyRegistrationCompleteRequest,
|
#[body] request: PasskeyRegistrationCompleteRequest,
|
||||||
) -> Result<server_api_responses::AuthPasskeyRegistrationComplete, RepositoryApiError>;
|
) -> Result<server_api_responses::AuthPasskeyRegistrationComplete, RepositoryApiError>;
|
||||||
|
|
||||||
|
#[post(
|
||||||
|
"/api/auth/passkeys/addition/options",
|
||||||
|
status = 200,
|
||||||
|
error_status = 400,
|
||||||
|
additional_error_statuses = [403, 500, 502],
|
||||||
|
bearer_auth = true,
|
||||||
|
browser_auth = true
|
||||||
|
)]
|
||||||
|
async fn auth_passkey_addition_options(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[body] request: PasskeyAdditionOptionsRequest,
|
||||||
|
) -> Result<PasskeyRegistrationOptionsResponse, RepositoryApiError>;
|
||||||
|
|
||||||
|
#[post(
|
||||||
|
"/api/auth/passkeys/addition/complete",
|
||||||
|
responses = [
|
||||||
|
(status = 200, body = AuthUserResponse, headers = [("set-cookie", SetCookieHeader)])
|
||||||
|
],
|
||||||
|
error_status = 400,
|
||||||
|
additional_error_statuses = [403, 500],
|
||||||
|
bearer_auth = true,
|
||||||
|
browser_auth = true
|
||||||
|
)]
|
||||||
|
async fn auth_passkey_addition_complete(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[body] request: PasskeyRegistrationCompleteRequest,
|
||||||
|
) -> Result<server_api_responses::AuthPasskeyAdditionComplete, RepositoryApiError>;
|
||||||
|
|
||||||
#[post(
|
#[post(
|
||||||
"/api/auth/passkeys/login/options",
|
"/api/auth/passkeys/login/options",
|
||||||
status = 200,
|
status = 200,
|
||||||
@@ -3872,6 +3903,7 @@ pub struct AuthUserResponse {
|
|||||||
pub user: AuthenticatedUser,
|
pub user: AuthenticatedUser,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starts new-account registration only; an existing handle is never interpreted as key addition.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -3885,6 +3917,16 @@ pub struct PasskeyRegistrationOptionsRequest {
|
|||||||
pub browser_origin: Option<String>,
|
pub browser_origin: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds a credential to the current authenticated actor; target identity is never public input.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PasskeyAdditionOptionsRequest {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||||
|
pub browser_origin: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -3905,6 +3947,10 @@ pub struct PasskeyRegistrationCompleteRequest {
|
|||||||
pub credential: RegisterPublicKeyCredential,
|
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.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -11344,6 +11390,7 @@ pub fn auth_api_typescript() -> String {
|
|||||||
WhoamiResponse::decl(&config),
|
WhoamiResponse::decl(&config),
|
||||||
AuthBootstrapUserRequest::decl(&config),
|
AuthBootstrapUserRequest::decl(&config),
|
||||||
AuthUserResponse::decl(&config),
|
AuthUserResponse::decl(&config),
|
||||||
|
PasskeyAdditionOptionsRequest::decl(&config),
|
||||||
PasskeyRegistrationOptionsRequest::decl(&config),
|
PasskeyRegistrationOptionsRequest::decl(&config),
|
||||||
PasskeyRegistrationOptionsResponse::decl(&config),
|
PasskeyRegistrationOptionsResponse::decl(&config),
|
||||||
PasskeyRegistrationCompleteRequest::decl(&config),
|
PasskeyRegistrationCompleteRequest::decl(&config),
|
||||||
@@ -12061,6 +12108,16 @@ mod tests {
|
|||||||
HttpMethod::Post,
|
HttpMethod::Post,
|
||||||
"/api/auth/passkeys/registration/options",
|
"/api/auth/passkeys/registration/options",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"auth_passkey_addition_options",
|
||||||
|
HttpMethod::Post,
|
||||||
|
"/api/auth/passkeys/addition/options",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"auth_passkey_addition_complete",
|
||||||
|
HttpMethod::Post,
|
||||||
|
"/api/auth/passkeys/addition/complete",
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"auth_passkey_login_options",
|
"auth_passkey_login_options",
|
||||||
HttpMethod::Post,
|
HttpMethod::Post,
|
||||||
@@ -14452,6 +14509,20 @@ mod openapi_artifact_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passkey_addition_rejects_caller_selected_target_fields() {
|
||||||
|
for field in ["handle", "user_id", "account_id"] {
|
||||||
|
let payload = serde_json::json!({ (field): "other-user" });
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<PasskeyAdditionOptionsRequest>(payload).is_err(),
|
||||||
|
"addition must reject caller-selected {field}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<PasskeyAdditionOptionsRequest>(serde_json::json!({})).is_ok()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn auth_workspace_catalog_and_typed_header_contract_is_strict() {
|
fn auth_workspace_catalog_and_typed_header_contract_is_strict() {
|
||||||
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
|
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
|
||||||
@@ -14465,6 +14536,8 @@ mod openapi_artifact_tests {
|
|||||||
("/api/auth/bootstrap-user", "post"),
|
("/api/auth/bootstrap-user", "post"),
|
||||||
("/api/auth/passkeys/registration/options", "post"),
|
("/api/auth/passkeys/registration/options", "post"),
|
||||||
("/api/auth/passkeys/registration/complete", "post"),
|
("/api/auth/passkeys/registration/complete", "post"),
|
||||||
|
("/api/auth/passkeys/addition/options", "post"),
|
||||||
|
("/api/auth/passkeys/addition/complete", "post"),
|
||||||
("/api/auth/passkeys/login/options", "post"),
|
("/api/auth/passkeys/login/options", "post"),
|
||||||
("/api/auth/passkeys/login/complete", "post"),
|
("/api/auth/passkeys/login/complete", "post"),
|
||||||
("/api/auth/logout", "post"),
|
("/api/auth/logout", "post"),
|
||||||
@@ -14503,6 +14576,7 @@ mod openapi_artifact_tests {
|
|||||||
}
|
}
|
||||||
for path in [
|
for path in [
|
||||||
"/api/auth/passkeys/registration/complete",
|
"/api/auth/passkeys/registration/complete",
|
||||||
|
"/api/auth/passkeys/addition/complete",
|
||||||
"/api/auth/passkeys/login/complete",
|
"/api/auth/passkeys/login/complete",
|
||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
] {
|
] {
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ pub async fn resolve_request_actor(
|
|||||||
|
|
||||||
// One owned synchronous stage includes token usage marking and the user read.
|
// One owned synchronous stage includes token usage marking and the user read.
|
||||||
// Do not call async store wrappers here: they use the same executor themselves.
|
// Do not call async store wrappers here: they use the same executor themselves.
|
||||||
fn resolve_request_actor_sync(
|
pub(crate) fn resolve_request_actor_sync(
|
||||||
store: &dyn ControlPlaneStore,
|
store: &dyn ControlPlaneStore,
|
||||||
bearer: Option<String>,
|
bearer: Option<String>,
|
||||||
session: Option<String>,
|
session: Option<String>,
|
||||||
|
|||||||
+1792
-106
File diff suppressed because it is too large
Load Diff
@@ -661,6 +661,30 @@ pub struct UserRecord {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enrollment authority persisted with the WebAuthn verifier, never supplied by a client.
|
||||||
|
/// New-account identities are only allocations until verified completion commits them.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(tag = "purpose", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum PasskeyRegistrationTarget {
|
||||||
|
NewAccount {
|
||||||
|
user: UserRecord,
|
||||||
|
},
|
||||||
|
Addition {
|
||||||
|
user: UserRecord,
|
||||||
|
authentication: PasskeyAuthenticationBinding,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash of the exact transport credential that authorized an addition. A second
|
||||||
|
/// session for the same user cannot complete its ceremony; revocation is checked
|
||||||
|
/// again in the credential insertion transaction.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(tag = "method", content = "token_hash", rename_all = "snake_case")]
|
||||||
|
pub enum PasskeyAuthenticationBinding {
|
||||||
|
BrowserSession(String),
|
||||||
|
ApiToken(String),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct PasskeyCredentialRecord {
|
pub struct PasskeyCredentialRecord {
|
||||||
pub credential_id: String,
|
pub credential_id: String,
|
||||||
@@ -1552,6 +1576,13 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
|||||||
fn get_user(&self, user_id: &str) -> Result<Option<UserRecord>>;
|
fn get_user(&self, user_id: &str) -> Result<Option<UserRecord>>;
|
||||||
fn get_user_by_handle(&self, handle: &str) -> Result<Option<UserRecord>>;
|
fn get_user_by_handle(&self, handle: &str) -> Result<Option<UserRecord>>;
|
||||||
fn any_user(&self) -> Result<Option<UserRecord>>;
|
fn any_user(&self) -> Result<Option<UserRecord>>;
|
||||||
|
/// Atomically creates a new account/user/credential, or inserts a credential
|
||||||
|
/// for a still-authenticated existing target. Never overwrites a credential.
|
||||||
|
fn register_verified_passkey(
|
||||||
|
&self,
|
||||||
|
target: &PasskeyRegistrationTarget,
|
||||||
|
credential: &PasskeyCredentialRecord,
|
||||||
|
) -> Result<()>;
|
||||||
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>;
|
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>;
|
||||||
fn get_passkey_credential(
|
fn get_passkey_credential(
|
||||||
&self,
|
&self,
|
||||||
@@ -6346,6 +6377,66 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn register_verified_passkey(
|
||||||
|
&self,
|
||||||
|
target: &PasskeyRegistrationTarget,
|
||||||
|
credential: &PasskeyCredentialRecord,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.with_immediate_transaction(|tx| {
|
||||||
|
let user = match target {
|
||||||
|
PasskeyRegistrationTarget::NewAccount { user } => {
|
||||||
|
let occupied: bool = tx.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM accounts WHERE kind = 'user' AND handle = ?1) OR EXISTS(SELECT 1 FROM users WHERE handle = ?1)",
|
||||||
|
params![user.handle], |row| row.get(0),
|
||||||
|
)?;
|
||||||
|
if occupied {
|
||||||
|
return Err(crate::auth::auth_error("auth_handle_exists", "handle is already registered"));
|
||||||
|
}
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO accounts (account_id, kind, handle, display_name, created_at, updated_at) VALUES (?1, 'user', ?2, ?3, ?4, ?5)",
|
||||||
|
params![user.account_id, user.handle, user.display_name, user.created_at, user.updated_at],
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO users (user_id, account_id, handle, display_name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
params![user.user_id, user.account_id, user.handle, user.display_name, user.created_at, user.updated_at],
|
||||||
|
)?;
|
||||||
|
user
|
||||||
|
}
|
||||||
|
PasskeyRegistrationTarget::Addition { user, authentication } => {
|
||||||
|
let current = tx.query_row(
|
||||||
|
user_select_sql("WHERE user_id = ?1 AND account_id = ?2").as_str(),
|
||||||
|
params![user.user_id, user.account_id], read_user_record,
|
||||||
|
).optional()?;
|
||||||
|
if current.is_none() {
|
||||||
|
return Err(crate::auth::auth_error("passkey_target_mismatch", "addition target no longer exists"));
|
||||||
|
}
|
||||||
|
let expires_at: Option<Option<String>> = match authentication {
|
||||||
|
PasskeyAuthenticationBinding::BrowserSession(hash) => tx.query_row(
|
||||||
|
"SELECT expires_at FROM browser_sessions WHERE token_hash = ?1 AND user_id = ?2 AND revoked_at IS NULL",
|
||||||
|
params![hash, user.user_id], |row| row.get(0),
|
||||||
|
).optional()?,
|
||||||
|
PasskeyAuthenticationBinding::ApiToken(hash) => tx.query_row(
|
||||||
|
"SELECT expires_at FROM api_tokens WHERE token_hash = ?1 AND user_id = ?2 AND revoked_at IS NULL",
|
||||||
|
params![hash, user.user_id], |row| row.get(0),
|
||||||
|
).optional()?,
|
||||||
|
};
|
||||||
|
if !expires_at.is_some_and(|expiry| expiry.as_deref().is_none_or(|value| !crate::auth::is_expired(value))) {
|
||||||
|
return Err(crate::auth::auth_error("auth_required", "addition credential has expired or been revoked"));
|
||||||
|
}
|
||||||
|
user
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if user.user_id != credential.user_id {
|
||||||
|
return Err(crate::auth::auth_error("passkey_target_mismatch", "credential does not match enrollment target"));
|
||||||
|
}
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||||
|
params![credential.credential_id, credential.user_id, credential.public_key_cose, credential.transports_json, credential.sign_count, credential.created_at, credential.last_used_at],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> {
|
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -6435,7 +6526,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
ceremony: &str,
|
ceremony: &str,
|
||||||
consumed_at: &str,
|
consumed_at: &str,
|
||||||
) -> Result<Option<AuthChallengeRecord>> {
|
) -> Result<Option<AuthChallengeRecord>> {
|
||||||
self.with_conn(|conn| {
|
self.with_immediate_transaction(|conn| {
|
||||||
let record = conn
|
let record = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
auth_challenge_select_sql(
|
auth_challenge_select_sql(
|
||||||
|
|||||||
+163
-1
@@ -6562,6 +6562,19 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"PasskeyAdditionOptionsRequest": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Adds a credential to the current authenticated actor; target identity is never public input.",
|
||||||
|
"properties": {
|
||||||
|
"browser_origin": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"PasskeyLoginCompleteRequest": {
|
"PasskeyLoginCompleteRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -6578,6 +6591,7 @@
|
|||||||
},
|
},
|
||||||
"PasskeyLoginOptionsRequest": {
|
"PasskeyLoginOptionsRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "Identified-account login. Missing handles are rejected, not resolved to an implicit user.\n\nThe nullable wire field permits an explicit authentication error for old callers; it does not\nadvertise a discoverable-login ceremony.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"browser_origin": {
|
"browser_origin": {
|
||||||
"type": [
|
"type": [
|
||||||
@@ -6624,6 +6638,7 @@
|
|||||||
},
|
},
|
||||||
"PasskeyRegistrationOptionsRequest": {
|
"PasskeyRegistrationOptionsRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "Starts new-account registration only; an existing handle is never interpreted as key addition.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"browser_origin": {
|
"browser_origin": {
|
||||||
"type": [
|
"type": [
|
||||||
@@ -17582,7 +17597,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c"
|
"x-yoi-source-digest": "sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
@@ -17915,6 +17930,153 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/auth/passkeys/addition/complete": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "auth_passkey_addition_complete",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PasskeyRegistrationCompleteRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AuthUserResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response",
|
||||||
|
"headers": {
|
||||||
|
"set-cookie": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SetCookieHeader"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/auth/passkeys/addition/options": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "auth_passkey_addition_options",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PasskeyAdditionOptionsRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PasskeyRegistrationOptionsResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Successful response"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/RepositoryApiError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Error response"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"browserSession": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/auth/passkeys/login/complete": {
|
"/api/auth/passkeys/login/complete": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "auth_passkey_login_complete",
|
"operationId": "auth_passkey_login_complete",
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ serde_json.workspace = true
|
|||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
server-api.workspace = true
|
||||||
|
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "auth_passkeys"
|
||||||
|
path = "tests/auth_passkeys.rs"
|
||||||
|
required-features = ["e2e"]
|
||||||
|
|
||||||
[[test]]
|
[[test]]
|
||||||
name = "panel"
|
name = "panel"
|
||||||
path = "tests/panel.rs"
|
path = "tests/panel.rs"
|
||||||
|
|||||||
@@ -305,7 +305,8 @@ impl WorkerE2eEnvironment {
|
|||||||
self.start_runtime()
|
self.start_runtime()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_backend(&mut self) -> Result<()> {
|
/// 空のDBでBackendだけを起動し、Account/Workspaceは作成しない。
|
||||||
|
pub fn start_empty_backend(&mut self) -> Result<()> {
|
||||||
assert_absent_or_empty(&self.server_data_dir, "Server data directory")?;
|
assert_absent_or_empty(&self.server_data_dir, "Server data directory")?;
|
||||||
assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?;
|
assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?;
|
||||||
let server_binary = self.binaries.binary("yoi-server")?;
|
let server_binary = self.binaries.binary("yoi-server")?;
|
||||||
@@ -328,7 +329,14 @@ impl WorkerE2eEnvironment {
|
|||||||
self.server = Some(server);
|
self.server = Some(server);
|
||||||
self.server_url = Some(server_url.clone());
|
self.server_url = Some(server_url.clone());
|
||||||
self.wait_for_server_health(&server_url)?;
|
self.wait_for_server_health(&server_url)?;
|
||||||
|
self.write_metadata("empty-backend-running")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_backend(&mut self) -> Result<()> {
|
||||||
|
self.start_empty_backend()?;
|
||||||
|
let server_binary = self.binaries.binary("yoi-server")?;
|
||||||
|
let server_url = self.required_server_url()?.to_string();
|
||||||
let bootstrap = self.run_command(
|
let bootstrap = self.run_command(
|
||||||
&server_binary,
|
&server_binary,
|
||||||
&[
|
&[
|
||||||
@@ -439,7 +447,14 @@ impl WorkerE2eEnvironment {
|
|||||||
false,
|
false,
|
||||||
)?;
|
)?;
|
||||||
let enrollment: Value = serde_json::from_slice(&enrollment.stdout)?;
|
let enrollment: Value = serde_json::from_slice(&enrollment.stdout)?;
|
||||||
let workspace_trust_id = json_string(&enrollment, "/trust_id")?;
|
if json_string(&enrollment, "/trust_id")?.is_empty()
|
||||||
|
|| enrollment.get("workspace_id").and_then(Value::as_str) != Some(workspace_id.as_str())
|
||||||
|
|| enrollment.get("state").and_then(Value::as_str) != Some("active")
|
||||||
|
{
|
||||||
|
return Err(HarnessError::Protocol(
|
||||||
|
"Runtime did not retain active trust for the fixture Workspace".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let mut command = self.isolated_command(&runtime_binary);
|
let mut command = self.isolated_command(&runtime_binary);
|
||||||
command.args([
|
command.args([
|
||||||
@@ -468,12 +483,20 @@ impl WorkerE2eEnvironment {
|
|||||||
.post(format!("{server_url}/api/w/{workspace_id}/runtimes"))
|
.post(format!("{server_url}/api/w/{workspace_id}/runtimes"))
|
||||||
.json(&serde_json::json!({
|
.json(&serde_json::json!({
|
||||||
"public_bundle": runtime_identity,
|
"public_bundle": runtime_identity,
|
||||||
"workspace_trust_id": workspace_trust_id,
|
|
||||||
"display_name": format!("E2E {}", self.runtime_id),
|
"display_name": format!("E2E {}", self.runtime_id),
|
||||||
"endpoint": runtime_url
|
"endpoint": runtime_url
|
||||||
})),
|
})),
|
||||||
"register Runtime",
|
"register Runtime",
|
||||||
)?;
|
)?;
|
||||||
|
self.assert_connected_and_empty()?;
|
||||||
|
self.write_metadata("running")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn assert_connected_and_empty(&self) -> Result<()> {
|
||||||
|
let server_url = self.required_server_url()?;
|
||||||
|
let workspace_id = self.required_workspace_id()?;
|
||||||
|
// connection testは実署名要求。保存済みの接続flagを認証成功の代わりにしない。
|
||||||
let connection = self.request_json(
|
let connection = self.request_json(
|
||||||
self.client.post(format!(
|
self.client.post(format!(
|
||||||
"{server_url}/api/w/{workspace_id}/runtimes/{}/connection-tests",
|
"{server_url}/api/w/{workspace_id}/runtimes/{}/connection-tests",
|
||||||
@@ -486,14 +509,7 @@ impl WorkerE2eEnvironment {
|
|||||||
"Runtime connection was not compatible: {connection}"
|
"Runtime connection was not compatible: {connection}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
self.assert_connected_and_empty()?;
|
let binding_id = json_string(&connection, "/binding_id")?;
|
||||||
self.write_metadata("running")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn assert_connected_and_empty(&self) -> Result<()> {
|
|
||||||
let server_url = self.required_server_url()?;
|
|
||||||
let workspace_id = self.required_workspace_id()?;
|
|
||||||
let runtimes = self.request_json(
|
let runtimes = self.request_json(
|
||||||
self.client
|
self.client
|
||||||
.get(format!("{server_url}/api/w/{workspace_id}/runtimes")),
|
.get(format!("{server_url}/api/w/{workspace_id}/runtimes")),
|
||||||
@@ -506,9 +522,9 @@ impl WorkerE2eEnvironment {
|
|||||||
items.iter().any(|item| {
|
items.iter().any(|item| {
|
||||||
item.get("runtime_id").and_then(Value::as_str) == Some(self.runtime_id.as_str())
|
item.get("runtime_id").and_then(Value::as_str) == Some(self.runtime_id.as_str())
|
||||||
&& item
|
&& item
|
||||||
.pointer("/management/binding/connection_state")
|
.pointer("/management/binding/binding_id")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
== Some("verified")
|
== Some(binding_id.as_str())
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
if !connected {
|
if !connected {
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
//! 隔離した実yoi-serverのHTTP APIで、新規登録→明示handle login→本人の鍵追加→
|
||||||
|
//! owner-first Workspace作成を検証する。DB、認証、routerは本番実装を使い、
|
||||||
|
//! authenticatorだけをSoftPasskeyに置換する(browser UI/OS authenticatorは対象外)。
|
||||||
|
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。
|
||||||
|
//! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。
|
||||||
|
//! HTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。
|
||||||
|
|
||||||
|
use std::error::Error;
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use reqwest::blocking::{Client, RequestBuilder, Response};
|
||||||
|
use reqwest::header::{COOKIE, ORIGIN, SET_COOKIE};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use server_api::{
|
||||||
|
AuthUserResponse, PasskeyLoginCompleteRequest, PasskeyLoginOptionsResponse,
|
||||||
|
PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsResponse,
|
||||||
|
};
|
||||||
|
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
|
||||||
|
use yoi_e2e::WorkerE2eEnvironment;
|
||||||
|
|
||||||
|
type Result<T = ()> = std::result::Result<T, Box<dyn Error>>;
|
||||||
|
const BROWSER_ORIGIN: &str = "http://localhost:5173";
|
||||||
|
|
||||||
|
// 通信と診断だけを補助する。起動/DB/process管理は共有harnessに任せる。
|
||||||
|
fn send(request: RequestBuilder, operation: &str, artifacts: &Path) -> Result<Response> {
|
||||||
|
let response = request.send()?;
|
||||||
|
let mut log = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(artifacts.join("auth-operations.jsonl"))?;
|
||||||
|
writeln!(
|
||||||
|
log,
|
||||||
|
"{}",
|
||||||
|
json!({"operation": operation, "status": response.status().as_u16()})
|
||||||
|
)?;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn success(response: Response) -> Result<Response> {
|
||||||
|
let status = response.status();
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"unexpected {status}: {}",
|
||||||
|
response.text()?.chars().take(2048).collect::<String>()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode<T: DeserializeOwned>(response: Response) -> Result<T> {
|
||||||
|
Ok(success(response)?.json()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rejected(response: Response, expected: StatusCode, diagnostic: &str) -> Result {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text()?;
|
||||||
|
assert!(
|
||||||
|
status == expected,
|
||||||
|
"expected rejection {expected}, got {status}: {}",
|
||||||
|
body.chars().take(2048).collect::<String>()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains(diagnostic),
|
||||||
|
"missing diagnostic {diagnostic:?}: {}",
|
||||||
|
body.chars().take(2048).collect::<String>()
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session(response: Response) -> Result<(AuthUserResponse, String)> {
|
||||||
|
let response = success(response)?;
|
||||||
|
let header = response
|
||||||
|
.headers()
|
||||||
|
.get(SET_COOKIE)
|
||||||
|
.ok_or("missing session cookie")?
|
||||||
|
.to_str()?;
|
||||||
|
assert!(header.contains("HttpOnly"));
|
||||||
|
assert!(header.contains("SameSite=Lax"));
|
||||||
|
let cookie = header
|
||||||
|
.split(';')
|
||||||
|
.next()
|
||||||
|
.ok_or("missing cookie pair")?
|
||||||
|
.to_owned();
|
||||||
|
Ok((response.json()?, cookie))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register(
|
||||||
|
client: &Client,
|
||||||
|
base: &str,
|
||||||
|
artifacts: &Path,
|
||||||
|
handle: &str,
|
||||||
|
authenticator: &mut WebauthnAuthenticator<SoftPasskey>,
|
||||||
|
) -> Result<(AuthUserResponse, String, Value)> {
|
||||||
|
let options: PasskeyRegistrationOptionsResponse = decode(send(
|
||||||
|
client.post(format!("{base}/api/auth/passkeys/registration/options"))
|
||||||
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
|
.json(&json!({"handle": handle, "display_name": "Isolated passkey E2E", "browser_origin": BROWSER_ORIGIN})),
|
||||||
|
"new account registration options", artifacts,
|
||||||
|
)?)?;
|
||||||
|
let user_handle = serde_json::to_value(&options.public_key)?["publicKey"]["user"]["id"].clone();
|
||||||
|
assert!(user_handle.is_string(), "WebAuthn user handle is absent");
|
||||||
|
let credential = authenticator.do_registration(BROWSER_ORIGIN.parse()?, options.public_key)?;
|
||||||
|
let (user, cookie) = session(send(
|
||||||
|
client
|
||||||
|
.post(format!("{base}/api/auth/passkeys/registration/complete"))
|
||||||
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
|
.json(&PasskeyRegistrationCompleteRequest {
|
||||||
|
challenge_id: options.challenge_id,
|
||||||
|
credential,
|
||||||
|
}),
|
||||||
|
"new account registration complete",
|
||||||
|
artifacts,
|
||||||
|
)?)?;
|
||||||
|
assert_eq!(user.user.handle, handle);
|
||||||
|
Ok((user, cookie, user_handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login(
|
||||||
|
client: &Client,
|
||||||
|
base: &str,
|
||||||
|
artifacts: &Path,
|
||||||
|
handle: &str,
|
||||||
|
authenticator: &mut WebauthnAuthenticator<SoftPasskey>,
|
||||||
|
) -> 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",
|
||||||
|
artifacts,
|
||||||
|
)?)?;
|
||||||
|
let credential =
|
||||||
|
authenticator.do_authentication(BROWSER_ORIGIN.parse()?, options.public_key)?;
|
||||||
|
session(send(
|
||||||
|
client
|
||||||
|
.post(format!("{base}/api/auth/passkeys/login/complete"))
|
||||||
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
|
.json(&PasskeyLoginCompleteRequest {
|
||||||
|
challenge_id: options.challenge_id,
|
||||||
|
credential,
|
||||||
|
}),
|
||||||
|
"verified login complete",
|
||||||
|
artifacts,
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fresh_account_adds_own_passkey_and_creates_owned_workspace() -> Result {
|
||||||
|
let mut environment = WorkerE2eEnvironment::new()?;
|
||||||
|
eprintln!(
|
||||||
|
"passkey E2E artifacts: {}",
|
||||||
|
environment.artifacts_dir.display()
|
||||||
|
);
|
||||||
|
assert!(!environment.server_data_dir.exists());
|
||||||
|
environment.start_empty_backend()?;
|
||||||
|
assert!(environment.workspace_id.is_none());
|
||||||
|
assert!(environment.runtime_pid().is_none());
|
||||||
|
let base = environment
|
||||||
|
.server_url
|
||||||
|
.as_deref()
|
||||||
|
.ok_or("missing server URL")?;
|
||||||
|
let artifacts = &environment.artifacts_dir;
|
||||||
|
let client = Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.connect_timeout(Duration::from_secs(1))
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.build()?;
|
||||||
|
let whoami: Value = decode(send(
|
||||||
|
client.get(format!("{base}/api/auth/whoami")),
|
||||||
|
"fresh whoami",
|
||||||
|
artifacts,
|
||||||
|
)?)?;
|
||||||
|
assert!(whoami["actor"].is_null());
|
||||||
|
|
||||||
|
let workspace_request = json!({
|
||||||
|
"operation_key": format!("passkey-e2e-{}", environment.runtime_id),
|
||||||
|
"display_name": "Passkey owner-first E2E",
|
||||||
|
"repository": {"repository_key": "fixture", "uri": "https://example.invalid/passkey-e2e.git", "default_ref": "develop"}
|
||||||
|
});
|
||||||
|
rejected(
|
||||||
|
send(
|
||||||
|
client
|
||||||
|
.post(format!("{base}/api/workspaces"))
|
||||||
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
|
.json(&workspace_request),
|
||||||
|
"unauthenticated loopback cannot bootstrap Workspace",
|
||||||
|
artifacts,
|
||||||
|
)?,
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"authenticated owner",
|
||||||
|
)?;
|
||||||
|
rejected(
|
||||||
|
send(
|
||||||
|
client
|
||||||
|
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||||
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
|
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||||
|
"unauthenticated addition options",
|
||||||
|
artifacts,
|
||||||
|
)?,
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"auth_required",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut original_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
||||||
|
let (registered, _, user_handle) = register(
|
||||||
|
&client,
|
||||||
|
base,
|
||||||
|
artifacts,
|
||||||
|
&environment.account_handle,
|
||||||
|
&mut original_key,
|
||||||
|
)?;
|
||||||
|
// registrationのcookieは使わず、実assertionで得たlogin cookieを次の変更操作に使う。
|
||||||
|
let (logged_in, cookie) = login(
|
||||||
|
&client,
|
||||||
|
base,
|
||||||
|
artifacts,
|
||||||
|
&environment.account_handle,
|
||||||
|
&mut original_key,
|
||||||
|
)?;
|
||||||
|
assert_eq!(logged_in.user, registered.user);
|
||||||
|
let whoami: Value = decode(send(
|
||||||
|
client
|
||||||
|
.get(format!("{base}/api/auth/whoami"))
|
||||||
|
.header(COOKIE, &cookie),
|
||||||
|
"logged-in whoami",
|
||||||
|
artifacts,
|
||||||
|
)?)?;
|
||||||
|
assert_eq!(whoami["actor"]["account_id"], registered.user.account_id);
|
||||||
|
let workspaces: Value = decode(send(
|
||||||
|
client
|
||||||
|
.get(format!("{base}/api/workspaces"))
|
||||||
|
.header(COOKIE, &cookie),
|
||||||
|
"zero Workspaces after registration and login",
|
||||||
|
artifacts,
|
||||||
|
)?)?;
|
||||||
|
assert_eq!(
|
||||||
|
workspaces,
|
||||||
|
json!([]),
|
||||||
|
"startup/registration must not create a Workspace"
|
||||||
|
);
|
||||||
|
rejected(send(
|
||||||
|
client.post(format!("{base}/api/auth/passkeys/registration/options"))
|
||||||
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
|
.json(&json!({"handle": environment.account_handle, "display_name": "Attempted replacement", "browser_origin": BROWSER_ORIGIN})),
|
||||||
|
"existing handle cannot use unauthenticated registration", artifacts,
|
||||||
|
)?, StatusCode::BAD_REQUEST, "auth_handle_exists")?;
|
||||||
|
|
||||||
|
// 2 usersを実登録し、handle省略が最古userへfallbackしないことを検証する。
|
||||||
|
let mut other_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
||||||
|
let other_handle = format!("{}-other", environment.account_handle);
|
||||||
|
let (other, _, other_user_handle) =
|
||||||
|
register(&client, base, artifacts, &other_handle, &mut other_key)?;
|
||||||
|
assert_ne!(other.user.account_id, registered.user.account_id);
|
||||||
|
assert_ne!(other_user_handle, user_handle);
|
||||||
|
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",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
for (field, value) in [
|
||||||
|
("handle", &other.user.handle),
|
||||||
|
("user_id", &other.user.user_id),
|
||||||
|
("account_id", &other.user.account_id),
|
||||||
|
] {
|
||||||
|
let mut body = json!({"browser_origin": BROWSER_ORIGIN});
|
||||||
|
body[field] = json!(value);
|
||||||
|
rejected(
|
||||||
|
send(
|
||||||
|
client
|
||||||
|
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||||
|
.header(COOKIE, &cookie)
|
||||||
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
|
.json(&body),
|
||||||
|
&format!("reject addition target injection: {field}"),
|
||||||
|
artifacts,
|
||||||
|
)?,
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
"unknown field",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for origin in [None, Some("http://localhost:5174")] {
|
||||||
|
let mut request = client
|
||||||
|
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||||
|
.header(COOKIE, &cookie)
|
||||||
|
.json(&json!({"browser_origin": BROWSER_ORIGIN}));
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
request = request.header(ORIGIN, origin);
|
||||||
|
}
|
||||||
|
rejected(
|
||||||
|
send(
|
||||||
|
request,
|
||||||
|
"cookie addition requires actual matching Origin header",
|
||||||
|
artifacts,
|
||||||
|
)?,
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"origin",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let options: PasskeyRegistrationOptionsResponse = decode(send(
|
||||||
|
client
|
||||||
|
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||||
|
.header(COOKIE, &cookie)
|
||||||
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
|
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||||
|
"authenticated self addition options",
|
||||||
|
artifacts,
|
||||||
|
)?)?;
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&options.public_key)?["publicKey"]["user"]["id"],
|
||||||
|
user_handle,
|
||||||
|
"same account must retain its WebAuthn user handle"
|
||||||
|
);
|
||||||
|
let mut added_key = WebauthnAuthenticator::new(SoftPasskey::new(true));
|
||||||
|
let credential = added_key.do_registration(BROWSER_ORIGIN.parse()?, options.public_key)?;
|
||||||
|
let complete = PasskeyRegistrationCompleteRequest {
|
||||||
|
challenge_id: options.challenge_id,
|
||||||
|
credential,
|
||||||
|
};
|
||||||
|
rejected(
|
||||||
|
send(
|
||||||
|
client
|
||||||
|
.post(format!("{base}/api/auth/passkeys/addition/complete"))
|
||||||
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
|
.json(&complete),
|
||||||
|
"unauthenticated addition complete",
|
||||||
|
artifacts,
|
||||||
|
)?,
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"auth_required",
|
||||||
|
)?;
|
||||||
|
for origin in [None, Some("http://localhost:5174")] {
|
||||||
|
let mut request = client
|
||||||
|
.post(format!("{base}/api/auth/passkeys/addition/complete"))
|
||||||
|
.header(COOKIE, &cookie)
|
||||||
|
.json(&complete);
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
request = request.header(ORIGIN, origin);
|
||||||
|
}
|
||||||
|
rejected(
|
||||||
|
send(
|
||||||
|
request,
|
||||||
|
"cookie addition complete requires matching Origin",
|
||||||
|
artifacts,
|
||||||
|
)?,
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"origin",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for (field, value) in [
|
||||||
|
("handle", &other.user.handle),
|
||||||
|
("user_id", &other.user.user_id),
|
||||||
|
("account_id", &other.user.account_id),
|
||||||
|
] {
|
||||||
|
let mut body = serde_json::to_value(&complete)?;
|
||||||
|
body[field] = json!(value);
|
||||||
|
rejected(
|
||||||
|
send(
|
||||||
|
client
|
||||||
|
.post(format!("{base}/api/auth/passkeys/addition/complete"))
|
||||||
|
.header(COOKIE, &cookie)
|
||||||
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
|
.json(&body),
|
||||||
|
&format!("reject addition complete target injection: {field}"),
|
||||||
|
artifacts,
|
||||||
|
)?,
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
"unknown field",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let (added, _) = session(send(
|
||||||
|
client
|
||||||
|
.post(format!("{base}/api/auth/passkeys/addition/complete"))
|
||||||
|
.header(COOKIE, &cookie)
|
||||||
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
|
.json(&complete),
|
||||||
|
"authenticated self addition complete",
|
||||||
|
artifacts,
|
||||||
|
)?)?;
|
||||||
|
assert_eq!(added.user, registered.user);
|
||||||
|
rejected(
|
||||||
|
send(
|
||||||
|
client
|
||||||
|
.post(format!("{base}/api/auth/passkeys/addition/complete"))
|
||||||
|
.header(COOKIE, &cookie)
|
||||||
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
|
.json(&complete),
|
||||||
|
"addition challenge cannot be replayed",
|
||||||
|
artifacts,
|
||||||
|
)?,
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_passkey_challenge",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let (new_login, new_cookie) = login(
|
||||||
|
&client,
|
||||||
|
base,
|
||||||
|
artifacts,
|
||||||
|
&environment.account_handle,
|
||||||
|
&mut added_key,
|
||||||
|
)?;
|
||||||
|
assert_eq!(new_login.user, registered.user);
|
||||||
|
let (old_login, _) = login(
|
||||||
|
&client,
|
||||||
|
base,
|
||||||
|
artifacts,
|
||||||
|
&environment.account_handle,
|
||||||
|
&mut original_key,
|
||||||
|
)?;
|
||||||
|
assert_eq!(
|
||||||
|
old_login.user, registered.user,
|
||||||
|
"adding a key must preserve the original key"
|
||||||
|
);
|
||||||
|
let created: Value = decode(send(
|
||||||
|
client
|
||||||
|
.post(format!("{base}/api/workspaces"))
|
||||||
|
.header(COOKIE, &new_cookie)
|
||||||
|
.header(ORIGIN, BROWSER_ORIGIN)
|
||||||
|
.json(&workspace_request),
|
||||||
|
"first Workspace from verified new-key login",
|
||||||
|
artifacts,
|
||||||
|
)?)?;
|
||||||
|
assert_eq!(
|
||||||
|
created["workspace"]["owner_account_id"],
|
||||||
|
registered.user.account_id
|
||||||
|
);
|
||||||
|
let workspace_id = created["workspace"]["workspace_id"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or("missing Workspace ID")?;
|
||||||
|
let listed: Value = decode(send(
|
||||||
|
client
|
||||||
|
.get(format!("{base}/api/workspaces"))
|
||||||
|
.header(COOKIE, &new_cookie),
|
||||||
|
"reread owned Workspace",
|
||||||
|
artifacts,
|
||||||
|
)?)?;
|
||||||
|
let items = listed
|
||||||
|
.as_array()
|
||||||
|
.ok_or("Workspace catalog is not an array")?;
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0]["workspace_id"], workspace_id);
|
||||||
|
assert_eq!(items[0]["owner_account_id"], registered.user.account_id);
|
||||||
|
|
||||||
|
let report = environment.cleanup()?;
|
||||||
|
assert!(report.cleanup_success);
|
||||||
|
assert!(!report.fixture_root_exists_after);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ export type AuthBootstrapUserRequest = { handle: string, display_name?: string |
|
|||||||
|
|
||||||
export type AuthUserResponse = { user: AuthenticatedUser, };
|
export type AuthUserResponse = { user: AuthenticatedUser, };
|
||||||
|
|
||||||
|
export type PasskeyAdditionOptionsRequest = { browser_origin?: string | null, };
|
||||||
|
|
||||||
export type PasskeyRegistrationOptionsRequest = { handle: string, display_name?: string | null, browser_origin?: string | null, };
|
export type PasskeyRegistrationOptionsRequest = { handle: string, display_name?: string | null, browser_origin?: string | null, };
|
||||||
|
|
||||||
export type PasskeyRegistrationOptionsResponse = { challenge_id: string, public_key: unknown, };
|
export type PasskeyRegistrationOptionsResponse = { challenge_id: string, public_key: unknown, };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
|
// Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
|
// Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
|
// Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
|
// Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb)
|
// Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f)
|
||||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
|
// Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// HTTP caller試験は本番adapterとWebAuthn変換を接続し、fetchとbrowser credential境界だけを代替する。
|
||||||
|
// 認証・署名の検証はBackend suiteが所有し、このfixtureでは保証しない。
|
||||||
|
export const authUser = {
|
||||||
|
user_id: "user-alice",
|
||||||
|
account_id: "account-alice",
|
||||||
|
handle: "alice",
|
||||||
|
display_name: "Alice",
|
||||||
|
};
|
||||||
|
export const registrationOptions = {
|
||||||
|
challenge_id: "create-challenge",
|
||||||
|
public_key: {
|
||||||
|
publicKey: {
|
||||||
|
challenge: "AQID",
|
||||||
|
rp: { id: "localhost", name: "Yoi" },
|
||||||
|
user: { id: "BAUG", name: "alice", displayName: "Alice" },
|
||||||
|
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export const loginOptions = {
|
||||||
|
challenge_id: "login-challenge",
|
||||||
|
public_key: {
|
||||||
|
publicKey: {
|
||||||
|
challenge: "AQID",
|
||||||
|
allowCredentials: [{ type: "public-key", id: "BwgJ" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export function credentialFixture(authentication = false): Credential {
|
||||||
|
const bytes = new Uint8Array([1, 2, 3]).buffer;
|
||||||
|
return {
|
||||||
|
id: "AQID",
|
||||||
|
rawId: bytes,
|
||||||
|
type: "public-key",
|
||||||
|
authenticatorAttachment: "platform",
|
||||||
|
getClientExtensionResults: () => ({}),
|
||||||
|
response: authentication
|
||||||
|
? {
|
||||||
|
clientDataJSON: bytes,
|
||||||
|
authenticatorData: bytes,
|
||||||
|
signature: bytes,
|
||||||
|
userHandle: null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
clientDataJSON: bytes,
|
||||||
|
attestationObject: bytes,
|
||||||
|
getTransports: () => ["internal"],
|
||||||
|
},
|
||||||
|
} as unknown as Credential;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
DeviceLoginApproveRequest,
|
DeviceLoginApproveRequest,
|
||||||
|
PasskeyAdditionOptionsRequest,
|
||||||
PasskeyLoginCompleteRequest,
|
PasskeyLoginCompleteRequest,
|
||||||
PasskeyLoginOptionsRequest,
|
PasskeyLoginOptionsRequest,
|
||||||
PasskeyRegistrationCompleteRequest,
|
PasskeyRegistrationCompleteRequest,
|
||||||
@@ -114,7 +115,23 @@ export async function registerPasskey(
|
|||||||
display_name: displayName ?? null,
|
display_name: displayName ?? null,
|
||||||
browser_origin: window.location.origin,
|
browser_origin: window.location.origin,
|
||||||
};
|
};
|
||||||
const options = await requestJson("/api/auth/passkeys/registration/options", {
|
return await createPasskey("registration", optionsRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addPasskey(): Promise<AuthUser> {
|
||||||
|
const optionsRequest: PasskeyAdditionOptionsRequest = {
|
||||||
|
browser_origin: window.location.origin,
|
||||||
|
};
|
||||||
|
return await createPasskey("addition", optionsRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPasskey(
|
||||||
|
purpose: "registration" | "addition",
|
||||||
|
optionsRequest:
|
||||||
|
| PasskeyRegistrationOptionsRequest
|
||||||
|
| PasskeyAdditionOptionsRequest,
|
||||||
|
): Promise<AuthUser> {
|
||||||
|
const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(optionsRequest),
|
body: JSON.stringify(optionsRequest),
|
||||||
});
|
});
|
||||||
@@ -137,7 +154,7 @@ export async function registerPasskey(
|
|||||||
credential: registrationCredentialToJson(credential),
|
credential: registrationCredentialToJson(credential),
|
||||||
};
|
};
|
||||||
const result = parseAuthUserResponse(
|
const result = parseAuthUserResponse(
|
||||||
await requestJson("/api/auth/passkeys/registration/complete", {
|
await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(completeRequest),
|
body: JSON.stringify(completeRequest),
|
||||||
}),
|
}),
|
||||||
@@ -145,9 +162,13 @@ export async function registerPasskey(
|
|||||||
return result.user;
|
return result.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginWithPasskey(handle?: string): Promise<AuthUser> {
|
export async function loginWithPasskey(handle: string): Promise<AuthUser> {
|
||||||
|
const loginHandle = handle.trim();
|
||||||
|
if (!loginHandle) {
|
||||||
|
throw new Error("User handle is required for passkey login.");
|
||||||
|
}
|
||||||
const optionsRequest: PasskeyLoginOptionsRequest = {
|
const optionsRequest: PasskeyLoginOptionsRequest = {
|
||||||
handle: handle ?? null,
|
handle: loginHandle,
|
||||||
browser_origin: window.location.origin,
|
browser_origin: window.location.origin,
|
||||||
};
|
};
|
||||||
const options = await requestJson("/api/auth/passkeys/login/options", {
|
const options = await requestJson("/api/auth/passkeys/login/options", {
|
||||||
|
|||||||
@@ -1284,9 +1284,6 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
|||||||
const devicePage = await Deno.readTextFile(
|
const devicePage = await Deno.readTextFile(
|
||||||
new URL("./../../../routes/login/device/+page.svelte", import.meta.url),
|
new URL("./../../../routes/login/device/+page.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
const authApi = await Deno.readTextFile(
|
|
||||||
new URL("../auth/api.ts", import.meta.url),
|
|
||||||
);
|
|
||||||
const rootLayout = await Deno.readTextFile(
|
const rootLayout = await Deno.readTextFile(
|
||||||
new URL("./../../../routes/+layout.svelte", import.meta.url),
|
new URL("./../../../routes/+layout.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
@@ -1339,14 +1336,6 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
|||||||
devicePage.includes("Approve device login"),
|
devicePage.includes("Approve device login"),
|
||||||
"Device login page should approve CLI login without DevTools console",
|
"Device login page should approve CLI login without DevTools console",
|
||||||
);
|
);
|
||||||
assert(
|
|
||||||
authApi.includes("/api/auth/whoami") &&
|
|
||||||
authApi.includes("/api/auth/passkeys/registration/options") &&
|
|
||||||
authApi.includes("/api/auth/passkeys/login/complete") &&
|
|
||||||
authApi.includes("/api/auth/logout") &&
|
|
||||||
authApi.includes("/api/auth/device-login/approve"),
|
|
||||||
"Auth model should stay on Backend auth APIs rather than workspace authorization APIs",
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
rootLayout.includes("SIDEBAR_CONTEXT") &&
|
rootLayout.includes("SIDEBAR_CONTEXT") &&
|
||||||
rootLayout.includes("GlobalSidebar") &&
|
rootLayout.includes("GlobalSidebar") &&
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import {
|
||||||
|
addPasskey,
|
||||||
loadWhoami,
|
loadWhoami,
|
||||||
loginWithPasskey,
|
loginWithPasskey,
|
||||||
logout,
|
logout,
|
||||||
@@ -13,16 +14,19 @@
|
|||||||
let handle = $state('local');
|
let handle = $state('local');
|
||||||
let displayName = $state('Local User');
|
let displayName = $state('Local User');
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
let sessionReady = $state(false);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let status = $state('');
|
let status = $state('');
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
async function refreshWhoami() {
|
async function refreshWhoami() {
|
||||||
loading = true;
|
loading = true;
|
||||||
|
sessionReady = false;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const whoami = await loadWhoami();
|
const whoami = await loadWhoami();
|
||||||
actor = whoami.actor;
|
actor = whoami.actor;
|
||||||
|
sessionReady = true;
|
||||||
if (actor?.handle) {
|
if (actor?.handle) {
|
||||||
handle = actor.handle;
|
handle = actor.handle;
|
||||||
displayName = actor.display_name;
|
displayName = actor.display_name;
|
||||||
@@ -35,12 +39,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function register() {
|
async function register() {
|
||||||
|
if (busy || !sessionReady) return;
|
||||||
|
const adding = actor !== null;
|
||||||
busy = true;
|
busy = true;
|
||||||
status = 'Waiting for passkey registration…';
|
status = adding ? 'Waiting for passkey addition…' : 'Waiting for new account registration…';
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
await registerPasskey(handle.trim(), displayName.trim() || handle.trim());
|
if (adding) {
|
||||||
status = 'Passkey registered.';
|
await addPasskey();
|
||||||
|
} else {
|
||||||
|
await registerPasskey(handle.trim(), displayName.trim() || handle.trim());
|
||||||
|
}
|
||||||
|
status = adding ? 'Passkey added to your account.' : 'New account registered.';
|
||||||
await refreshWhoami();
|
await refreshWhoami();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
@@ -138,16 +148,16 @@
|
|||||||
<h2>Passkey</h2>
|
<h2>Passkey</h2>
|
||||||
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); }}>
|
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); }}>
|
||||||
<label>
|
<label>
|
||||||
User handle
|
{actor ? 'Current user handle' : 'User handle (new registration or login)'}
|
||||||
<input bind:value={handle} autocomplete="username webauthn" placeholder="local" />
|
<input bind:value={handle} disabled={busy || loading || !sessionReady || actor !== null} autocomplete="username webauthn" placeholder="local" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Display name
|
Display name (new account only)
|
||||||
<input bind:value={displayName} autocomplete="name" placeholder="Local User" />
|
<input bind:value={displayName} disabled={busy || loading || !sessionReady || actor !== null} autocomplete="name" placeholder="Local User" />
|
||||||
</label>
|
</label>
|
||||||
<div class="settings-action-row">
|
<div class="settings-action-row">
|
||||||
<button type="button" disabled={busy || !handle.trim()} onclick={register}>Register passkey</button>
|
<button type="button" disabled={busy || loading || !sessionReady || (!actor && !handle.trim())} onclick={register}>{actor ? `Add passkey to ${actor.handle}` : 'Register new account'}</button>
|
||||||
<button type="button" disabled={busy || !handle.trim()} onclick={login}>Log in with passkey</button>
|
<button type="button" disabled={busy || loading || !sessionReady || actor !== null || !handle.trim()} onclick={login}>Log in with passkey</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/svelte";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import Account from "./+page.svelte";
|
||||||
|
import {
|
||||||
|
authUser,
|
||||||
|
credentialFixture,
|
||||||
|
registrationOptions,
|
||||||
|
} from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
// UIから本番HTTP adapterまでを接続し、fetch/ブラウザーcredential境界だけを代替する。
|
||||||
|
function boundary(authenticated: boolean, completeStatus = 200) {
|
||||||
|
const create = vi.fn(async () => credentialFixture());
|
||||||
|
vi.stubGlobal("navigator", { credentials: { create } });
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||||
|
const path = String(input);
|
||||||
|
if (path === "/api/auth/whoami") {
|
||||||
|
return Response.json({
|
||||||
|
actor: authenticated
|
||||||
|
? { ...authUser, auth_method: "browser_session" }
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (path.endsWith("/options")) return Response.json(registrationOptions);
|
||||||
|
if (path.endsWith("/complete")) {
|
||||||
|
if (completeStatus !== 200) {
|
||||||
|
return new Response(null, { status: completeStatus });
|
||||||
|
}
|
||||||
|
authenticated = true;
|
||||||
|
return Response.json({ user: authUser });
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected API: ${path}`);
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
return { fetcher, create };
|
||||||
|
}
|
||||||
|
|
||||||
|
function posted(fetcher: ReturnType<typeof boundary>["fetcher"]) {
|
||||||
|
return fetcher.mock.calls.filter(([, init]) => init?.method === "POST").map((
|
||||||
|
[path, init],
|
||||||
|
) => ({
|
||||||
|
path: String(path),
|
||||||
|
body: JSON.parse(String(init!.body)),
|
||||||
|
credentials: init!.credentials,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("anonymous existing Account page registers a new account via registration endpoints", async () => {
|
||||||
|
const { fetcher, create } = boundary(false);
|
||||||
|
render(Account);
|
||||||
|
const button = await screen.findByRole("button", {
|
||||||
|
name: "Register new account",
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect((button as HTMLButtonElement).disabled).toBe(false)
|
||||||
|
);
|
||||||
|
await fireEvent.input(
|
||||||
|
screen.getByLabelText("User handle (new registration or login)"),
|
||||||
|
{ target: { value: " alice " } },
|
||||||
|
);
|
||||||
|
await fireEvent.input(
|
||||||
|
screen.getByLabelText("Display name (new account only)"),
|
||||||
|
{ target: { value: "Alice" } },
|
||||||
|
);
|
||||||
|
await fireEvent.click(button);
|
||||||
|
await screen.findByText("New account registered.");
|
||||||
|
const requests = posted(fetcher);
|
||||||
|
expect(requests.map((request) => request.path)).toEqual([
|
||||||
|
"/api/auth/passkeys/registration/options",
|
||||||
|
"/api/auth/passkeys/registration/complete",
|
||||||
|
]);
|
||||||
|
expect(requests[0].body).toEqual({
|
||||||
|
handle: "alice",
|
||||||
|
display_name: "Alice",
|
||||||
|
browser_origin: window.location.origin,
|
||||||
|
});
|
||||||
|
expect(requests[1].body.challenge_id).toBe(registrationOptions.challenge_id);
|
||||||
|
expect(create).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authenticated Account locks identity fields and adds only through self-only addition endpoints", async () => {
|
||||||
|
const { fetcher, create } = boundary(true);
|
||||||
|
render(Account);
|
||||||
|
const button = await screen.findByRole("button", {
|
||||||
|
name: "Add passkey to alice",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText("Current user handle") as HTMLInputElement).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText(
|
||||||
|
"Display name (new account only)",
|
||||||
|
) as HTMLInputElement).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", {
|
||||||
|
name: "Log in with passkey",
|
||||||
|
}) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
await fireEvent.click(button);
|
||||||
|
await screen.findByText("Passkey added to your account.");
|
||||||
|
const requests = posted(fetcher);
|
||||||
|
expect(requests.map((request) => request.path)).toEqual([
|
||||||
|
"/api/auth/passkeys/addition/options",
|
||||||
|
"/api/auth/passkeys/addition/complete",
|
||||||
|
]);
|
||||||
|
expect(requests[0].body).toEqual({ browser_origin: window.location.origin });
|
||||||
|
expect(Object.keys(requests[1].body).sort()).toEqual([
|
||||||
|
"challenge_id",
|
||||||
|
"credential",
|
||||||
|
]);
|
||||||
|
expect(requests.map((request) => request.credentials)).toEqual([
|
||||||
|
"same-origin",
|
||||||
|
"same-origin",
|
||||||
|
]);
|
||||||
|
expect(create).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("addition completion rejection is visible and never retries as new registration", async () => {
|
||||||
|
const { fetcher } = boundary(true, 401);
|
||||||
|
render(Account);
|
||||||
|
await fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "Add passkey to alice" }),
|
||||||
|
);
|
||||||
|
await screen.findByText("Auth request failed (401).");
|
||||||
|
expect(posted(fetcher).map((request) => request.path)).toEqual([
|
||||||
|
"/api/auth/passkeys/addition/options",
|
||||||
|
"/api/auth/passkeys/addition/complete",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blank handle disables anonymous registration and login", async () => {
|
||||||
|
boundary(false);
|
||||||
|
render(Account);
|
||||||
|
await screen.findByText("No browser session is active.");
|
||||||
|
await fireEvent.input(
|
||||||
|
screen.getByLabelText("User handle (new registration or login)"),
|
||||||
|
{ target: { value: " " } },
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", {
|
||||||
|
name: "Register new account",
|
||||||
|
}) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", {
|
||||||
|
name: "Log in with passkey",
|
||||||
|
}) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unknown session never enables registration or addition", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response(null, { status: 503 })),
|
||||||
|
);
|
||||||
|
render(Account);
|
||||||
|
await screen.findByText("Auth request failed (503).");
|
||||||
|
expect(
|
||||||
|
(screen.getByRole("button", {
|
||||||
|
name: "Register new account",
|
||||||
|
}) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText(
|
||||||
|
"User handle (new registration or login)",
|
||||||
|
) as HTMLInputElement).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// production shellを既存static fixtureから配信し、Account表示用の境界だけを置換する。
|
||||||
|
// 実account/session/credentialは作成せず、queryで撮影条件を固定する。
|
||||||
|
const port = Number(Deno.args[0]);
|
||||||
|
const upstream = Deno.args[1];
|
||||||
|
if (!Number.isInteger(port) || !upstream) {
|
||||||
|
throw new Error("usage: server <port> <upstream>");
|
||||||
|
}
|
||||||
|
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.pathname === "/health") return new Response("ok");
|
||||||
|
if (url.pathname === "/api/auth/whoami") {
|
||||||
|
return Response.json({
|
||||||
|
actor:
|
||||||
|
request.headers.get("cookie")?.includes("fixture-actor=authenticated")
|
||||||
|
? {
|
||||||
|
user_id: "fixture-user",
|
||||||
|
account_id: "fixture-account",
|
||||||
|
handle: "alice",
|
||||||
|
display_name: "Alice",
|
||||||
|
auth_method: "browser_session",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/workspaces") return Response.json([]);
|
||||||
|
if (url.pathname.startsWith("/api/")) {
|
||||||
|
return new Response("unexpected fixture API", { status: 404 });
|
||||||
|
}
|
||||||
|
const response = await fetch(new URL(url.pathname + url.search, upstream));
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
if (url.pathname === "/account") {
|
||||||
|
headers.set(
|
||||||
|
"set-cookie",
|
||||||
|
`fixture-actor=${
|
||||||
|
url.searchParams.get("actor") === "authenticated"
|
||||||
|
? "authenticated"
|
||||||
|
: "anonymous"
|
||||||
|
}; Path=/; SameSite=Strict`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response(response.body, { status: response.status, headers });
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "account-passkeys",
|
||||||
|
"title": "Existing Account registration and self-only passkey addition",
|
||||||
|
"baseUrl": "http://127.0.0.1:15371",
|
||||||
|
"colorScheme": "light",
|
||||||
|
"reducedMotion": "reduce",
|
||||||
|
"personas": [
|
||||||
|
{
|
||||||
|
"id": "synthetic",
|
||||||
|
"label": "Synthetic browser session; no real credentials",
|
||||||
|
"auth": { "kind": "anonymous" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"viewports": [
|
||||||
|
{ "label": "desktop", "width": 1440, "height": 900 },
|
||||||
|
{ "label": "narrow", "width": 768, "height": 900 },
|
||||||
|
{ "label": "mobile", "width": 390, "height": 844 }
|
||||||
|
],
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"id": "anonymous",
|
||||||
|
"label": "New registration or explicit-handle login",
|
||||||
|
"path": "/account",
|
||||||
|
"goal": "Identify registration as a new-account operation, distinct from logging into an existing handle.",
|
||||||
|
"dataState": "No browser session; existing Account page.",
|
||||||
|
"ready": {
|
||||||
|
"kind": "selector",
|
||||||
|
"selector": ".account-panel:has-text('No browser session is active.')"
|
||||||
|
},
|
||||||
|
"capturePoints": [
|
||||||
|
{ "id": "initial", "label": "Anonymous Account", "fullPage": true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "authenticated",
|
||||||
|
"label": "Add only to current user",
|
||||||
|
"path": "/account?actor=authenticated",
|
||||||
|
"goal": "Identify the authenticated actor as the passkey addition target; identity fields cannot retarget addition.",
|
||||||
|
"dataState": "Synthetic passkey actor alice; existing Account page.",
|
||||||
|
"ready": { "kind": "selector", "selector": ".account-details" },
|
||||||
|
"capturePoints": [
|
||||||
|
{ "id": "initial", "label": "Authenticated Account", "fullPage": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"processes": [
|
||||||
|
{
|
||||||
|
"id": "static-fixture",
|
||||||
|
"command": "deno",
|
||||||
|
"cwd": "../../../../tools/web-ux",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--allow-net",
|
||||||
|
"--allow-read",
|
||||||
|
"browser-tests/workspace_home_fixture_server.ts",
|
||||||
|
"15372",
|
||||||
|
"../../web/workspace/build"
|
||||||
|
],
|
||||||
|
"readyUrl": "http://127.0.0.1:15372/health"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "account-fixture",
|
||||||
|
"command": "deno",
|
||||||
|
"cwd": "../..",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"--allow-net",
|
||||||
|
"test/visual-review/account-passkeys-fixture-server.ts",
|
||||||
|
"15371",
|
||||||
|
"http://127.0.0.1:15372"
|
||||||
|
],
|
||||||
|
"readyUrl": "http://127.0.0.1:15371/health"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -3,10 +3,20 @@ declare const Deno: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
addPasskey,
|
||||||
loadWhoami,
|
loadWhoami,
|
||||||
|
loginWithPasskey,
|
||||||
readBoundedAuthResponseJson,
|
readBoundedAuthResponseJson,
|
||||||
|
registerPasskey,
|
||||||
} from "../src/lib/workspace/auth/api.ts";
|
} from "../src/lib/workspace/auth/api.ts";
|
||||||
|
|
||||||
|
import {
|
||||||
|
authUser,
|
||||||
|
credentialFixture,
|
||||||
|
loginOptions,
|
||||||
|
registrationOptions,
|
||||||
|
} from "../src/lib/workspace/auth/api.test-fixtures.ts";
|
||||||
|
|
||||||
function assertEquals(actual: unknown, expected: unknown): void {
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -97,3 +107,170 @@ Deno.test("bounded auth response reader rejects invalid JSON without echoing con
|
|||||||
sensitive,
|
sensitive,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function withBrowserBoundary(
|
||||||
|
fetcher: typeof fetch,
|
||||||
|
run: (calls: { create: number; get: number }) => Promise<void>,
|
||||||
|
) {
|
||||||
|
const descriptors = new Map(
|
||||||
|
["fetch", "window", "navigator"].map((key) => [
|
||||||
|
key,
|
||||||
|
Object.getOwnPropertyDescriptor(globalThis, key),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const calls = { create: 0, get: 0 };
|
||||||
|
Object.defineProperties(globalThis, {
|
||||||
|
fetch: { configurable: true, writable: true, value: fetcher },
|
||||||
|
window: {
|
||||||
|
configurable: true,
|
||||||
|
value: { location: { origin: "https://yoi.example" } },
|
||||||
|
},
|
||||||
|
navigator: {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
credentials: {
|
||||||
|
create: (options: CredentialCreationOptions) => {
|
||||||
|
calls.create++;
|
||||||
|
assertEquals([
|
||||||
|
...new Uint8Array(options.publicKey!.challenge as ArrayBuffer),
|
||||||
|
], [1, 2, 3]);
|
||||||
|
assertEquals([
|
||||||
|
...new Uint8Array(options.publicKey!.user.id as ArrayBuffer),
|
||||||
|
], [4, 5, 6]);
|
||||||
|
return Promise.resolve(credentialFixture());
|
||||||
|
},
|
||||||
|
get: (options: CredentialRequestOptions) => {
|
||||||
|
calls.get++;
|
||||||
|
assertEquals([
|
||||||
|
...new Uint8Array(
|
||||||
|
options.publicKey!.allowCredentials![0].id as ArrayBuffer,
|
||||||
|
),
|
||||||
|
], [7, 8, 9]);
|
||||||
|
return Promise.resolve(credentialFixture(true));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await run(calls);
|
||||||
|
} finally {
|
||||||
|
for (const [key, descriptor] of descriptors) {
|
||||||
|
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||||
|
else Reflect.deleteProperty(globalThis, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const purpose of ["registration", "addition"] as const) {
|
||||||
|
Deno.test(`${purpose} adapter uses only its own options and complete routes with session credentials`, async () => {
|
||||||
|
const requests: { path: string; init: RequestInit }[] = [];
|
||||||
|
await withBrowserBoundary(async (input, init) => {
|
||||||
|
requests.push({ path: String(input), init: init! });
|
||||||
|
return Response.json(
|
||||||
|
requests.length === 1 ? registrationOptions : { user: authUser },
|
||||||
|
);
|
||||||
|
}, async (calls) => {
|
||||||
|
const user = purpose === "registration"
|
||||||
|
? await registerPasskey("alice", "Alice")
|
||||||
|
: await addPasskey();
|
||||||
|
assertEquals(user, authUser);
|
||||||
|
assertEquals(calls, { create: 1, get: 0 });
|
||||||
|
});
|
||||||
|
assertEquals(requests.map(({ path }) => path), [
|
||||||
|
`/api/auth/passkeys/${purpose}/options`,
|
||||||
|
`/api/auth/passkeys/${purpose}/complete`,
|
||||||
|
]);
|
||||||
|
for (const { init } of requests) {
|
||||||
|
assertEquals(init.method, "POST");
|
||||||
|
assertEquals(init.credentials, "same-origin");
|
||||||
|
assertEquals(init.headers, { "content-type": "application/json" });
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
JSON.parse(String(requests[0].init.body)),
|
||||||
|
purpose === "registration"
|
||||||
|
? {
|
||||||
|
handle: "alice",
|
||||||
|
display_name: "Alice",
|
||||||
|
browser_origin: "https://yoi.example",
|
||||||
|
}
|
||||||
|
: { browser_origin: "https://yoi.example" },
|
||||||
|
);
|
||||||
|
const complete = JSON.parse(String(requests[1].init.body));
|
||||||
|
assertEquals(Object.keys(complete).sort(), ["challenge_id", "credential"]);
|
||||||
|
assertEquals(complete.challenge_id, registrationOptions.challenge_id);
|
||||||
|
assertEquals(complete.credential.response.attestationObject, "AQID");
|
||||||
|
assertEquals(complete.credential.response.transports, ["internal"]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("addition rejects expired-session completion without falling back to registration", async () => {
|
||||||
|
const paths: string[] = [];
|
||||||
|
await withBrowserBoundary(async (input) => {
|
||||||
|
paths.push(String(input));
|
||||||
|
return paths.length === 1
|
||||||
|
? Response.json(registrationOptions)
|
||||||
|
: new Response(null, { status: 401 });
|
||||||
|
}, async () => {
|
||||||
|
await assertRejects(addPasskey(), "Auth request failed (401).");
|
||||||
|
});
|
||||||
|
assertEquals(paths, [
|
||||||
|
"/api/auth/passkeys/addition/options",
|
||||||
|
"/api/auth/passkeys/addition/complete",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("existing-handle registration rejection never starts WebAuthn or retries addition", async () => {
|
||||||
|
const paths: string[] = [];
|
||||||
|
await withBrowserBoundary(async (input) => {
|
||||||
|
paths.push(String(input));
|
||||||
|
return new Response(null, { status: 409 });
|
||||||
|
}, async (calls) => {
|
||||||
|
await assertRejects(registerPasskey("alice"), "Auth request failed (409).");
|
||||||
|
assertEquals(calls, { create: 0, get: 0 });
|
||||||
|
});
|
||||||
|
assertEquals(paths, ["/api/auth/passkeys/registration/options"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("login requires a nonempty explicit handle before HTTP or WebAuthn", async () => {
|
||||||
|
await withBrowserBoundary(() => {
|
||||||
|
throw new Error("Unexpected HTTP request");
|
||||||
|
}, async (calls) => {
|
||||||
|
for (const handle of ["", " ", "\n\t"]) {
|
||||||
|
await assertRejects(
|
||||||
|
loginWithPasskey(handle),
|
||||||
|
"User handle is required for passkey login.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertEquals(calls, { create: 0, get: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("login sends the explicit trimmed handle and serializes the assertion", async () => {
|
||||||
|
const requests: { path: string; init: RequestInit }[] = [];
|
||||||
|
await withBrowserBoundary(async (input, init) => {
|
||||||
|
requests.push({ path: String(input), init: init! });
|
||||||
|
return Response.json(
|
||||||
|
requests.length === 1 ? loginOptions : { user: authUser },
|
||||||
|
);
|
||||||
|
}, async (calls) => {
|
||||||
|
assertEquals(await loginWithPasskey(" alice "), authUser);
|
||||||
|
assertEquals(calls, { create: 0, get: 1 });
|
||||||
|
});
|
||||||
|
assertEquals(requests.map(({ path }) => path), [
|
||||||
|
"/api/auth/passkeys/login/options",
|
||||||
|
"/api/auth/passkeys/login/complete",
|
||||||
|
]);
|
||||||
|
assertEquals(JSON.parse(String(requests[0].init.body)), {
|
||||||
|
handle: "alice",
|
||||||
|
browser_origin: "https://yoi.example",
|
||||||
|
});
|
||||||
|
const complete = JSON.parse(String(requests[1].init.body));
|
||||||
|
assertEquals(complete.challenge_id, loginOptions.challenge_id);
|
||||||
|
assertEquals(complete.credential.response.signature, "AQID");
|
||||||
|
assertEquals(complete.credential.response.userHandle, null);
|
||||||
|
assertEquals(requests.map(({ init }) => init.credentials), [
|
||||||
|
"same-origin",
|
||||||
|
"same-origin",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user