fix: separate account registration from authenticated passkey addition

Bind enrollment purpose and allocated/current account to verifier state. Reject existing-handle registration, derive additions from live transport authentication, recheck revocation/expiry in the insert-only transaction, and consume challenges atomically across connections. Derive stable account WebAuthn handles without rewriting legacy credentials; reject implicit-user login and unsafe legacy ceremonies.

Align account callers, DTO/OpenAPI/generated artifacts and migration/recovery rationale. Preserve credential-less legacy identities rather than allowing anonymous reclaim.

Validation: 14 actual-verifier passkey tests; full workspace-server 1086 library + 11 binary passed (Drive ignored case explicitly selected separately and passed); server-api default 99 and TypeScript feature 108 passed; isolated auth/Runtime E2E 1+2 passed; Web 645 + component 394 passed; root cargo check, docs/doctests, Server/Runtime builds, fmt, naming and generated consistency passed. Software authenticator only; hosted CI/hardware login not claimed.
This commit is contained in:
2026-10-16 00:55:16 +09:00
parent b744efd104
commit 6218c27fb6
24 changed files with 3184 additions and 140 deletions
Generated
+2
View File
@@ -6708,8 +6708,10 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"server-api",
"sha2 0.11.0",
"tempfile",
"webauthn-authenticator-rs",
]
[[package]]
+74
View File
@@ -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",
] {
+1 -1
View File
@@ -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>,
File diff suppressed because it is too large Load Diff
+92 -1
View File
@@ -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,
@@ -6323,6 +6354,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(
@@ -6412,7 +6503,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
View File
@@ -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",
+9
View File
@@ -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"
+9 -1
View File
@@ -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,
&[
+465
View File
@@ -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;
}
+25 -4
View File
@@ -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") &&
+19 -9
View File
@@ -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"
}
]
}
+177
View File
@@ -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",
]);
});