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",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"server-api",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"webauthn-authenticator-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -179,6 +179,7 @@ impl_openapi_schema!(
|
||||
PasskeyLoginOptionsRequest,
|
||||
PasskeyLoginOptionsResponse,
|
||||
PasskeyLoginCompleteRequest,
|
||||
PasskeyAdditionOptionsRequest,
|
||||
PasskeyRegistrationOptionsRequest,
|
||||
PasskeyRegistrationOptionsResponse,
|
||||
PasskeyRegistrationCompleteRequest,
|
||||
@@ -546,6 +547,36 @@ pub trait ServerApi {
|
||||
#[body] request: PasskeyRegistrationCompleteRequest,
|
||||
) -> 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(
|
||||
"/api/auth/passkeys/login/options",
|
||||
status = 200,
|
||||
@@ -3872,6 +3903,7 @@ pub struct AuthUserResponse {
|
||||
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)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -3885,6 +3917,16 @@ pub struct PasskeyRegistrationOptionsRequest {
|
||||
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)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -3905,6 +3947,10 @@ 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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -11344,6 +11390,7 @@ pub fn auth_api_typescript() -> String {
|
||||
WhoamiResponse::decl(&config),
|
||||
AuthBootstrapUserRequest::decl(&config),
|
||||
AuthUserResponse::decl(&config),
|
||||
PasskeyAdditionOptionsRequest::decl(&config),
|
||||
PasskeyRegistrationOptionsRequest::decl(&config),
|
||||
PasskeyRegistrationOptionsResponse::decl(&config),
|
||||
PasskeyRegistrationCompleteRequest::decl(&config),
|
||||
@@ -12061,6 +12108,16 @@ mod tests {
|
||||
HttpMethod::Post,
|
||||
"/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",
|
||||
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]
|
||||
fn auth_workspace_catalog_and_typed_header_contract_is_strict() {
|
||||
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/passkeys/registration/options", "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/complete", "post"),
|
||||
("/api/auth/logout", "post"),
|
||||
@@ -14503,6 +14576,7 @@ mod openapi_artifact_tests {
|
||||
}
|
||||
for path in [
|
||||
"/api/auth/passkeys/registration/complete",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
"/api/auth/passkeys/login/complete",
|
||||
"/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.
|
||||
// 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,
|
||||
bearer: 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,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct PasskeyCredentialRecord {
|
||||
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_by_handle(&self, handle: &str) -> 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 get_passkey_credential(
|
||||
&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<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
@@ -6435,7 +6526,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
ceremony: &str,
|
||||
consumed_at: &str,
|
||||
) -> Result<Option<AuthChallengeRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
self.with_immediate_transaction(|conn| {
|
||||
let record = conn
|
||||
.query_row(
|
||||
auth_challenge_select_sql(
|
||||
|
||||
+163
-1
@@ -6562,6 +6562,19 @@
|
||||
],
|
||||
"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": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -6578,6 +6591,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.",
|
||||
"properties": {
|
||||
"browser_origin": {
|
||||
"type": [
|
||||
@@ -6624,6 +6638,7 @@
|
||||
},
|
||||
"PasskeyRegistrationOptionsRequest": {
|
||||
"additionalProperties": false,
|
||||
"description": "Starts new-account registration only; an existing handle is never interpreted as key addition.",
|
||||
"properties": {
|
||||
"browser_origin": {
|
||||
"type": [
|
||||
@@ -17582,7 +17597,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c"
|
||||
"x-yoi-source-digest": "sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"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": {
|
||||
"post": {
|
||||
"operationId": "auth_passkey_login_complete",
|
||||
|
||||
@@ -18,6 +18,15 @@ serde_json.workspace = true
|
||||
sha2.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]]
|
||||
name = "panel"
|
||||
path = "tests/panel.rs"
|
||||
|
||||
@@ -305,7 +305,8 @@ impl WorkerE2eEnvironment {
|
||||
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.runtime_data_dir, "Runtime data directory")?;
|
||||
let server_binary = self.binaries.binary("yoi-server")?;
|
||||
@@ -328,7 +329,14 @@ impl WorkerE2eEnvironment {
|
||||
self.server = Some(server);
|
||||
self.server_url = Some(server_url.clone());
|
||||
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(
|
||||
&server_binary,
|
||||
&[
|
||||
@@ -439,7 +447,14 @@ impl WorkerE2eEnvironment {
|
||||
false,
|
||||
)?;
|
||||
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);
|
||||
command.args([
|
||||
@@ -468,12 +483,20 @@ impl WorkerE2eEnvironment {
|
||||
.post(format!("{server_url}/api/w/{workspace_id}/runtimes"))
|
||||
.json(&serde_json::json!({
|
||||
"public_bundle": runtime_identity,
|
||||
"workspace_trust_id": workspace_trust_id,
|
||||
"display_name": format!("E2E {}", self.runtime_id),
|
||||
"endpoint": runtime_url
|
||||
})),
|
||||
"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(
|
||||
self.client.post(format!(
|
||||
"{server_url}/api/w/{workspace_id}/runtimes/{}/connection-tests",
|
||||
@@ -486,14 +509,7 @@ impl WorkerE2eEnvironment {
|
||||
"Runtime connection was not compatible: {connection}"
|
||||
)));
|
||||
}
|
||||
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()?;
|
||||
let binding_id = json_string(&connection, "/binding_id")?;
|
||||
let runtimes = self.request_json(
|
||||
self.client
|
||||
.get(format!("{server_url}/api/w/{workspace_id}/runtimes")),
|
||||
@@ -506,9 +522,9 @@ impl WorkerE2eEnvironment {
|
||||
items.iter().any(|item| {
|
||||
item.get("runtime_id").and_then(Value::as_str) == Some(self.runtime_id.as_str())
|
||||
&& item
|
||||
.pointer("/management/binding/connection_state")
|
||||
.pointer("/management/binding/binding_id")
|
||||
.and_then(Value::as_str)
|
||||
== Some("verified")
|
||||
== Some(binding_id.as_str())
|
||||
})
|
||||
});
|
||||
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 PasskeyAdditionOptionsRequest = { 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, };
|
||||
|
||||
@@ -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:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
|
||||
// Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f)
|
||||
// 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: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
|
||||
// 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:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c)
|
||||
// Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f)
|
||||
// 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: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
|
||||
// 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-workdir-openapi-typescript@1. Do not edit.
|
||||
// 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
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// 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.
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -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 {
|
||||
DeviceLoginApproveRequest,
|
||||
PasskeyAdditionOptionsRequest,
|
||||
PasskeyLoginCompleteRequest,
|
||||
PasskeyLoginOptionsRequest,
|
||||
PasskeyRegistrationCompleteRequest,
|
||||
@@ -114,7 +115,23 @@ export async function registerPasskey(
|
||||
display_name: displayName ?? null,
|
||||
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",
|
||||
body: JSON.stringify(optionsRequest),
|
||||
});
|
||||
@@ -137,7 +154,7 @@ export async function registerPasskey(
|
||||
credential: registrationCredentialToJson(credential),
|
||||
};
|
||||
const result = parseAuthUserResponse(
|
||||
await requestJson("/api/auth/passkeys/registration/complete", {
|
||||
await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(completeRequest),
|
||||
}),
|
||||
@@ -145,9 +162,13 @@ export async function registerPasskey(
|
||||
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 = {
|
||||
handle: handle ?? null,
|
||||
handle: loginHandle,
|
||||
browser_origin: window.location.origin,
|
||||
};
|
||||
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(
|
||||
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(
|
||||
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"),
|
||||
"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(
|
||||
rootLayout.includes("SIDEBAR_CONTEXT") &&
|
||||
rootLayout.includes("GlobalSidebar") &&
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
addPasskey,
|
||||
loadWhoami,
|
||||
loginWithPasskey,
|
||||
logout,
|
||||
@@ -13,16 +14,19 @@
|
||||
let handle = $state('local');
|
||||
let displayName = $state('Local User');
|
||||
let loading = $state(true);
|
||||
let sessionReady = $state(false);
|
||||
let busy = $state(false);
|
||||
let status = $state('');
|
||||
let error = $state('');
|
||||
|
||||
async function refreshWhoami() {
|
||||
loading = true;
|
||||
sessionReady = false;
|
||||
error = '';
|
||||
try {
|
||||
const whoami = await loadWhoami();
|
||||
actor = whoami.actor;
|
||||
sessionReady = true;
|
||||
if (actor?.handle) {
|
||||
handle = actor.handle;
|
||||
displayName = actor.display_name;
|
||||
@@ -35,12 +39,18 @@
|
||||
}
|
||||
|
||||
async function register() {
|
||||
if (busy || !sessionReady) return;
|
||||
const adding = actor !== null;
|
||||
busy = true;
|
||||
status = 'Waiting for passkey registration…';
|
||||
status = adding ? 'Waiting for passkey addition…' : 'Waiting for new account registration…';
|
||||
error = '';
|
||||
try {
|
||||
await registerPasskey(handle.trim(), displayName.trim() || handle.trim());
|
||||
status = 'Passkey registered.';
|
||||
if (adding) {
|
||||
await addPasskey();
|
||||
} else {
|
||||
await registerPasskey(handle.trim(), displayName.trim() || handle.trim());
|
||||
}
|
||||
status = adding ? 'Passkey added to your account.' : 'New account registered.';
|
||||
await refreshWhoami();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
@@ -138,16 +148,16 @@
|
||||
<h2>Passkey</h2>
|
||||
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); }}>
|
||||
<label>
|
||||
User handle
|
||||
<input bind:value={handle} autocomplete="username webauthn" placeholder="local" />
|
||||
{actor ? 'Current user handle' : 'User handle (new registration or login)'}
|
||||
<input bind:value={handle} disabled={busy || loading || !sessionReady || actor !== null} autocomplete="username webauthn" placeholder="local" />
|
||||
</label>
|
||||
<label>
|
||||
Display name
|
||||
<input bind:value={displayName} autocomplete="name" placeholder="Local User" />
|
||||
Display name (new account only)
|
||||
<input bind:value={displayName} disabled={busy || loading || !sessionReady || actor !== null} autocomplete="name" placeholder="Local User" />
|
||||
</label>
|
||||
<div class="settings-action-row">
|
||||
<button type="button" disabled={busy || !handle.trim()} onclick={register}>Register passkey</button>
|
||||
<button type="button" disabled={busy || !handle.trim()} onclick={login}>Log in with 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 || loading || !sessionReady || actor !== null || !handle.trim()} onclick={login}>Log in with passkey</button>
|
||||
</div>
|
||||
</form>
|
||||
</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 {
|
||||
addPasskey,
|
||||
loadWhoami,
|
||||
loginWithPasskey,
|
||||
readBoundedAuthResponseJson,
|
||||
registerPasskey,
|
||||
} 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 {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
@@ -97,3 +107,170 @@ Deno.test("bounded auth response reader rejects invalid JSON without echoing con
|
||||
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