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", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"server-api",
"sha2 0.11.0", "sha2 0.11.0",
"tempfile", "tempfile",
"webauthn-authenticator-rs",
] ]
[[package]] [[package]]
+74
View File
@@ -179,6 +179,7 @@ impl_openapi_schema!(
PasskeyLoginOptionsRequest, PasskeyLoginOptionsRequest,
PasskeyLoginOptionsResponse, PasskeyLoginOptionsResponse,
PasskeyLoginCompleteRequest, PasskeyLoginCompleteRequest,
PasskeyAdditionOptionsRequest,
PasskeyRegistrationOptionsRequest, PasskeyRegistrationOptionsRequest,
PasskeyRegistrationOptionsResponse, PasskeyRegistrationOptionsResponse,
PasskeyRegistrationCompleteRequest, PasskeyRegistrationCompleteRequest,
@@ -546,6 +547,36 @@ pub trait ServerApi {
#[body] request: PasskeyRegistrationCompleteRequest, #[body] request: PasskeyRegistrationCompleteRequest,
) -> Result<server_api_responses::AuthPasskeyRegistrationComplete, RepositoryApiError>; ) -> Result<server_api_responses::AuthPasskeyRegistrationComplete, RepositoryApiError>;
#[post(
"/api/auth/passkeys/addition/options",
status = 200,
error_status = 400,
additional_error_statuses = [403, 500, 502],
bearer_auth = true,
browser_auth = true
)]
async fn auth_passkey_addition_options(
&self,
#[extension] context: ServerRequestContext,
#[body] request: PasskeyAdditionOptionsRequest,
) -> Result<PasskeyRegistrationOptionsResponse, RepositoryApiError>;
#[post(
"/api/auth/passkeys/addition/complete",
responses = [
(status = 200, body = AuthUserResponse, headers = [("set-cookie", SetCookieHeader)])
],
error_status = 400,
additional_error_statuses = [403, 500],
bearer_auth = true,
browser_auth = true
)]
async fn auth_passkey_addition_complete(
&self,
#[extension] context: ServerRequestContext,
#[body] request: PasskeyRegistrationCompleteRequest,
) -> Result<server_api_responses::AuthPasskeyAdditionComplete, RepositoryApiError>;
#[post( #[post(
"/api/auth/passkeys/login/options", "/api/auth/passkeys/login/options",
status = 200, status = 200,
@@ -3872,6 +3903,7 @@ pub struct AuthUserResponse {
pub user: AuthenticatedUser, pub user: AuthenticatedUser,
} }
/// Starts new-account registration only; an existing handle is never interpreted as key addition.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -3885,6 +3917,16 @@ pub struct PasskeyRegistrationOptionsRequest {
pub browser_origin: Option<String>, pub browser_origin: Option<String>,
} }
/// Adds a credential to the current authenticated actor; target identity is never public input.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PasskeyAdditionOptionsRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
pub browser_origin: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -3905,6 +3947,10 @@ pub struct PasskeyRegistrationCompleteRequest {
pub credential: RegisterPublicKeyCredential, pub credential: RegisterPublicKeyCredential,
} }
/// Identified-account login. Missing handles are rejected, not resolved to an implicit user.
///
/// The nullable wire field permits an explicit authentication error for old callers; it does not
/// advertise a discoverable-login ceremony.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -11344,6 +11390,7 @@ pub fn auth_api_typescript() -> String {
WhoamiResponse::decl(&config), WhoamiResponse::decl(&config),
AuthBootstrapUserRequest::decl(&config), AuthBootstrapUserRequest::decl(&config),
AuthUserResponse::decl(&config), AuthUserResponse::decl(&config),
PasskeyAdditionOptionsRequest::decl(&config),
PasskeyRegistrationOptionsRequest::decl(&config), PasskeyRegistrationOptionsRequest::decl(&config),
PasskeyRegistrationOptionsResponse::decl(&config), PasskeyRegistrationOptionsResponse::decl(&config),
PasskeyRegistrationCompleteRequest::decl(&config), PasskeyRegistrationCompleteRequest::decl(&config),
@@ -12061,6 +12108,16 @@ mod tests {
HttpMethod::Post, HttpMethod::Post,
"/api/auth/passkeys/registration/options", "/api/auth/passkeys/registration/options",
), ),
(
"auth_passkey_addition_options",
HttpMethod::Post,
"/api/auth/passkeys/addition/options",
),
(
"auth_passkey_addition_complete",
HttpMethod::Post,
"/api/auth/passkeys/addition/complete",
),
( (
"auth_passkey_login_options", "auth_passkey_login_options",
HttpMethod::Post, HttpMethod::Post,
@@ -14452,6 +14509,20 @@ mod openapi_artifact_tests {
} }
} }
#[test]
fn passkey_addition_rejects_caller_selected_target_fields() {
for field in ["handle", "user_id", "account_id"] {
let payload = serde_json::json!({ (field): "other-user" });
assert!(
serde_json::from_value::<PasskeyAdditionOptionsRequest>(payload).is_err(),
"addition must reject caller-selected {field}"
);
}
assert!(
serde_json::from_value::<PasskeyAdditionOptionsRequest>(serde_json::json!({})).is_ok()
);
}
#[test] #[test]
fn auth_workspace_catalog_and_typed_header_contract_is_strict() { fn auth_workspace_catalog_and_typed_header_contract_is_strict() {
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build"); let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
@@ -14465,6 +14536,8 @@ mod openapi_artifact_tests {
("/api/auth/bootstrap-user", "post"), ("/api/auth/bootstrap-user", "post"),
("/api/auth/passkeys/registration/options", "post"), ("/api/auth/passkeys/registration/options", "post"),
("/api/auth/passkeys/registration/complete", "post"), ("/api/auth/passkeys/registration/complete", "post"),
("/api/auth/passkeys/addition/options", "post"),
("/api/auth/passkeys/addition/complete", "post"),
("/api/auth/passkeys/login/options", "post"), ("/api/auth/passkeys/login/options", "post"),
("/api/auth/passkeys/login/complete", "post"), ("/api/auth/passkeys/login/complete", "post"),
("/api/auth/logout", "post"), ("/api/auth/logout", "post"),
@@ -14503,6 +14576,7 @@ mod openapi_artifact_tests {
} }
for path in [ for path in [
"/api/auth/passkeys/registration/complete", "/api/auth/passkeys/registration/complete",
"/api/auth/passkeys/addition/complete",
"/api/auth/passkeys/login/complete", "/api/auth/passkeys/login/complete",
"/api/auth/logout", "/api/auth/logout",
] { ] {
+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. // One owned synchronous stage includes token usage marking and the user read.
// Do not call async store wrappers here: they use the same executor themselves. // Do not call async store wrappers here: they use the same executor themselves.
fn resolve_request_actor_sync( pub(crate) fn resolve_request_actor_sync(
store: &dyn ControlPlaneStore, store: &dyn ControlPlaneStore,
bearer: Option<String>, bearer: Option<String>,
session: Option<String>, session: Option<String>,
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, pub updated_at: String,
} }
/// Enrollment authority persisted with the WebAuthn verifier, never supplied by a client.
/// New-account identities are only allocations until verified completion commits them.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "purpose", rename_all = "snake_case", deny_unknown_fields)]
pub enum PasskeyRegistrationTarget {
NewAccount {
user: UserRecord,
},
Addition {
user: UserRecord,
authentication: PasskeyAuthenticationBinding,
},
}
/// Hash of the exact transport credential that authorized an addition. A second
/// session for the same user cannot complete its ceremony; revocation is checked
/// again in the credential insertion transaction.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "method", content = "token_hash", rename_all = "snake_case")]
pub enum PasskeyAuthenticationBinding {
BrowserSession(String),
ApiToken(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PasskeyCredentialRecord { pub struct PasskeyCredentialRecord {
pub credential_id: String, pub credential_id: String,
@@ -1552,6 +1576,13 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
fn get_user(&self, user_id: &str) -> Result<Option<UserRecord>>; fn get_user(&self, user_id: &str) -> Result<Option<UserRecord>>;
fn get_user_by_handle(&self, handle: &str) -> Result<Option<UserRecord>>; fn get_user_by_handle(&self, handle: &str) -> Result<Option<UserRecord>>;
fn any_user(&self) -> Result<Option<UserRecord>>; fn any_user(&self) -> Result<Option<UserRecord>>;
/// Atomically creates a new account/user/credential, or inserts a credential
/// for a still-authenticated existing target. Never overwrites a credential.
fn register_verified_passkey(
&self,
target: &PasskeyRegistrationTarget,
credential: &PasskeyCredentialRecord,
) -> Result<()>;
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>; fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>;
fn get_passkey_credential( fn get_passkey_credential(
&self, &self,
@@ -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<()> { fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> {
self.with_conn(|conn| { self.with_conn(|conn| {
conn.execute( conn.execute(
@@ -6412,7 +6503,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
ceremony: &str, ceremony: &str,
consumed_at: &str, consumed_at: &str,
) -> Result<Option<AuthChallengeRecord>> { ) -> Result<Option<AuthChallengeRecord>> {
self.with_conn(|conn| { self.with_immediate_transaction(|conn| {
let record = conn let record = conn
.query_row( .query_row(
auth_challenge_select_sql( auth_challenge_select_sql(
+163 -1
View File
@@ -6562,6 +6562,19 @@
], ],
"type": "object" "type": "object"
}, },
"PasskeyAdditionOptionsRequest": {
"additionalProperties": false,
"description": "Adds a credential to the current authenticated actor; target identity is never public input.",
"properties": {
"browser_origin": {
"type": [
"string",
"null"
]
}
},
"type": "object"
},
"PasskeyLoginCompleteRequest": { "PasskeyLoginCompleteRequest": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -6578,6 +6591,7 @@
}, },
"PasskeyLoginOptionsRequest": { "PasskeyLoginOptionsRequest": {
"additionalProperties": false, "additionalProperties": false,
"description": "Identified-account login. Missing handles are rejected, not resolved to an implicit user.\n\nThe nullable wire field permits an explicit authentication error for old callers; it does not\nadvertise a discoverable-login ceremony.",
"properties": { "properties": {
"browser_origin": { "browser_origin": {
"type": [ "type": [
@@ -6624,6 +6638,7 @@
}, },
"PasskeyRegistrationOptionsRequest": { "PasskeyRegistrationOptionsRequest": {
"additionalProperties": false, "additionalProperties": false,
"description": "Starts new-account registration only; an existing handle is never interpreted as key addition.",
"properties": { "properties": {
"browser_origin": { "browser_origin": {
"type": [ "type": [
@@ -17582,7 +17597,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c" "x-yoi-source-digest": "sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -17915,6 +17930,153 @@
} }
} }
}, },
"/api/auth/passkeys/addition/complete": {
"post": {
"operationId": "auth_passkey_addition_complete",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyRegistrationCompleteRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthUserResponse"
}
}
},
"description": "Successful response",
"headers": {
"set-cookie": {
"schema": {
"$ref": "#/components/schemas/SetCookieHeader"
}
}
}
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/auth/passkeys/addition/options": {
"post": {
"operationId": "auth_passkey_addition_options",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyAdditionOptionsRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyRegistrationOptionsResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"502": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/auth/passkeys/login/complete": { "/api/auth/passkeys/login/complete": {
"post": { "post": {
"operationId": "auth_passkey_login_complete", "operationId": "auth_passkey_login_complete",
+9
View File
@@ -18,6 +18,15 @@ serde_json.workspace = true
sha2.workspace = true sha2.workspace = true
tempfile.workspace = true tempfile.workspace = true
[dev-dependencies]
server-api.workspace = true
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
[[test]]
name = "auth_passkeys"
path = "tests/auth_passkeys.rs"
required-features = ["e2e"]
[[test]] [[test]]
name = "panel" name = "panel"
path = "tests/panel.rs" path = "tests/panel.rs"
+9 -1
View File
@@ -305,7 +305,8 @@ impl WorkerE2eEnvironment {
self.start_runtime() self.start_runtime()
} }
pub fn start_backend(&mut self) -> Result<()> { /// 空のDBでBackendだけを起動し、Account/Workspaceは作成しない。
pub fn start_empty_backend(&mut self) -> Result<()> {
assert_absent_or_empty(&self.server_data_dir, "Server data directory")?; assert_absent_or_empty(&self.server_data_dir, "Server data directory")?;
assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?; assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?;
let server_binary = self.binaries.binary("yoi-server")?; let server_binary = self.binaries.binary("yoi-server")?;
@@ -328,7 +329,14 @@ impl WorkerE2eEnvironment {
self.server = Some(server); self.server = Some(server);
self.server_url = Some(server_url.clone()); self.server_url = Some(server_url.clone());
self.wait_for_server_health(&server_url)?; self.wait_for_server_health(&server_url)?;
self.write_metadata("empty-backend-running")?;
Ok(())
}
pub fn start_backend(&mut self) -> Result<()> {
self.start_empty_backend()?;
let server_binary = self.binaries.binary("yoi-server")?;
let server_url = self.required_server_url()?.to_string();
let bootstrap = self.run_command( let bootstrap = self.run_command(
&server_binary, &server_binary,
&[ &[
+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 AuthUserResponse = { user: AuthenticatedUser, };
export type PasskeyAdditionOptionsRequest = { browser_origin?: string | null, };
export type PasskeyRegistrationOptionsRequest = { handle: string, display_name?: string | null, browser_origin?: string | null, }; export type PasskeyRegistrationOptionsRequest = { handle: string, display_name?: string | null, browser_origin?: string | null, };
export type PasskeyRegistrationOptionsResponse = { challenge_id: string, public_key: unknown, }; export type PasskeyRegistrationOptionsResponse = { challenge_id: string, public_key: unknown, };
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c) // Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c) // Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c) // Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c) // Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit. // @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb) // Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts // Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c) // Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -0,0 +1,50 @@
// HTTP caller試験は本番adapterとWebAuthn変換を接続し、fetchとbrowser credential境界だけを代替する。
// 認証・署名の検証はBackend suiteが所有し、このfixtureでは保証しない。
export const authUser = {
user_id: "user-alice",
account_id: "account-alice",
handle: "alice",
display_name: "Alice",
};
export const registrationOptions = {
challenge_id: "create-challenge",
public_key: {
publicKey: {
challenge: "AQID",
rp: { id: "localhost", name: "Yoi" },
user: { id: "BAUG", name: "alice", displayName: "Alice" },
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
},
},
};
export const loginOptions = {
challenge_id: "login-challenge",
public_key: {
publicKey: {
challenge: "AQID",
allowCredentials: [{ type: "public-key", id: "BwgJ" }],
},
},
};
export function credentialFixture(authentication = false): Credential {
const bytes = new Uint8Array([1, 2, 3]).buffer;
return {
id: "AQID",
rawId: bytes,
type: "public-key",
authenticatorAttachment: "platform",
getClientExtensionResults: () => ({}),
response: authentication
? {
clientDataJSON: bytes,
authenticatorData: bytes,
signature: bytes,
userHandle: null,
}
: {
clientDataJSON: bytes,
attestationObject: bytes,
getTransports: () => ["internal"],
},
} as unknown as Credential;
}
+25 -4
View File
@@ -1,5 +1,6 @@
import type { import type {
DeviceLoginApproveRequest, DeviceLoginApproveRequest,
PasskeyAdditionOptionsRequest,
PasskeyLoginCompleteRequest, PasskeyLoginCompleteRequest,
PasskeyLoginOptionsRequest, PasskeyLoginOptionsRequest,
PasskeyRegistrationCompleteRequest, PasskeyRegistrationCompleteRequest,
@@ -114,7 +115,23 @@ export async function registerPasskey(
display_name: displayName ?? null, display_name: displayName ?? null,
browser_origin: window.location.origin, browser_origin: window.location.origin,
}; };
const options = await requestJson("/api/auth/passkeys/registration/options", { return await createPasskey("registration", optionsRequest);
}
export async function addPasskey(): Promise<AuthUser> {
const optionsRequest: PasskeyAdditionOptionsRequest = {
browser_origin: window.location.origin,
};
return await createPasskey("addition", optionsRequest);
}
async function createPasskey(
purpose: "registration" | "addition",
optionsRequest:
| PasskeyRegistrationOptionsRequest
| PasskeyAdditionOptionsRequest,
): Promise<AuthUser> {
const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, {
method: "POST", method: "POST",
body: JSON.stringify(optionsRequest), body: JSON.stringify(optionsRequest),
}); });
@@ -137,7 +154,7 @@ export async function registerPasskey(
credential: registrationCredentialToJson(credential), credential: registrationCredentialToJson(credential),
}; };
const result = parseAuthUserResponse( const result = parseAuthUserResponse(
await requestJson("/api/auth/passkeys/registration/complete", { await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
method: "POST", method: "POST",
body: JSON.stringify(completeRequest), body: JSON.stringify(completeRequest),
}), }),
@@ -145,9 +162,13 @@ export async function registerPasskey(
return result.user; return result.user;
} }
export async function loginWithPasskey(handle?: string): Promise<AuthUser> { export async function loginWithPasskey(handle: string): Promise<AuthUser> {
const loginHandle = handle.trim();
if (!loginHandle) {
throw new Error("User handle is required for passkey login.");
}
const optionsRequest: PasskeyLoginOptionsRequest = { const optionsRequest: PasskeyLoginOptionsRequest = {
handle: handle ?? null, handle: loginHandle,
browser_origin: window.location.origin, browser_origin: window.location.origin,
}; };
const options = await requestJson("/api/auth/passkeys/login/options", { const options = await requestJson("/api/auth/passkeys/login/options", {
@@ -1284,9 +1284,6 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
const devicePage = await Deno.readTextFile( const devicePage = await Deno.readTextFile(
new URL("./../../../routes/login/device/+page.svelte", import.meta.url), new URL("./../../../routes/login/device/+page.svelte", import.meta.url),
); );
const authApi = await Deno.readTextFile(
new URL("../auth/api.ts", import.meta.url),
);
const rootLayout = await Deno.readTextFile( const rootLayout = await Deno.readTextFile(
new URL("./../../../routes/+layout.svelte", import.meta.url), new URL("./../../../routes/+layout.svelte", import.meta.url),
); );
@@ -1339,14 +1336,6 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
devicePage.includes("Approve device login"), devicePage.includes("Approve device login"),
"Device login page should approve CLI login without DevTools console", "Device login page should approve CLI login without DevTools console",
); );
assert(
authApi.includes("/api/auth/whoami") &&
authApi.includes("/api/auth/passkeys/registration/options") &&
authApi.includes("/api/auth/passkeys/login/complete") &&
authApi.includes("/api/auth/logout") &&
authApi.includes("/api/auth/device-login/approve"),
"Auth model should stay on Backend auth APIs rather than workspace authorization APIs",
);
assert( assert(
rootLayout.includes("SIDEBAR_CONTEXT") && rootLayout.includes("SIDEBAR_CONTEXT") &&
rootLayout.includes("GlobalSidebar") && rootLayout.includes("GlobalSidebar") &&
+18 -8
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import {
addPasskey,
loadWhoami, loadWhoami,
loginWithPasskey, loginWithPasskey,
logout, logout,
@@ -13,16 +14,19 @@
let handle = $state('local'); let handle = $state('local');
let displayName = $state('Local User'); let displayName = $state('Local User');
let loading = $state(true); let loading = $state(true);
let sessionReady = $state(false);
let busy = $state(false); let busy = $state(false);
let status = $state(''); let status = $state('');
let error = $state(''); let error = $state('');
async function refreshWhoami() { async function refreshWhoami() {
loading = true; loading = true;
sessionReady = false;
error = ''; error = '';
try { try {
const whoami = await loadWhoami(); const whoami = await loadWhoami();
actor = whoami.actor; actor = whoami.actor;
sessionReady = true;
if (actor?.handle) { if (actor?.handle) {
handle = actor.handle; handle = actor.handle;
displayName = actor.display_name; displayName = actor.display_name;
@@ -35,12 +39,18 @@
} }
async function register() { async function register() {
if (busy || !sessionReady) return;
const adding = actor !== null;
busy = true; busy = true;
status = 'Waiting for passkey registration…'; status = adding ? 'Waiting for passkey addition…' : 'Waiting for new account registration…';
error = ''; error = '';
try { try {
if (adding) {
await addPasskey();
} else {
await registerPasskey(handle.trim(), displayName.trim() || handle.trim()); await registerPasskey(handle.trim(), displayName.trim() || handle.trim());
status = 'Passkey registered.'; }
status = adding ? 'Passkey added to your account.' : 'New account registered.';
await refreshWhoami(); await refreshWhoami();
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : String(err); error = err instanceof Error ? err.message : String(err);
@@ -138,16 +148,16 @@
<h2>Passkey</h2> <h2>Passkey</h2>
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); }}> <form class="settings-form" onsubmit={(event) => { event.preventDefault(); }}>
<label> <label>
User handle {actor ? 'Current user handle' : 'User handle (new registration or login)'}
<input bind:value={handle} autocomplete="username webauthn" placeholder="local" /> <input bind:value={handle} disabled={busy || loading || !sessionReady || actor !== null} autocomplete="username webauthn" placeholder="local" />
</label> </label>
<label> <label>
Display name Display name (new account only)
<input bind:value={displayName} autocomplete="name" placeholder="Local User" /> <input bind:value={displayName} disabled={busy || loading || !sessionReady || actor !== null} autocomplete="name" placeholder="Local User" />
</label> </label>
<div class="settings-action-row"> <div class="settings-action-row">
<button type="button" disabled={busy || !handle.trim()} onclick={register}>Register passkey</button> <button type="button" disabled={busy || loading || !sessionReady || (!actor && !handle.trim())} onclick={register}>{actor ? `Add passkey to ${actor.handle}` : 'Register new account'}</button>
<button type="button" disabled={busy || !handle.trim()} onclick={login}>Log in with passkey</button> <button type="button" disabled={busy || loading || !sessionReady || actor !== null || !handle.trim()} onclick={login}>Log in with passkey</button>
</div> </div>
</form> </form>
</section> </section>
@@ -0,0 +1,180 @@
// @vitest-environment happy-dom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import Account from "./+page.svelte";
import {
authUser,
credentialFixture,
registrationOptions,
} from "#lib/workspace/auth/api.test-fixtures.ts";
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
// UIから本番HTTP adapterまでを接続し、fetch/ブラウザーcredential境界だけを代替する。
function boundary(authenticated: boolean, completeStatus = 200) {
const create = vi.fn(async () => credentialFixture());
vi.stubGlobal("navigator", { credentials: { create } });
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/whoami") {
return Response.json({
actor: authenticated
? { ...authUser, auth_method: "browser_session" }
: null,
});
}
if (path.endsWith("/options")) return Response.json(registrationOptions);
if (path.endsWith("/complete")) {
if (completeStatus !== 200) {
return new Response(null, { status: completeStatus });
}
authenticated = true;
return Response.json({ user: authUser });
}
throw new Error(`Unexpected API: ${path}`);
});
vi.stubGlobal("fetch", fetcher);
return { fetcher, create };
}
function posted(fetcher: ReturnType<typeof boundary>["fetcher"]) {
return fetcher.mock.calls.filter(([, init]) => init?.method === "POST").map((
[path, init],
) => ({
path: String(path),
body: JSON.parse(String(init!.body)),
credentials: init!.credentials,
}));
}
test("anonymous existing Account page registers a new account via registration endpoints", async () => {
const { fetcher, create } = boundary(false);
render(Account);
const button = await screen.findByRole("button", {
name: "Register new account",
});
await waitFor(() =>
expect((button as HTMLButtonElement).disabled).toBe(false)
);
await fireEvent.input(
screen.getByLabelText("User handle (new registration or login)"),
{ target: { value: " alice " } },
);
await fireEvent.input(
screen.getByLabelText("Display name (new account only)"),
{ target: { value: "Alice" } },
);
await fireEvent.click(button);
await screen.findByText("New account registered.");
const requests = posted(fetcher);
expect(requests.map((request) => request.path)).toEqual([
"/api/auth/passkeys/registration/options",
"/api/auth/passkeys/registration/complete",
]);
expect(requests[0].body).toEqual({
handle: "alice",
display_name: "Alice",
browser_origin: window.location.origin,
});
expect(requests[1].body.challenge_id).toBe(registrationOptions.challenge_id);
expect(create).toHaveBeenCalledOnce();
});
test("authenticated Account locks identity fields and adds only through self-only addition endpoints", async () => {
const { fetcher, create } = boundary(true);
render(Account);
const button = await screen.findByRole("button", {
name: "Add passkey to alice",
});
expect(
(screen.getByLabelText("Current user handle") as HTMLInputElement).disabled,
).toBe(true);
expect(
(screen.getByLabelText(
"Display name (new account only)",
) as HTMLInputElement).disabled,
).toBe(true);
expect(
(screen.getByRole("button", {
name: "Log in with passkey",
}) as HTMLButtonElement).disabled,
).toBe(true);
await fireEvent.click(button);
await screen.findByText("Passkey added to your account.");
const requests = posted(fetcher);
expect(requests.map((request) => request.path)).toEqual([
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
expect(requests[0].body).toEqual({ browser_origin: window.location.origin });
expect(Object.keys(requests[1].body).sort()).toEqual([
"challenge_id",
"credential",
]);
expect(requests.map((request) => request.credentials)).toEqual([
"same-origin",
"same-origin",
]);
expect(create).toHaveBeenCalledOnce();
});
test("addition completion rejection is visible and never retries as new registration", async () => {
const { fetcher } = boundary(true, 401);
render(Account);
await fireEvent.click(
await screen.findByRole("button", { name: "Add passkey to alice" }),
);
await screen.findByText("Auth request failed (401).");
expect(posted(fetcher).map((request) => request.path)).toEqual([
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
});
test("blank handle disables anonymous registration and login", async () => {
boundary(false);
render(Account);
await screen.findByText("No browser session is active.");
await fireEvent.input(
screen.getByLabelText("User handle (new registration or login)"),
{ target: { value: " " } },
);
expect(
(screen.getByRole("button", {
name: "Register new account",
}) as HTMLButtonElement).disabled,
).toBe(true);
expect(
(screen.getByRole("button", {
name: "Log in with passkey",
}) as HTMLButtonElement).disabled,
).toBe(true);
});
test("unknown session never enables registration or addition", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 503 })),
);
render(Account);
await screen.findByText("Auth request failed (503).");
expect(
(screen.getByRole("button", {
name: "Register new account",
}) as HTMLButtonElement).disabled,
).toBe(true);
expect(
(screen.getByLabelText(
"User handle (new registration or login)",
) as HTMLInputElement).disabled,
).toBe(true);
});
@@ -0,0 +1,42 @@
// production shellを既存static fixtureから配信し、Account表示用の境界だけを置換する。
// 実account/session/credentialは作成せず、queryで撮影条件を固定する。
const port = Number(Deno.args[0]);
const upstream = Deno.args[1];
if (!Number.isInteger(port) || !upstream) {
throw new Error("usage: server <port> <upstream>");
}
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const url = new URL(request.url);
if (url.pathname === "/health") return new Response("ok");
if (url.pathname === "/api/auth/whoami") {
return Response.json({
actor:
request.headers.get("cookie")?.includes("fixture-actor=authenticated")
? {
user_id: "fixture-user",
account_id: "fixture-account",
handle: "alice",
display_name: "Alice",
auth_method: "browser_session",
}
: null,
});
}
if (url.pathname === "/api/workspaces") return Response.json([]);
if (url.pathname.startsWith("/api/")) {
return new Response("unexpected fixture API", { status: 404 });
}
const response = await fetch(new URL(url.pathname + url.search, upstream));
const headers = new Headers(response.headers);
if (url.pathname === "/account") {
headers.set(
"set-cookie",
`fixture-actor=${
url.searchParams.get("actor") === "authenticated"
? "authenticated"
: "anonymous"
}; Path=/; SameSite=Strict`,
);
}
return new Response(response.body, { status: response.status, headers });
});
@@ -0,0 +1,76 @@
{
"schemaVersion": 1,
"id": "account-passkeys",
"title": "Existing Account registration and self-only passkey addition",
"baseUrl": "http://127.0.0.1:15371",
"colorScheme": "light",
"reducedMotion": "reduce",
"personas": [
{
"id": "synthetic",
"label": "Synthetic browser session; no real credentials",
"auth": { "kind": "anonymous" }
}
],
"viewports": [
{ "label": "desktop", "width": 1440, "height": 900 },
{ "label": "narrow", "width": 768, "height": 900 },
{ "label": "mobile", "width": 390, "height": 844 }
],
"routes": [
{
"id": "anonymous",
"label": "New registration or explicit-handle login",
"path": "/account",
"goal": "Identify registration as a new-account operation, distinct from logging into an existing handle.",
"dataState": "No browser session; existing Account page.",
"ready": {
"kind": "selector",
"selector": ".account-panel:has-text('No browser session is active.')"
},
"capturePoints": [
{ "id": "initial", "label": "Anonymous Account", "fullPage": true }
]
},
{
"id": "authenticated",
"label": "Add only to current user",
"path": "/account?actor=authenticated",
"goal": "Identify the authenticated actor as the passkey addition target; identity fields cannot retarget addition.",
"dataState": "Synthetic passkey actor alice; existing Account page.",
"ready": { "kind": "selector", "selector": ".account-details" },
"capturePoints": [
{ "id": "initial", "label": "Authenticated Account", "fullPage": true }
]
}
],
"processes": [
{
"id": "static-fixture",
"command": "deno",
"cwd": "../../../../tools/web-ux",
"args": [
"run",
"--allow-net",
"--allow-read",
"browser-tests/workspace_home_fixture_server.ts",
"15372",
"../../web/workspace/build"
],
"readyUrl": "http://127.0.0.1:15372/health"
},
{
"id": "account-fixture",
"command": "deno",
"cwd": "../..",
"args": [
"run",
"--allow-net",
"test/visual-review/account-passkeys-fixture-server.ts",
"15371",
"http://127.0.0.1:15372"
],
"readyUrl": "http://127.0.0.1:15371/health"
}
]
}
+177
View File
@@ -3,10 +3,20 @@ declare const Deno: {
}; };
import { import {
addPasskey,
loadWhoami, loadWhoami,
loginWithPasskey,
readBoundedAuthResponseJson, readBoundedAuthResponseJson,
registerPasskey,
} from "../src/lib/workspace/auth/api.ts"; } from "../src/lib/workspace/auth/api.ts";
import {
authUser,
credentialFixture,
loginOptions,
registrationOptions,
} from "../src/lib/workspace/auth/api.test-fixtures.ts";
function assertEquals(actual: unknown, expected: unknown): void { function assertEquals(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) { if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error( throw new Error(
@@ -97,3 +107,170 @@ Deno.test("bounded auth response reader rejects invalid JSON without echoing con
sensitive, sensitive,
); );
}); });
async function withBrowserBoundary(
fetcher: typeof fetch,
run: (calls: { create: number; get: number }) => Promise<void>,
) {
const descriptors = new Map(
["fetch", "window", "navigator"].map((key) => [
key,
Object.getOwnPropertyDescriptor(globalThis, key),
]),
);
const calls = { create: 0, get: 0 };
Object.defineProperties(globalThis, {
fetch: { configurable: true, writable: true, value: fetcher },
window: {
configurable: true,
value: { location: { origin: "https://yoi.example" } },
},
navigator: {
configurable: true,
value: {
credentials: {
create: (options: CredentialCreationOptions) => {
calls.create++;
assertEquals([
...new Uint8Array(options.publicKey!.challenge as ArrayBuffer),
], [1, 2, 3]);
assertEquals([
...new Uint8Array(options.publicKey!.user.id as ArrayBuffer),
], [4, 5, 6]);
return Promise.resolve(credentialFixture());
},
get: (options: CredentialRequestOptions) => {
calls.get++;
assertEquals([
...new Uint8Array(
options.publicKey!.allowCredentials![0].id as ArrayBuffer,
),
], [7, 8, 9]);
return Promise.resolve(credentialFixture(true));
},
},
},
},
});
try {
await run(calls);
} finally {
for (const [key, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
}
for (const purpose of ["registration", "addition"] as const) {
Deno.test(`${purpose} adapter uses only its own options and complete routes with session credentials`, async () => {
const requests: { path: string; init: RequestInit }[] = [];
await withBrowserBoundary(async (input, init) => {
requests.push({ path: String(input), init: init! });
return Response.json(
requests.length === 1 ? registrationOptions : { user: authUser },
);
}, async (calls) => {
const user = purpose === "registration"
? await registerPasskey("alice", "Alice")
: await addPasskey();
assertEquals(user, authUser);
assertEquals(calls, { create: 1, get: 0 });
});
assertEquals(requests.map(({ path }) => path), [
`/api/auth/passkeys/${purpose}/options`,
`/api/auth/passkeys/${purpose}/complete`,
]);
for (const { init } of requests) {
assertEquals(init.method, "POST");
assertEquals(init.credentials, "same-origin");
assertEquals(init.headers, { "content-type": "application/json" });
}
assertEquals(
JSON.parse(String(requests[0].init.body)),
purpose === "registration"
? {
handle: "alice",
display_name: "Alice",
browser_origin: "https://yoi.example",
}
: { browser_origin: "https://yoi.example" },
);
const complete = JSON.parse(String(requests[1].init.body));
assertEquals(Object.keys(complete).sort(), ["challenge_id", "credential"]);
assertEquals(complete.challenge_id, registrationOptions.challenge_id);
assertEquals(complete.credential.response.attestationObject, "AQID");
assertEquals(complete.credential.response.transports, ["internal"]);
});
}
Deno.test("addition rejects expired-session completion without falling back to registration", async () => {
const paths: string[] = [];
await withBrowserBoundary(async (input) => {
paths.push(String(input));
return paths.length === 1
? Response.json(registrationOptions)
: new Response(null, { status: 401 });
}, async () => {
await assertRejects(addPasskey(), "Auth request failed (401).");
});
assertEquals(paths, [
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
});
Deno.test("existing-handle registration rejection never starts WebAuthn or retries addition", async () => {
const paths: string[] = [];
await withBrowserBoundary(async (input) => {
paths.push(String(input));
return new Response(null, { status: 409 });
}, async (calls) => {
await assertRejects(registerPasskey("alice"), "Auth request failed (409).");
assertEquals(calls, { create: 0, get: 0 });
});
assertEquals(paths, ["/api/auth/passkeys/registration/options"]);
});
Deno.test("login requires a nonempty explicit handle before HTTP or WebAuthn", async () => {
await withBrowserBoundary(() => {
throw new Error("Unexpected HTTP request");
}, async (calls) => {
for (const handle of ["", " ", "\n\t"]) {
await assertRejects(
loginWithPasskey(handle),
"User handle is required for passkey login.",
);
}
assertEquals(calls, { create: 0, get: 0 });
});
});
Deno.test("login sends the explicit trimmed handle and serializes the assertion", async () => {
const requests: { path: string; init: RequestInit }[] = [];
await withBrowserBoundary(async (input, init) => {
requests.push({ path: String(input), init: init! });
return Response.json(
requests.length === 1 ? loginOptions : { user: authUser },
);
}, async (calls) => {
assertEquals(await loginWithPasskey(" alice "), authUser);
assertEquals(calls, { create: 0, get: 1 });
});
assertEquals(requests.map(({ path }) => path), [
"/api/auth/passkeys/login/options",
"/api/auth/passkeys/login/complete",
]);
assertEquals(JSON.parse(String(requests[0].init.body)), {
handle: "alice",
browser_origin: "https://yoi.example",
});
const complete = JSON.parse(String(requests[1].init.body));
assertEquals(complete.challenge_id, loginOptions.challenge_id);
assertEquals(complete.credential.response.signature, "AQID");
assertEquals(complete.credential.response.userHandle, null);
assertEquals(requests.map(({ init }) => init.credentials), [
"same-origin",
"same-origin",
]);
});