diff --git a/tests/e2e/tests/auth_passkeys.rs b/tests/e2e/tests/auth_passkeys.rs index 0482caa6a..a7e88be29 100644 --- a/tests/e2e/tests/auth_passkeys.rs +++ b/tests/e2e/tests/auth_passkeys.rs @@ -18,8 +18,9 @@ use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::{Value, json}; use server_api::{ - AuthUserResponse, AuthenticatedUser, PasskeyLoginCompleteRequest, PasskeyLoginOptionsResponse, - PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsResponse, + ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyLoginCompleteRequest, + PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest, + PasskeyRegistrationOptionsResponse, WhoamiResponse, }; use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; use yoi_e2e::WorkerE2eEnvironment; @@ -211,6 +212,26 @@ fn access(client: &Client, base: &str, artifacts: &Path, cookie: &str, is_admin: Ok(()) } +fn browser_identity( + client: &Client, + base: &str, + artifacts: &Path, + cookie: &str, + user: &AuthenticatedUser, +) -> Result { + let whoami: WhoamiResponse = decode(send( + client + .get(format!("{base}/api/auth/whoami")) + .header(COOKIE, cookie), + "browser session rereads its own identity", + artifacts, + )?)?; + let actor = whoami.actor.ok_or("browser session is anonymous")?; + assert_eq!(actor.user(), *user); + assert_eq!(actor.auth_method, ActorAuthMethod::BrowserSession); + Ok(()) +} + fn logout(client: &Client, base: &str, artifacts: &Path, cookie: &str) -> Result { let response = success(send( client @@ -357,14 +378,15 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow )?; let mut original_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); - let (registered, _, user_handle) = register( + let (registered, registration_cookie, user_handle) = register( &client, base, artifacts, &environment.account_handle, &mut original_key, )?; - // registrationのcookieは使わず、実assertionで得たlogin cookieを次の変更操作に使う。 + // 登録sessionを失効させ、実assertionで得たlogin cookieだけで本人追加へ進む。 + logout(&client, base, artifacts, ®istration_cookie)?; let (logged_in, cookie) = login( &client, base, @@ -373,14 +395,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow &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); + browser_identity(&client, base, artifacts, &cookie, ®istered.user)?; let workspaces: Value = decode(send( client .get(format!("{base}/api/workspaces")) @@ -534,7 +549,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow "unknown field", )?; } - let (added, _) = session(send( + let (added, added_cookie) = session(send( client .post(format!("{base}/api/auth/passkeys/addition/complete")) .header(COOKIE, &cookie) @@ -544,6 +559,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow artifacts, )?)?; assert_eq!(added.user, registered.user); + browser_identity(&client, base, artifacts, &added_cookie, ®istered.user)?; rejected( send( client @@ -558,6 +574,24 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow "invalid_passkey_challenge", )?; + // cookieを消すだけでなく、追加前後のsessionがServerでも失効したことを確かめる。 + logout(&client, base, artifacts, &cookie)?; + logout(&client, base, artifacts, &added_cookie)?; + for revoked_cookie in [&cookie, &added_cookie] { + rejected( + send( + client + .post(format!("{base}/api/auth/passkeys/addition/options")) + .header(COOKIE, revoked_cookie) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"browser_origin": BROWSER_ORIGIN})), + "logout prevents further self addition with the old cookie", + artifacts, + )?, + StatusCode::FORBIDDEN, + "auth_required", + )?; + } let (new_login, new_cookie) = login( &client, base, @@ -566,7 +600,8 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow &mut added_key, )?; assert_eq!(new_login.user, registered.user); - let (old_login, _) = login( + browser_identity(&client, base, artifacts, &new_cookie, ®istered.user)?; + let (old_login, old_cookie) = login( &client, base, artifacts, @@ -577,6 +612,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow old_login.user, registered.user, "adding a key must preserve the original key" ); + logout(&client, base, artifacts, &old_cookie)?; let created: Value = decode(send( client .post(format!("{base}/api/workspaces")) @@ -640,18 +676,32 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow "owner", )?; + logout(&client, base, artifacts, &new_cookie)?; let report = environment.cleanup()?; assert!(report.cleanup_success); assert!(!report.fixture_root_exists_after); Ok(()) } -fn ordinary_device_login_does_not_grant_hosting_authority( +fn device_login_pending(client: &Client, base: &str, artifacts: &Path, flow: &Value) -> Result { + let pending: Value = decode(send( + client + .post(format!("{base}/api/auth/device-login/poll")) + .json(&json!({"device_code": flow["device_code"]})), + "device flow remains pending without explicit approval", + artifacts, + )?)?; + assert_eq!(pending["status"], "pending"); + assert!(pending.get("access_token").is_none_or(Value::is_null)); + Ok(()) +} + +fn ordinary_device_login_requires_explicit_approval_and_does_not_grant_hosting_authority( client: &Client, base: &str, artifacts: &Path, user: &AuthUserResponse, - cookie: &str, + key: &mut WebauthnAuthenticator, ) -> Result { let flow: Value = decode(send( client @@ -673,10 +723,16 @@ fn ordinary_device_login_does_not_grant_hosting_authority( StatusCode::UNAUTHORIZED, "request requires", )?; + device_login_pending(client, base, artifacts, &flow)?; + // login APIの完了では認証だけが成立し、CLIへの権限付与はまだない。 + let (logged_in, cookie) = login(client, base, artifacts, &user.user.handle, key)?; + assert_eq!(logged_in.user, user.user); + browser_identity(client, base, artifacts, &cookie, &user.user)?; + device_login_pending(client, base, artifacts, &flow)?; for origin in [None, Some("http://localhost:5174")] { let mut request = client .post(format!("{base}/api/auth/device-login/approve")) - .header(COOKIE, cookie) + .header(COOKIE, &cookie) .json(&approval); if let Some(origin) = origin { request = request.header(ORIGIN, origin); @@ -691,10 +747,11 @@ fn ordinary_device_login_does_not_grant_hosting_authority( "origin", )?; } + device_login_pending(client, base, artifacts, &flow)?; let approved: Value = decode(send( client .post(format!("{base}/api/auth/device-login/approve")) - .header(COOKIE, cookie) + .header(COOKIE, &cookie) .header(ORIGIN, BROWSER_ORIGIN) .json(&approval), "existing ordinary user approves device login while registration disabled", @@ -713,6 +770,16 @@ fn ordinary_device_login_does_not_grant_hosting_authority( let token = issued["access_token"] .as_str() .ok_or("device poll omitted bearer")?; + let whoami: WhoamiResponse = decode(send( + client + .get(format!("{base}/api/auth/whoami")) + .bearer_auth(token), + "explicit device approval issues the logged-in user's bearer", + artifacts, + )?)?; + let actor = whoami.actor.ok_or("approved device bearer is anonymous")?; + assert_eq!(actor.user(), user.user); + assert_eq!(actor.auth_method, ActorAuthMethod::ApiToken); let access: Value = decode(send( client .get(format!("{base}/api/hosting/access")) @@ -743,6 +810,7 @@ fn ordinary_device_login_does_not_grant_hosting_authority( )?)?; assert_eq!(consumed["status"], "consumed"); assert!(consumed.get("access_token").is_none_or(Value::is_null)); + logout(client, base, artifacts, &cookie)?; policy(client, base, artifacts, false) } @@ -974,18 +1042,13 @@ fn hosting_policy_rejects_spoofing_inflight_registration_and_survives_restart() StatusCode::FORBIDDEN, "self_registration_disabled", )?; - let (logged_in, cookie) = login( + ordinary_device_login_requires_explicit_approval_and_does_not_grant_hosting_authority( &client, &base, &artifacts, - &ordinary.user.handle, + &ordinary, &mut ordinary_key, )?; - assert_eq!(logged_in.user, ordinary.user); - access(&client, &base, &artifacts, &cookie, false)?; - ordinary_device_login_does_not_grant_hosting_authority( - &client, &base, &artifacts, &logged_in, &cookie, - )?; let (_, cookie) = login( &client, &base, @@ -1528,6 +1591,18 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp artifacts, )?)?; for bearer in [None, Some(issue.code.as_str()), Some(challenge_id.as_str())] { + let mut request = client.get(format!("{base}/api/auth/whoami")); + if let Some(bearer) = bearer { + request = request.bearer_auth(bearer); + } + let response = activation_success(send( + request, + "activation code and challenge leave whoami anonymous", + artifacts, + )?)?; + assert!(response.headers().get(SET_COOKIE).is_none()); + let whoami: WhoamiResponse = response.json()?; + assert!(whoami.actor.is_none()); let requests = [ client.get(format!("{base}/api/hosting/settings")), client.post(format!("{base}/api/workspaces")).header(ORIGIN, BROWSER_ORIGIN) @@ -1550,15 +1625,7 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp )?)?; } } - let polled: Value = decode(send( - client - .post(format!("{base}/api/auth/device-login/poll")) - .json(&json!({"device_code": flow["device_code"]})), - "failed pending-user approvals did not authorize device", - artifacts, - )?)?; - assert_eq!(polled["status"], "pending"); - assert!(polled.get("access_token").is_none_or(Value::is_null)); + device_login_pending(&client, base, artifacts, &flow)?; // completionはchallengeのtargetを使い、本文で別accountへ切り替えられない。 for field in ["handle", "account_id", "user_id"] { diff --git a/tools/web-ux/scenarios/hosting-settings-dark.json b/tools/web-ux/scenarios/hosting-settings-dark.json index f6eafa34e..2b71d9110 100644 --- a/tools/web-ux/scenarios/hosting-settings-dark.json +++ b/tools/web-ux/scenarios/hosting-settings-dark.json @@ -274,30 +274,12 @@ } ] }, - { - "id": "account-on", - "label": "account-on", - "path": "/account?fixture=account-on", - "goal": "Verify production shell UI projection, not server authorization.", - "dataState": "Anonymous policy on; registration and login available.", - "ready": { - "kind": "selector", - "selector": "button:has-text(\"Register new account\"):enabled" - }, - "capturePoints": [ - { - "id": "initial", - "label": "Initial state", - "fullPage": true - } - ] - }, { "id": "account-off", "label": "account-off", - "path": "/account?fixture=account-off", + "path": "/register?fixture=account-off", "goal": "Verify production shell UI projection, not server authorization.", - "dataState": "Anonymous policy off; no registration action, login available.", + "dataState": "Anonymous policy off; registration disabled and existing login link available.", "ready": { "kind": "selector", "selector": "p:has-text(\"New account registration is disabled.\")" @@ -315,7 +297,7 @@ "label": "admin-account", "path": "/account?fixture=admin-account", "goal": "Verify production shell UI projection, not server authorization.", - "dataState": "Authenticated admin; own passkey addition and global settings link.", + "dataState": "Authenticated own account; addition available independently of registration policy. Global navigation remains on Workspace shell.", "ready": { "kind": "selector", "selector": "button:has-text(\"Add passkey to alice\")" @@ -333,7 +315,7 @@ "label": "ordinary-account-off", "path": "/account?fixture=ordinary-account-off", "goal": "Verify production shell UI projection, not server authorization.", - "dataState": "Authenticated ordinary user with policy off; own passkey addition available without admin nav.", + "dataState": "Authenticated own account; addition available independently of registration policy. Global navigation remains on Workspace shell.", "ready": { "kind": "selector", "selector": "button:has-text(\"Add passkey to alice\")" @@ -349,7 +331,7 @@ { "id": "account-error", "label": "account-error", - "path": "/account?fixture=account-error", + "path": "/register?fixture=account-error", "goal": "Verify production shell UI projection, not server authorization.", "dataState": "Intentional policy HTTP503; expected console resource error and visible alert; login retained.", "ready": { @@ -367,7 +349,7 @@ { "id": "account-loading", "label": "account-loading", - "path": "/account?fixture=account-loading", + "path": "/register?fixture=account-loading", "goal": "Verify production shell UI projection, not server authorization.", "dataState": "Policy request held pending; login remains available after session loads.", "ready": { diff --git a/tools/web-ux/scenarios/hosting-settings-light.json b/tools/web-ux/scenarios/hosting-settings-light.json index a7a52630e..af1604ef5 100644 --- a/tools/web-ux/scenarios/hosting-settings-light.json +++ b/tools/web-ux/scenarios/hosting-settings-light.json @@ -274,30 +274,12 @@ } ] }, - { - "id": "account-on", - "label": "account-on", - "path": "/account?fixture=account-on", - "goal": "Verify production shell UI projection, not server authorization.", - "dataState": "Anonymous policy on; registration and login available.", - "ready": { - "kind": "selector", - "selector": "button:has-text(\"Register new account\"):enabled" - }, - "capturePoints": [ - { - "id": "initial", - "label": "Initial state", - "fullPage": true - } - ] - }, { "id": "account-off", "label": "account-off", - "path": "/account?fixture=account-off", + "path": "/register?fixture=account-off", "goal": "Verify production shell UI projection, not server authorization.", - "dataState": "Anonymous policy off; no registration action, login available.", + "dataState": "Anonymous policy off; registration disabled and existing login link available.", "ready": { "kind": "selector", "selector": "p:has-text(\"New account registration is disabled.\")" @@ -315,7 +297,7 @@ "label": "admin-account", "path": "/account?fixture=admin-account", "goal": "Verify production shell UI projection, not server authorization.", - "dataState": "Authenticated admin; own passkey addition and global settings link.", + "dataState": "Authenticated own account; addition available independently of registration policy. Global navigation remains on Workspace shell.", "ready": { "kind": "selector", "selector": "button:has-text(\"Add passkey to alice\")" @@ -333,7 +315,7 @@ "label": "ordinary-account-off", "path": "/account?fixture=ordinary-account-off", "goal": "Verify production shell UI projection, not server authorization.", - "dataState": "Authenticated ordinary user with policy off; own passkey addition available without admin nav.", + "dataState": "Authenticated own account; addition available independently of registration policy. Global navigation remains on Workspace shell.", "ready": { "kind": "selector", "selector": "button:has-text(\"Add passkey to alice\")" @@ -349,7 +331,7 @@ { "id": "account-error", "label": "account-error", - "path": "/account?fixture=account-error", + "path": "/register?fixture=account-error", "goal": "Verify production shell UI projection, not server authorization.", "dataState": "Intentional policy HTTP503; expected console resource error and visible alert; login retained.", "ready": { @@ -367,7 +349,7 @@ { "id": "account-loading", "label": "account-loading", - "path": "/account?fixture=account-loading", + "path": "/register?fixture=account-loading", "goal": "Verify production shell UI projection, not server authorization.", "dataState": "Policy request held pending; login remains available after session loads.", "ready": { diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 04e381b19..d7623e425 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts", "test:browser:hosting": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/hosting-browser/hosting-shell.browser.test.ts test/hosting-browser/setup-shell.browser.test.ts", @@ -18,7 +18,8 @@ "generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types", "check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check", "generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types", - "check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check" + "check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check", + "test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts" }, "imports": { "@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@4.0.0", diff --git a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte new file mode 100644 index 000000000..cac9c86f0 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte @@ -0,0 +1,114 @@ + + +{mode === 'login' ? 'Log in' : 'Create account'} · Yoi +
+

{mode === 'login' ? 'Log in' : 'Create account'}

+ {#if auth.session.kind === 'loading'} +

Loading session…

+ {:else if auth.session.kind === 'error'} + + + {:else if auth.actor} +

Logged in as {auth.actor.display_name} @{auth.actor.handle}.

+ Continue to {destination === '/' ? 'dashboard / Workspaces' : 'requested page'} + Open Account + {:else} + {#if mode === 'register'} + {#if policyLoading} +

Checking registration availability…

+ {:else if policyError} + + + {:else if policy === false} +

New account registration is disabled. Existing users can still log in.

+ {/if} + {/if} + {#if mode === 'login' || policy === true} +
{ event.preventDefault(); void submit(); }}> + +

{mode === 'login' ? 'Use your account ID, not your display name.' : 'Your unique sign-in ID. This is separate from your display name.'}

+ {#if mode === 'register'} + + {/if} + +
+ {/if} + {/if} + {#if auth.status}

{auth.status}

{/if} + {#if auth.error}{/if} + +
diff --git a/web/workspace/src/lib/workspace/auth/api.ts b/web/workspace/src/lib/workspace/auth/api.ts index 8f09b944a..f796031c4 100644 --- a/web/workspace/src/lib/workspace/auth/api.ts +++ b/web/workspace/src/lib/workspace/auth/api.ts @@ -20,6 +20,12 @@ import { type WhoamiResponse, } from "#lib/workspace/auth/model.ts"; +export class AuthRequestError extends Error { + constructor(readonly status: number) { + super(`Auth request failed (${status}).`); + } +} + const MAX_AUTH_RESPONSE_BYTES = 256 * 1024; export async function readBoundedAuthResponseJson( @@ -80,6 +86,9 @@ export async function readBoundedAuthResponseJson( async function requestJson(path: string, init?: RequestInit): Promise { const response = await fetch(path, { credentials: "same-origin", + mode: "same-origin", + redirect: "error", + cache: "no-store", ...init, headers: { "content-type": "application/json", @@ -88,20 +97,23 @@ async function requestJson(path: string, init?: RequestInit): Promise { }); if (!response.ok) { await response.body?.cancel(); - throw new Error(`Auth request failed (${response.status}).`); + throw new AuthRequestError(response.status); } return await readBoundedAuthResponseJson(response); } -export async function loadWhoami(): Promise { - return parseWhoamiResponse(await requestJson("/api/auth/whoami")); +export async function loadWhoami( + signal?: AbortSignal, +): Promise { + return parseWhoamiResponse(await requestJson("/api/auth/whoami", { signal })); } -export async function logout(): Promise { +export async function logout(signal?: AbortSignal): Promise { parseLogoutResponse( await requestJson("/api/auth/logout", { method: "POST", body: "{}", + signal, }), ); } @@ -109,20 +121,21 @@ export async function logout(): Promise { export async function registerPasskey( handle: string, displayName?: string, + signal?: AbortSignal, ): Promise { const optionsRequest: PasskeyRegistrationOptionsRequest = { handle, display_name: displayName ?? null, browser_origin: window.location.origin, }; - return await createPasskey("registration", optionsRequest); + return await createPasskey("registration", optionsRequest, signal); } -export async function addPasskey(): Promise { +export async function addPasskey(signal?: AbortSignal): Promise { const optionsRequest: PasskeyAdditionOptionsRequest = { browser_origin: window.location.origin, }; - return await createPasskey("addition", optionsRequest); + return await createPasskey("addition", optionsRequest, signal); } async function createPasskey( @@ -130,10 +143,12 @@ async function createPasskey( optionsRequest: | PasskeyRegistrationOptionsRequest | PasskeyAdditionOptionsRequest, + signal?: AbortSignal, ): Promise { const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, { method: "POST", body: JSON.stringify(optionsRequest), + signal, }); const optionsRecord = typeof options === "object" && options !== null ? options as Record @@ -144,11 +159,16 @@ async function createPasskey( "Invalid auth payload: registration_options.challenge_id is required.", ); } + signal?.throwIfAborted(); const credential = await navigator.credentials.create({ + signal, publicKey: prepareRegistrationOptions(options), }); - if (!credential) throw new Error("Passkey registration was cancelled"); + if (!credential) { + throw new DOMException("Passkey cancelled", "NotAllowedError"); + } + signal?.throwIfAborted(); const completeRequest: PasskeyRegistrationCompleteRequest = { challenge_id: challengeId, credential: registrationCredentialToJson(credential), @@ -157,12 +177,16 @@ async function createPasskey( await requestJson(`/api/auth/passkeys/${purpose}/complete`, { method: "POST", body: JSON.stringify(completeRequest), + signal, }), ); return result.user; } -export async function loginWithPasskey(handle: string): Promise { +export async function loginWithPasskey( + handle: string, + signal?: AbortSignal, +): Promise { const loginHandle = handle.trim(); if (!loginHandle) { throw new Error("User handle is required for passkey login."); @@ -174,6 +198,7 @@ export async function loginWithPasskey(handle: string): Promise { const options = await requestJson("/api/auth/passkeys/login/options", { method: "POST", body: JSON.stringify(optionsRequest), + signal, }); const optionsRecord = typeof options === "object" && options !== null ? options as Record @@ -184,11 +209,16 @@ export async function loginWithPasskey(handle: string): Promise { "Invalid auth payload: login_options.challenge_id is required.", ); } + signal?.throwIfAborted(); const credential = await navigator.credentials.get({ + signal, publicKey: prepareLoginOptions(options), }); - if (!credential) throw new Error("Passkey login was cancelled"); + if (!credential) { + throw new DOMException("Passkey cancelled", "NotAllowedError"); + } + signal?.throwIfAborted(); const completeRequest: PasskeyLoginCompleteRequest = { challenge_id: challengeId, credential: authenticationCredentialToJson(credential), @@ -197,6 +227,7 @@ export async function loginWithPasskey(handle: string): Promise { await requestJson("/api/auth/passkeys/login/complete", { method: "POST", body: JSON.stringify(completeRequest), + signal, }), ); return result.user; @@ -204,12 +235,14 @@ export async function loginWithPasskey(handle: string): Promise { export async function approveDeviceLogin( userCode: string, + signal?: AbortSignal, ): Promise { const request: DeviceLoginApproveRequest = { user_code: userCode }; return parseDeviceApprovalResponse( await requestJson("/api/auth/device-login/approve", { method: "POST", body: JSON.stringify(request), + signal, }), ); } diff --git a/web/workspace/src/lib/workspace/auth/auth-page.css b/web/workspace/src/lib/workspace/auth/auth-page.css new file mode 100644 index 000000000..c857db5f8 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/auth-page.css @@ -0,0 +1,21 @@ +.auth-page { display: grid; align-content: start; gap: var(--space-5); min-width: 0; } +.auth-page h1 { margin: 0; font-size: var(--font-size-title); line-height: var(--line-height-title); } +.auth-page h2 { margin: 0; font-size: var(--font-size-body); } +.auth-page p { margin: 0; } +.auth-page form, .auth-page section, .auth-page nav { display: grid; gap: var(--space-4); min-width: 0; } +.auth-page label { display: grid; gap: var(--space-2); font-weight: 700; } +.auth-page input { width: 100%; min-width: 0; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: var(--bg); color: var(--text-strong); font: inherit; } +.auth-page button { justify-self: start; min-height: 2.25rem; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: transparent; color: var(--text-strong); font: inherit; cursor: pointer; } +.auth-page button.primary { background: var(--text-strong); color: var(--bg); } +.auth-page button:hover:not(:disabled) { background: var(--interactive-hover); color: var(--text-strong); } +.auth-page button:disabled { opacity: .55; cursor: not-allowed; } +.auth-page a { color: var(--text-strong); text-decoration: underline; text-underline-offset: .15em; overflow-wrap: anywhere; } +.auth-page a:hover, .auth-page a:active { color: var(--text); } +.auth-page :is(a, button, input):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.auth-page .muted { color: var(--text-muted); } +.auth-page .error { color: var(--danger); } +.auth-page .actions { display: flex; flex-wrap: wrap; gap: var(--space-3); } +.auth-page dl { display: grid; gap: var(--space-4); margin: 0; } +.auth-page dl div { display: grid; gap: var(--space-1); min-width: 0; } +.auth-page dt { color: var(--text-muted); } +.auth-page dd { margin: 0; overflow-wrap: anywhere; } diff --git a/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts b/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts new file mode 100644 index 000000000..50add1a12 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts @@ -0,0 +1,104 @@ +import { AuthRequestError, loadWhoami } from "./api.ts"; +import { notifySessionChanged } from "./hosting.ts"; +import type { RequestActor } from "./model.ts"; + +export type BrowserSession = + | { kind: "loading" } + | { kind: "error" } + | { kind: "ready"; actor: RequestActor | null }; + +export function authErrorMessage(error: unknown): string { + if (error instanceof AuthRequestError) { + if (error.status === 401) { + return "Your session or authentication challenge expired. Log in again or restart the Passkey operation."; + } + if (error.status === 400 || error.status === 410) { + return "The request or authentication challenge is invalid or expired. Restart the operation."; + } + return error.message; + } + if (error instanceof DOMException && error.name === "NotAllowedError") { + return "Passkey operation was cancelled or timed out. You can try again."; + } + if (error instanceof DOMException && error.name === "AbortError") { + return "Operation stopped. Completion is not confirmed; check your session before retrying."; + } + // Browser/transport例外の本文を表示せず、credentialや外部応答を診断へ複写しない。 + return "Authentication could not be confirmed. Check your session or try Passkey login before retrying registration."; +} + +export class BrowserAuth { + session = $state({ kind: "loading" }); + busy = $state(false); + error = $state(""); + status = $state(""); + private request = 0; + private disposed = false; + private controller: AbortController | null = null; + + get actor(): RequestActor | null { + return this.session.kind === "ready" ? this.session.actor : null; + } + + async refresh(): Promise { + if (this.busy || this.disposed) return; + await this.readSession(); + } + + private async readSession(): Promise { + const current = ++this.request; + this.session = { kind: "loading" }; + try { + const result = await loadWhoami(); + if (this.disposed || current !== this.request) return; + this.session = { kind: "ready", actor: result.actor }; + } catch { + if (this.disposed || current !== this.request) return; + this.session = { kind: "error" }; + } + } + + async run( + pending: string, + success: string, + operation: (signal: AbortSignal) => Promise, + ): Promise { + if (this.busy || this.disposed || this.session.kind !== "ready") { + return false; + } + this.busy = true; + ++this.request; + const controller = new AbortController(); + this.controller = controller; + this.error = ""; + this.status = pending; + try { + await operation(controller.signal); + if (this.disposed) return false; + notifySessionChanged(); + await this.readSession(); + if (this.disposed) return false; + const confirmed = this.session.kind === "ready"; + this.status = confirmed ? success : ""; + return confirmed; + } catch (error) { + if (this.disposed) return false; + this.error = authErrorMessage(error); + this.status = ""; + // 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。 + await this.readSession(); + return false; + } finally { + if (!this.disposed) { + this.busy = false; + this.controller = null; + } + } + } + + dispose(): void { + this.disposed = true; + ++this.request; + this.controller?.abort(); + } +} diff --git a/web/workspace/src/lib/workspace/auth/routes.test.ts b/web/workspace/src/lib/workspace/auth/routes.test.ts new file mode 100644 index 000000000..79607ed72 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/routes.test.ts @@ -0,0 +1,84 @@ +declare const Deno: { test(name: string, run: () => void): void }; +function assertEquals( + actual: string, + expected: string, + context?: string, +): void { + if (actual !== expected) { + throw new Error( + `${context ?? "return destination"}: expected ${expected}, got ${actual}`, + ); + } +} +import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts"; + +Deno.test("return destination permits known app screens without forwarding arbitrary query or fragment", () => { + for ( + const [input, expected] of [ + ["/", "/"], + ["/account", "/account"], + ["/account?returnTo=//evil.invalid#secret", "/account"], + [ + "/w/workspace-id/tickets/T-123?filter=all#entry", + "/w/workspace-id/tickets/T-123", + ], + [ + "/login/device?user_code=ABCD-EFGH", + "/login/device?user_code=ABCD-EFGH", + ], + [ + "/login/device?code=abcd-1234&returnTo=//evil.invalid", + "/login/device?user_code=abcd-1234", + ], + ["/login/device?user_code=%252f%252fevil.invalid", "/login/device"], + ] + ) assertEquals(safeReturnTo(input), expected, input); +}); + +Deno.test("return destination rejects external, ambiguous encoding and authentication loops", () => { + for ( + const input of [ + null, + "", + "https://evil.invalid", + "//evil.invalid", + "///evil.invalid", + "javascript:alert(1)", + "account", + "/\\evil.invalid", + "/%2f%2fevil.invalid", + "/%252f%252fevil.invalid", + "/%61ccount", + "/login", + "/register", + "/activate", + "/login/", + "/login?returnTo=/account", + "/login/device/", + "/login/../account", + "/w/a/../../login", + "/w/a//evil", + "/w/a/./b", + "/account\n", + "/account\t", + "/account\u0000", + "/account%00", + "/api/auth/logout", + "/hosting/setup", + "/not-an-app-route", + "/" + "a".repeat(2049), + ] + ) assertEquals(safeReturnTo(input), "/", String(input)); +}); + +Deno.test("login links encode one validated destination and discard nested auth loops", () => { + assertEquals( + loginUrl("/login/device?user_code=ABCD-EFGH"), + "/login?returnTo=%2Flogin%2Fdevice%3Fuser_code%3DABCD-EFGH", + ); + assertEquals( + authEntryUrl("/register", "/account"), + "/register?returnTo=%2Faccount", + ); + assertEquals(loginUrl("/login?returnTo=/login"), "/login?returnTo=%2F"); +}); diff --git a/web/workspace/src/lib/workspace/auth/routes.ts b/web/workspace/src/lib/workspace/auth/routes.ts new file mode 100644 index 000000000..d6c9c2d50 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/routes.ts @@ -0,0 +1,59 @@ +// T760が公開トップ/Workspace選択を整理するまで、現行の一覧をdashboardへの入口とする。 +// 戻り先は既知のアプリ画面だけ。認証経路への再帰やdecode段階の差による別originを作らない。 +export const AUTHENTICATED_HOME = "/"; + +export function safeReturnTo(value: string | null): string { + if ( + !value || value.length > 2048 || !value.startsWith("/") || + /[\\\s\u0000-\u001f\u007f]/u.test(value) || value.startsWith("//") + ) { + return AUTHENTICATED_HOME; + } + const rawPath = value.split(/[?#]/, 1)[0]; + if ( + rawPath.includes("%") || rawPath.includes("//") || + rawPath.split("/").some((part) => part === "." || part === "..") + ) { + return AUTHENTICATED_HOME; + } + let url: URL; + try { + url = new URL(value, "https://app.invalid"); + } catch { + return AUTHENTICATED_HOME; + } + if (url.origin !== "https://app.invalid") return AUTHENTICATED_HOME; + if (url.pathname === "/login/device") { + const code = url.searchParams.get("user_code") ?? + url.searchParams.get("code"); + if (code && /^[a-zA-Z0-9-]{1,64}$/.test(code)) { + return `/login/device?user_code=${encodeURIComponent(code)}`; + } + return "/login/device"; + } + if ( + url.pathname === "/" || url.pathname === "/account" || + /^\/w\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_/-]*)?$/.test(url.pathname) + ) { + return url.pathname; + } + return AUTHENTICATED_HOME; +} + +export function loginUrl(returnTo: string): string { + return `/login?${new URLSearchParams({ returnTo: safeReturnTo(returnTo) })}`; +} + +export function authEntryUrl( + route: "/login" | "/register", + returnTo: string, +): string { + return `${route}?${new URLSearchParams({ + returnTo: safeReturnTo(returnTo), + })}`; +} + +export function isAuthPage(path: string): boolean { + return ["/login", "/register", "/account", "/activate", "/login/device"] + .includes(path); +} diff --git a/web/workspace/src/lib/workspace/auth/session.browser.spec.ts b/web/workspace/src/lib/workspace/auth/session.browser.spec.ts new file mode 100644 index 000000000..a66581887 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/session.browser.spec.ts @@ -0,0 +1,467 @@ +// @vitest-environment happy-dom +import { afterEach, expect, test, vi } from "vitest"; +import { BrowserAuth } from "./browser-session.svelte.ts"; +import { addPasskey, loginWithPasskey, logout } from "./api.ts"; +import { SESSION_CHANGED_EVENT } from "./hosting.ts"; +import { + authUser, + credentialFixture, + loginOptions, + registrationOptions, +} from "./api.test-fixtures.ts"; + +const sessions: BrowserAuth[] = []; +afterEach(() => { + for (const auth of sessions.splice(0)) auth.dispose(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function gate() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function session() { + const auth = new BrowserAuth(); + sessions.push(auth); + return auth; +} + +function whoami(authenticated: boolean) { + return Response.json({ + actor: authenticated + ? { ...authUser, auth_method: "browser_session" } + : null, + }); +} + +// BrowserAuth→本番HTTP/WebAuthn adapterの状態遷移を検証する。fetch/credentialだけを代替し、 +// 遅延応答は明示的なgateで順序を決める。実Backendの認証・永続化を保証する試験ではない。 +function boundary(authenticated = false) { + const state = { authenticated, completeStatus: 200 }; + const create = vi.fn() + .mockResolvedValue(credentialFixture()); + const get = vi.fn() + .mockResolvedValue(credentialFixture(true)); + vi.stubGlobal("navigator", { credentials: { create, get } }); + const fetcher = vi.fn().mockImplementation(async (input) => { + const path = String(input); + if (path === "/api/auth/whoami") return whoami(state.authenticated); + if (path === "/api/auth/logout") { + state.authenticated = false; + return Response.json({ status: "logged_out" }); + } + if (path === "/api/auth/passkeys/login/options") { + return Response.json(loginOptions); + } + if (path === "/api/auth/passkeys/addition/options") { + return Response.json(registrationOptions); + } + if ( + path === "/api/auth/passkeys/login/complete" || + path === "/api/auth/passkeys/addition/complete" + ) { + if (state.completeStatus !== 200) { + if (state.completeStatus === 401) state.authenticated = false; + return new Response(null, { status: state.completeStatus }); + } + state.authenticated = true; + return Response.json({ user: authUser }); + } + throw new Error(`Unexpected API: ${path}`); + }); + vi.stubGlobal("fetch", fetcher); + return { fetcher, create, get, state }; +} + +function paths(fetcher: ReturnType["fetcher"]) { + return fetcher.mock.calls.map(([path]) => String(path)); +} + +test("loading and failed whoami are not anonymous readiness and cannot start authentication", async () => { + const { fetcher } = boundary(); + const response = gate(); + fetcher.mockReturnValueOnce(response.promise); + const auth = session(); + const operation = vi.fn(async () => {}); + expect(auth.session.kind).toBe("loading"); + const refresh = auth.refresh(); + expect(auth.actor).toBeNull(); + expect(await auth.run("pending", "done", operation)).toBe(false); + response.resolve(new Response(null, { status: 503 })); + await refresh; + expect(auth.session).toEqual({ kind: "error" }); + expect(await auth.run("pending", "done", operation)).toBe(false); + expect(operation).not.toHaveBeenCalled(); + await auth.refresh(); + expect(auth.session).toEqual({ kind: "ready", actor: null }); +}); + +for ( + const [olderStatus, latestStatus, latestAuthenticated] of [ + [200, 200, false], + [200, 503, false], + [503, 200, true], + ] as const +) { + test(`latest whoami (${latestStatus}, actor=${latestAuthenticated}) wins over a delayed ${olderStatus} response`, async () => { + const { fetcher } = boundary(); + const older = gate(); + const latest = gate(); + fetcher.mockReturnValueOnce(older.promise).mockReturnValueOnce( + latest.promise, + ); + const auth = session(); + const first = auth.refresh(); + const second = auth.refresh(); + latest.resolve( + latestStatus === 200 + ? whoami(latestAuthenticated) + : new Response(null, { status: latestStatus }), + ); + await second; + expect(auth.session.kind).toBe(latestStatus === 200 ? "ready" : "error"); + expect(auth.actor?.handle ?? null).toBe( + latestAuthenticated ? "alice" : null, + ); + const accepted = auth.session; + older.resolve( + olderStatus === 200 + ? whoami(true) + : new Response(null, { status: olderStatus }), + ); + await first; + expect(auth.session).toBe(accepted); + }); +} + +test("a delayed pre-logout whoami cannot resurrect the previous actor", async () => { + const { fetcher } = boundary(true); + const auth = session(); + await auth.refresh(); + const oldResponse = gate(); + fetcher.mockReturnValueOnce(oldResponse.promise); + const oldRefresh = auth.refresh(); + await auth.refresh(); + expect(auth.actor?.handle).toBe("alice"); + expect(await auth.run("Logging out", "Logged out", logout)).toBe(true); + expect(auth.session).toEqual({ kind: "ready", actor: null }); + oldResponse.resolve(whoami(true)); + await oldRefresh; + expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.status).toBe("Logged out"); +}); + +test("busy login rejects duplicate operations and refresh until its post-completion whoami settles", async () => { + const { fetcher } = boundary(); + const auth = session(); + await auth.refresh(); + const options = gate(); + const confirming = gate(); + const confirmingStarted = gate(); + const request = fetcher.getMockImplementation()!; + fetcher.mockImplementation((input, init) => { + if (String(input) === "/api/auth/whoami") { + confirmingStarted.resolve(); + return confirming.promise; + } + return request(input, init); + }); + fetcher.mockReturnValueOnce(options.promise); + const run = auth.run( + "Waiting", + "Logged in", + (signal) => loginWithPasskey("alice", signal), + ); + expect(auth.busy).toBe(true); + expect(auth.status).toBe("Waiting"); + const duplicate = vi.fn(async () => {}); + expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false); + await auth.refresh(); + expect(duplicate).not.toHaveBeenCalled(); + expect(paths(fetcher)).toEqual([ + "/api/auth/whoami", + "/api/auth/passkeys/login/options", + ]); + options.resolve(Response.json(loginOptions)); + await confirmingStarted.promise; + expect(auth.busy).toBe(true); + expect(auth.session.kind).toBe("loading"); + expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false); + await auth.refresh(); + expect(paths(fetcher).filter((path) => path === "/api/auth/whoami")) + .toHaveLength(2); + confirming.resolve(whoami(true)); + expect(await run).toBe(true); + expect(auth.busy).toBe(false); + expect(auth.actor?.handle).toBe("alice"); + expect(auth.status).toBe("Logged in"); + expect(paths(fetcher)).toEqual([ + "/api/auth/whoami", + "/api/auth/passkeys/login/options", + "/api/auth/passkeys/login/complete", + "/api/auth/whoami", + ]); +}); + +for (const status of [400, 401, 410]) { + test(`addition completion ${status} returns failure, clears success and rechecks the session`, async () => { + const { fetcher, state } = boundary(true); + const auth = session(); + await auth.refresh(); + state.completeStatus = status; + const result = await auth.run("Waiting", "Added", addPasskey); + expect(result).toBe(false); + expect(auth.busy).toBe(false); + expect(auth.status).toBe(""); + expect(auth.error).toMatch( + status === 401 + ? /Your session or authentication challenge expired/ + : /The request or authentication challenge is invalid or expired/, + ); + expect(auth.session.kind).toBe("ready"); + expect(auth.actor?.handle ?? null).toBe(status === 401 ? null : "alice"); + expect(paths(fetcher)).toEqual([ + "/api/auth/whoami", + "/api/auth/passkeys/addition/options", + "/api/auth/passkeys/addition/complete", + "/api/auth/whoami", + ]); + }); +} + +for (const name of ["NotAllowedError", "AbortError", "Error"] as const) { + test(`credential ${name} is sanitized, never completed, and leaves explicit retry possible`, async () => { + const { fetcher, get } = boundary(); + const auth = session(); + await auth.refresh(); + get.mockRejectedValueOnce( + name === "Error" + ? new Error("secret credential detail") + : new DOMException("secret credential detail", name), + ); + expect( + await auth.run( + "Waiting", + "Logged in", + (signal) => loginWithPasskey("alice", signal), + ), + ).toBe(false); + expect(auth.error).toMatch( + name === "NotAllowedError" + ? /cancelled or timed out/ + : name === "AbortError" + ? /Completion is not confirmed/ + : /Authentication could not be confirmed/, + ); + expect(auth.error).not.toContain("secret credential detail"); + expect(auth.status).toBe(""); + expect(auth.busy).toBe(false); + expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(paths(fetcher)).toEqual([ + "/api/auth/whoami", + "/api/auth/passkeys/login/options", + "/api/auth/whoami", + ]); + expect( + await auth.run( + "Waiting", + "Logged in", + (signal) => loginWithPasskey("alice", signal), + ), + ).toBe(true); + expect(auth.error).toBe(""); + expect(auth.actor?.handle).toBe("alice"); + }); +} + +test("completion does not confirm authentication when the subsequent whoami fails", async () => { + const { fetcher } = boundary(); + const auth = session(); + await auth.refresh(); + fetcher.mockImplementation(async (input) => { + const path = String(input); + if (path === "/api/auth/whoami") return new Response(null, { status: 503 }); + if (path === "/api/auth/passkeys/login/options") { + return Response.json(loginOptions); + } + if (path === "/api/auth/passkeys/login/complete") { + return Response.json({ user: authUser }); + } + throw new Error(`Unexpected API: ${path}`); + }); + expect( + await auth.run( + "Waiting", + "Logged in", + (signal) => loginWithPasskey("alice", signal), + ), + ).toBe(false); + expect(auth.session).toEqual({ kind: "error" }); + expect(auth.status).toBe(""); + expect(auth.actor).toBeNull(); + expect(auth.busy).toBe(false); +}); + +for (const rejected of [false, true]) { + test(`dispose ignores an outstanding whoami ${rejected ? "error" : "actor"} response and rejects future work`, async () => { + const { fetcher } = boundary(); + const response = gate(); + fetcher.mockReturnValueOnce(response.promise); + const auth = session(); + const refresh = auth.refresh(); + auth.dispose(); + response.resolve( + rejected ? new Response(null, { status: 503 }) : whoami(true), + ); + await refresh; + expect(auth.session).toEqual({ kind: "loading" }); + expect(auth.actor).toBeNull(); + await auth.refresh(); + const operation = vi.fn(async () => {}); + expect(await auth.run("Waiting", "Logged in", operation)).toBe(false); + expect(operation).not.toHaveBeenCalled(); + expect(fetcher).toHaveBeenCalledOnce(); + }); +} + +test("dispose aborts WebAuthn and ignores a late credential without completion, session notification or refresh", async () => { + const { fetcher, get } = boundary(); + const auth = session(); + await auth.refresh(); + const credential = gate(); + const started = gate(); + get.mockImplementationOnce((options) => { + started.resolve(options!.signal!); + return credential.promise; + }); + const dispatch = vi.spyOn(window, "dispatchEvent"); + const run = auth.run( + "Waiting", + "Logged in", + (signal) => loginWithPasskey("alice", signal), + ); + const signal = await started.promise; + expect(signal.aborted).toBe(false); + auth.dispose(); + expect(signal.aborted).toBe(true); + credential.resolve(credentialFixture(true)); + expect(await run).toBe(false); + expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.status).toBe("Waiting"); + expect(auth.error).toBe(""); + expect(paths(fetcher)).toEqual([ + "/api/auth/whoami", + "/api/auth/passkeys/login/options", + ]); + expect( + dispatch.mock.calls.filter(([event]) => + event.type === SESSION_CHANGED_EVENT + ), + ).toEqual([]); +}); + +test("dispose ignores a completion response even when the HTTP boundary does not honor abort", async () => { + const { fetcher } = boundary(); + const auth = session(); + await auth.refresh(); + const response = gate(); + const started = gate(); + fetcher.mockImplementation(async (input, init) => { + const path = String(input); + if (path === "/api/auth/passkeys/login/options") { + return Response.json(loginOptions); + } + if (path === "/api/auth/passkeys/login/complete") { + started.resolve(init!.signal!); + return await response.promise; + } + throw new Error(`Unexpected API: ${path}`); + }); + const dispatch = vi.spyOn(window, "dispatchEvent"); + const run = auth.run( + "Waiting", + "Logged in", + (signal) => loginWithPasskey("alice", signal), + ); + const signal = await started.promise; + auth.dispose(); + expect(signal.aborted).toBe(true); + response.resolve(Response.json({ user: authUser })); + expect(await run).toBe(false); + expect(auth.actor).toBeNull(); + expect(auth.status).toBe("Waiting"); + expect(auth.error).toBe(""); + expect(paths(fetcher)).toEqual([ + "/api/auth/whoami", + "/api/auth/passkeys/login/options", + "/api/auth/passkeys/login/complete", + ]); + expect( + dispatch.mock.calls.filter(([event]) => + event.type === SESSION_CHANGED_EVENT + ), + ).toEqual([]); +}); + +test("dispose during the post-operation whoami prevents its late actor from becoming readiness", async () => { + const { fetcher } = boundary(); + const auth = session(); + await auth.refresh(); + const response = gate(); + const started = gate(); + fetcher.mockImplementation(async (input) => { + const path = String(input); + if (path === "/api/auth/passkeys/login/options") { + return Response.json(loginOptions); + } + if (path === "/api/auth/passkeys/login/complete") { + return Response.json({ user: authUser }); + } + if (path === "/api/auth/whoami") { + started.resolve(); + return await response.promise; + } + throw new Error(`Unexpected API: ${path}`); + }); + const run = auth.run( + "Waiting", + "Logged in", + (signal) => loginWithPasskey("alice", signal), + ); + await started.promise; + expect(auth.session.kind).toBe("loading"); + expect(auth.busy).toBe(true); + auth.dispose(); + response.resolve(whoami(true)); + expect(await run).toBe(false); + expect(auth.session.kind).toBe("loading"); + expect(auth.actor).toBeNull(); +}); + +for (const purpose of ["login", "addition"] as const) { + test(`${purpose} null credential reports cancellation and never sends completion`, async () => { + const { fetcher, get, create } = boundary(purpose === "addition"); + const auth = session(); + await auth.refresh(); + (purpose === "login" ? get : create).mockResolvedValueOnce(null); + expect( + await auth.run( + "Waiting", + "Confirmed", + (signal) => + purpose === "login" + ? loginWithPasskey("alice", signal) + : addPasskey(signal), + ), + ).toBe(false); + expect(auth.error).toMatch(/cancelled or timed out/); + expect(paths(fetcher).some((path) => path.endsWith("/complete"))).toBe( + false, + ); + }); +} diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 66d3ed9cc..84b9d0030 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -72,8 +72,8 @@ Deno.test("workspace feature css is owned outside app css", async () => { workspaceLayout.includes("#lib/workspace/styles/workspace-pages.css") && workspaceLayout.includes("#lib/workspace/styles/workers.css") && settingsLayout.includes("#lib/workspace/styles/settings.css") && - accountPage.includes("#lib/workspace/styles/settings.css") && - deviceLoginPage.includes("#lib/workspace/styles/settings.css"), + accountPage.includes("#lib/workspace/auth/auth-page.css") && + deviceLoginPage.includes("#lib/workspace/auth/auth-page.css"), "feature-owned CSS should be imported by the layouts/pages that need it", ); }); @@ -1277,13 +1277,7 @@ Deno.test("Worker Console route resolves logical Worker authority before Runtime ); }); -Deno.test("Account UI owns browser passkey session state without workspace authorization", async () => { - const accountPage = await Deno.readTextFile( - new URL("./../../../routes/account/+page.svelte", import.meta.url), - ); - const devicePage = await Deno.readTextFile( - new URL("./../../../routes/login/device/+page.svelte", import.meta.url), - ); +Deno.test("Root chrome owns global navigation and scoped override slots", async () => { const rootLayout = await Deno.readTextFile( new URL("./../../../routes/+layout.svelte", import.meta.url), ); @@ -1321,21 +1315,6 @@ Deno.test("Account UI owns browser passkey session state without workspace autho new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url), ); - assert( - accountPage.includes("registerPasskey") && - accountPage.includes("loginWithPasskey") && - accountPage.includes("logout") && - accountPage.includes("loadWhoami") && - accountPage.includes("Current user"), - "Account page should expose registration, login, logout, and current-session inspection", - ); - assert( - devicePage.includes("approveDeviceLogin") && - devicePage.includes("loginWithPasskey") && - devicePage.includes("user_code") && - devicePage.includes("Approve device login"), - "Device login page should approve CLI login without DevTools console", - ); assert( rootLayout.includes("SIDEBAR_CONTEXT") && rootLayout.includes("GlobalSidebar") && diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 6009c9f3e..9b5976801 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -26,8 +26,9 @@ import { observeDeferred } from '#lib/workspace/navigation/observe-deferred.svelte.ts'; let { children, data }: LayoutProps = $props(); - // Hostingと初回登録はWorkspaceに依存しない。login全体の分離はここで扱わない。 - const standalone = $derived(page.url.pathname === '/activate' || page.url.pathname.startsWith('/hosting/')); + import { isAuthPage } from '#lib/workspace/auth/routes.ts'; + // 認証画面はWorkspaceの存在/取得/Sidebarに依存しない。 + const standalone = $derived(isAuthPage(page.url.pathname) || page.url.pathname.startsWith('/hosting/')); const catalog = observeDeferred(() => data.deferredCatalog, () => 'catalog'); let sidebar = $state(null); const sidebarOverrides = createOverrideStack((activeSidebar) => { diff --git a/web/workspace/src/routes/+layout.ts b/web/workspace/src/routes/+layout.ts index 09e31e688..621bbf6e9 100644 --- a/web/workspace/src/routes/+layout.ts +++ b/web/workspace/src/routes/+layout.ts @@ -1,3 +1,4 @@ +import { isAuthPage } from "#lib/workspace/auth/routes.ts"; import { listWorkspaces } from "#lib/workspace/api/workspace-catalog.ts"; import { settle } from "#lib/workspace/navigation/deferred.ts"; import type { Load } from "@sveltejs/kit"; @@ -6,8 +7,8 @@ export const ssr = false; export const load = ({ fetch, url }: Parameters[0]) => ({ deferredCatalog: settle((async () => { - // 初回有効化とhosting管理はWorkspace未作成でも利用する。 - if (url.pathname === "/activate" || url.pathname.startsWith("/hosting/")) { + // 認証/hostingの判断はWorkspace catalogを必要としない。 + if (isAuthPage(url.pathname) || url.pathname.startsWith("/hosting/")) { return { accessibleWorkspaces: [], workspaceCatalogError: null }; } try { diff --git a/web/workspace/src/routes/account/+page.svelte b/web/workspace/src/routes/account/+page.svelte index 7f703add5..51f673db8 100644 --- a/web/workspace/src/routes/account/+page.svelte +++ b/web/workspace/src/routes/account/+page.svelte @@ -1,213 +1,56 @@ - - Account · Yoi Workspace - - -
-
-

Account

-

Account

-

Register a passkey, sign in, sign out, and inspect the current browser session.

-
- - - -
-

Passkey

-
{ event.preventDefault(); }}> - - -
- {#if actor || policy === true} - - {/if} - -
-
- {#if !actor} - {#if policyLoading} -

Checking registration availability…

- {:else if policyError} - - - {:else if policy === false} -

New account registration is disabled. Existing users can still log in.

- {/if} - {/if} -
- - {#if status} -

{status}

+ {:else} +

No browser session is active.

+ Log in to your account {/if} - {#if error} -

{error}

- {/if} -
+ {#if auth.status}

{auth.status}

{/if} + {#if auth.error}{/if} + Dashboard / Workspaces + diff --git a/web/workspace/src/routes/account/account.browser.spec.ts b/web/workspace/src/routes/account/account.browser.spec.ts index 557eeb756..d9cc685bf 100644 --- a/web/workspace/src/routes/account/account.browser.spec.ts +++ b/web/workspace/src/routes/account/account.browser.spec.ts @@ -7,7 +7,12 @@ import { waitFor, } from "@testing-library/svelte"; import { afterEach, expect, test, vi } from "vitest"; +import { tick } from "svelte"; +import { goto } from "$app/navigation"; import Account from "./+page.svelte"; +import Login from "../login/+page.svelte"; +import Register from "../register/+page.svelte"; +import Device from "../login/device/+page.svelte"; import { authUser, credentialFixture, @@ -15,85 +20,248 @@ import { registrationOptions, } from "#lib/workspace/auth/api.test-fixtures.ts"; +const page = vi.hoisted(() => ({ url: new URL("https://example.test/login") })); +vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) })); +vi.mock("$app/state", () => ({ page })); + afterEach(() => { cleanup(); vi.unstubAllGlobals(); + vi.clearAllMocks(); + page.url = new URL("https://example.test/login"); }); -// UIから本番HTTP adapterまでを接続し、fetch/ブラウザーcredential境界だけを代替する。 -function boundary( - authenticated: boolean, +function gate() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +type Policy = boolean | "error" | "loading"; +// 各routeのUI→本番HTTP/WebAuthn変換を接続し、fetchとcredential境界だけを代替する。 +// Backendの署名検証・認可・永続化はこのsuiteの保証対象ではない。 +function boundary({ + authenticated = false, completeStatus = 200, - policy: boolean | "error" | "loading" = true, -) { - const create = vi.fn(async () => credentialFixture()); - const get = vi.fn(async () => credentialFixture(true)); + policy = true as Policy, +} = {}) { + const state = { authenticated, completeStatus, policy, whoamiStatus: 200 }; + const policyResponse = gate(); + const create = vi.fn() + .mockResolvedValue(credentialFixture()); + const get = vi.fn() + .mockResolvedValue(credentialFixture(true)); vi.stubGlobal("navigator", { credentials: { create, get } }); const fetcher = vi.fn().mockImplementation(async (input) => { const path = String(input); if (path === "/api/auth/whoami") { + if (state.whoamiStatus !== 200) { + return new Response(null, { status: state.whoamiStatus }); + } return Response.json({ - actor: authenticated + actor: state.authenticated ? { ...authUser, auth_method: "browser_session" } : null, }); } if (path === "/api/auth/registration-policy") { - if (policy === "loading") return await new Promise(() => {}); - if (policy === "error") return new Response(null, { status: 503 }); - return Response.json({ self_registration_enabled: policy }); + if (state.policy === "loading") return await policyResponse.promise; + if (state.policy === "error") return new Response(null, { status: 503 }); + return Response.json({ self_registration_enabled: state.policy }); } if (path === "/api/auth/passkeys/login/options") { return Response.json(loginOptions); } - if (path.endsWith("/options")) return Response.json(registrationOptions); - if (path.endsWith("/complete")) { - if (completeStatus !== 200) { - if (completeStatus === 403 && path.includes("/registration/")) { - policy = false; + if ( + /^\/api\/auth\/passkeys\/(registration|addition)\/options$/.test(path) + ) { + return Response.json(registrationOptions); + } + if ( + /^\/api\/auth\/passkeys\/(login|registration|addition)\/complete$/.test( + path, + ) + ) { + if (state.completeStatus !== 200) { + if (state.completeStatus === 403 && path.includes("/registration/")) { + state.policy = false; } - return new Response(null, { status: completeStatus }); + if (state.completeStatus === 401) state.authenticated = false; + return new Response(null, { status: state.completeStatus }); } - authenticated = true; + state.authenticated = true; return Response.json({ user: authUser }); } + if (path === "/api/auth/logout") { + state.authenticated = false; + return Response.json({ status: "logged_out" }); + } + if (path === "/api/auth/device-login/approve") { + if (state.completeStatus !== 200) { + if (state.completeStatus === 401) state.authenticated = false; + return new Response(null, { status: state.completeStatus }); + } + return Response.json({ status: "approved", user: authUser }); + } throw new Error(`Unexpected API: ${path}`); }); vi.stubGlobal("fetch", fetcher); - return { fetcher, create, get }; + return { fetcher, create, get, state, policyResponse }; } function posted(fetcher: ReturnType["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, - })); + 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", +function apiPaths(fetcher: ReturnType["fetcher"]) { + return fetcher.mock.calls.map(([path]) => String(path)); +} + +async function enterIdentity(registering = false) { + await fireEvent.input(await screen.findByLabelText("Account ID (handle)"), { + target: { value: " alice " }, }); + if (registering) { + await fireEvent.input(screen.getByLabelText("Display name"), { + target: { value: " Alice " }, + }); + } +} + +async function submit(name: string) { + await fireEvent.submit(screen.getByRole("button", { name }).closest("form")!); +} + +test("login asks only for an account ID and links separate registration and code activation", async () => { + const { fetcher } = boundary({ policy: false }); + page.url = new URL("https://example.test/login?returnTo=%2Faccount"); + render(Login); + const handle = await screen.findByLabelText("Account ID (handle)"); + expect(screen.queryByLabelText("Display name")).toBeNull(); + expect(screen.queryByRole("button", { name: "Register new account" })) + .toBeNull(); + expect( + screen.getByRole("link", { name: "Create a new account" }).getAttribute( + "href", + ), + ) + .toBe("/register?returnTo=%2Faccount"); + expect( + screen.getByRole("link", { name: "Activate an account with a code" }) + .getAttribute("href"), + ) + .toBe("/activate"); + await fireEvent.input(handle, { target: { value: " " } }); + expect( + (screen.getByRole("button", { + name: "Log in with passkey", + }) as HTMLButtonElement).disabled, + ) + .toBe(true); + await submit("Log in with passkey"); + expect(posted(fetcher)).toEqual([]); + expect(apiPaths(fetcher)).not.toContain("/api/auth/registration-policy"); +}); + +for ( + const [returnTo, expected] of [ + [null, "/"], + ["/account", "/account"], + ["/w/project/tickets?ignored=1#fragment", "/w/project/tickets"], + [ + "/login/device?code=abcd-EFGH&untrusted=1", + "/login/device?user_code=abcd-EFGH", + ], + ["https://evil.test/", "/"], + ["//evil.test/account", "/"], + ["/\\evil.test/account", "/"], + ["/%2f%2fevil.test", "/"], + ["/login?returnTo=/login", "/"], + ["/register", "/"], + ["/activate", "/"], + ] as const +) { + test(`login confirms whoami before navigating safely for returnTo=${returnTo}`, async () => { + const { fetcher, get, create } = boundary(); + page.url = new URL("https://example.test/login"); + if (returnTo !== null) page.url.searchParams.set("returnTo", returnTo); + render(Login); + await enterIdentity(); + await submit("Log in with passkey"); + await waitFor(() => + expect(goto).toHaveBeenCalledWith(expected, { replaceState: true }) + ); + const requests = posted(fetcher); + expect(requests.map(({ path }) => path)).toEqual([ + "/api/auth/passkeys/login/options", + "/api/auth/passkeys/login/complete", + ]); + expect(requests[0].body).toEqual({ + handle: "alice", + browser_origin: window.location.origin, + }); + expect(requests[1].body.challenge_id).toBe(loginOptions.challenge_id); + expect(get).toHaveBeenCalledOnce(); + expect(create).not.toHaveBeenCalled(); + expect(apiPaths(fetcher).filter((path) => path === "/api/auth/whoami")) + .toHaveLength(2); + expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve"); + }); +} + +test("successful login completion without confirmed whoami never navigates", async () => { + const { state, get } = boundary(); + render(Login); + await enterIdentity(); + get.mockImplementationOnce(async () => { + state.whoamiStatus = 503; + return credentialFixture(true); + }); + await submit("Log in with passkey"); + await screen.findByText(/Your session could not be loaded/); + expect(goto).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Log in with passkey" })) + .toBeNull(); +}); + +test("register requires a separate ID and display name and uses only new-account endpoints", async () => { + const { fetcher, create, get } = boundary(); + render(Register); + await screen.findByLabelText("Display name"); + expect( + (screen.getByRole("button", { + name: "Register new account", + }) as HTMLButtonElement).disabled, + ) + .toBe(true); + await fireEvent.input(screen.getByLabelText("Account ID (handle)"), { + target: { value: " alice " }, + }); + expect( + (screen.getByRole("button", { + name: "Register new account", + }) as HTMLButtonElement).disabled, + ) + .toBe(true); + expect(screen.queryByRole("button", { name: "Log in with passkey" })) + .toBeNull(); + await fireEvent.input(screen.getByLabelText("Display name"), { + target: { value: " Alice " }, + }); + await submit("Register new account"); await waitFor(() => - expect((button as HTMLButtonElement).disabled).toBe(false) + expect(goto).toHaveBeenCalledWith("/", { replaceState: true }) ); - 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([ + expect(requests.map(({ path }) => path)).toEqual([ "/api/auth/passkeys/registration/options", "/api/auth/passkeys/registration/complete", ]); @@ -104,31 +272,100 @@ test("anonymous existing Account page registers a new account via registration e }); expect(requests[1].body.challenge_id).toBe(registrationOptions.challenge_id); expect(create).toHaveBeenCalledOnce(); + expect(get).not.toHaveBeenCalled(); }); -test("authenticated Account locks identity fields and adds only through self-only addition endpoints", async () => { - const { fetcher, create } = boundary(true, 200, false); - render(Account); - const button = await screen.findByRole("button", { - name: "Add passkey to alice", +for (const policy of [false, "error", "loading"] as const) { + test(`register policy ${policy} prevents registration but preserves login and activation links`, async () => { + const { fetcher, create, policyResponse } = boundary({ policy }); + render(Register); + await screen.findByRole("heading", { name: "Create account" }); + if (policy === false) { + await screen.findByText(/New account registration is disabled/); + } + if (policy === "error") { + await screen.findByRole("button", { + name: "Retry registration availability", + }); + } + if (policy === "loading") { + await screen.findByText("Checking registration availability…"); + } + await waitFor(() => + expect(apiPaths(fetcher)).toContain("/api/auth/whoami") + ); + expect(screen.queryByLabelText("Account ID (handle)")).toBeNull(); + expect(screen.queryByRole("button", { name: "Register new account" })) + .toBeNull(); + expect( + screen.getByRole("link", { name: "Log in with an existing account" }) + .getAttribute("href"), + ) + .toBe("/login?returnTo=%2F"); + expect( + screen.getByRole("link", { name: "Activate an account with a code" }) + .getAttribute("href"), + ) + .toBe("/activate"); + expect(posted(fetcher)).toEqual([]); + expect(create).not.toHaveBeenCalled(); + if (policy === "loading") { + policyResponse.resolve( + Response.json({ self_registration_enabled: false }), + ); + await screen.findByText(/New account registration is disabled/); + } }); - 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); +} + +test("policy disabled during WebAuthn rejects registration, reloads policy and never falls back to addition", async () => { + const { fetcher, create, state } = boundary(); + const credential = gate(); + create.mockReturnValueOnce(credential.promise); + render(Register); + await enterIdentity(true); + await submit("Register new account"); + await waitFor(() => expect(create).toHaveBeenCalledOnce()); + state.completeStatus = 403; + credential.resolve(credentialFixture()); + await screen.findByText("Auth request failed (403)."); + await screen.findByText(/New account registration is disabled/); + expect(screen.queryByRole("button", { name: "Register new account" })) + .toBeNull(); + expect(screen.queryByText("New account registered.")).toBeNull(); + expect(posted(fetcher).map(({ path }) => path)).toEqual([ + "/api/auth/passkeys/registration/options", + "/api/auth/passkeys/registration/complete", + ]); + expect(goto).not.toHaveBeenCalled(); +}); + +test("account displays the current identity without editable target fields and adds only to self", async () => { + const { fetcher, create, get } = boundary({ + authenticated: true, + policy: false, + }); + render(Account); + await screen.findByRole("button", { name: "Add passkey to alice" }); + expect(screen.getByText("alice")).toBeTruthy(); + expect(screen.getByText("Alice")).toBeTruthy(); + expect(screen.queryAllByRole("textbox")).toEqual([]); + expect(screen.queryByRole("button", { name: "Log in with passkey" })) + .toBeNull(); + expect(screen.queryByRole("button", { name: "Register new account" })) + .toBeNull(); + await fireEvent.click( + screen.getByRole("button", { name: "Add passkey to alice" }), + ); await screen.findByText("Passkey added to your account."); + await waitFor(() => + expect( + (screen.getByRole("button", { name: "Log out" }) as HTMLButtonElement) + .disabled, + ).toBe(false) + ); const requests = posted(fetcher); - expect(requests.map((request) => request.path)).toEqual([ + expect(requests.map(({ path }) => path)).toEqual([ "/api/auth/passkeys/addition/options", "/api/auth/passkeys/addition/complete", ]); @@ -137,134 +374,304 @@ test("authenticated Account locks identity fields and adds only through self-onl "challenge_id", "credential", ]); - expect(requests.map((request) => request.credentials)).toEqual([ + expect(requests[1].body.challenge_id).toBe(registrationOptions.challenge_id); + expect(requests.map(({ credentials }) => credentials)).toEqual([ "same-origin", "same-origin", ]); + expect(apiPaths(fetcher)).not.toContain("/api/auth/registration-policy"); expect(create).toHaveBeenCalledOnce(); + expect(get).not.toHaveBeenCalled(); + expect(goto).not.toHaveBeenCalled(); }); -test("addition completion rejection is visible and never retries as new registration", async () => { - const { fetcher } = boundary(true, 401); +test("anonymous account redirects to login with an account return path and has no credential inputs", async () => { + const { fetcher } = boundary(); + render(Account); + await waitFor(() => + expect(goto).toHaveBeenCalledWith("/login?returnTo=%2Faccount", { + replaceState: true, + }) + ); + expect( + screen.getByRole("link", { name: "Log in to your account" }).getAttribute( + "href", + ), + ) + .toBe("/login?returnTo=%2Faccount"); + expect(screen.queryAllByRole("textbox")).toEqual([]); + expect(posted(fetcher)).toEqual([]); +}); + +for ( + const [route, Component] of [ + ["login", Login], + ["register", Register], + ["account", Account], + ["device", Device], + ] as const +) { + test(`${route} keeps loading and unknown whoami separate from an anonymous session`, async () => { + const { fetcher } = boundary(); + const response = gate(); + fetcher.mockReturnValueOnce(response.promise); + if (Component === Login) render(Login); + else if (Component === Register) render(Register); + else if (Component === Account) render(Account); + else render(Device); + await screen.findByText("Loading session…"); + expect( + screen.queryByRole("button", { + name: + /Log in with passkey|Register new account|Add passkey|Approve device login|Log out/, + }), + ).toBeNull(); + expect(goto).not.toHaveBeenCalled(); + response.resolve(new Response(null, { status: 503 })); + await screen.findByText(/Your session could not be loaded/); + expect(screen.queryByText("No browser session is active.")).toBeNull(); + expect( + screen.queryByRole("button", { + name: + /Log in with passkey|Register new account|Add passkey|Approve device login|Log out/, + }), + ).toBeNull(); + expect(posted(fetcher)).toEqual([]); + expect(goto).not.toHaveBeenCalled(); + }); +} + +test("unknown account session shows retry instead of treating errors as anonymous or redirecting", async () => { + const { state, fetcher } = boundary(); + state.whoamiStatus = 503; + render(Account); + await screen.findByText(/Your session could not be loaded/); + expect(screen.queryByText("No browser session is active.")).toBeNull(); + expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull(); + expect(goto).not.toHaveBeenCalled(); + state.whoamiStatus = 200; + state.authenticated = true; + await fireEvent.click(screen.getByRole("button", { name: "Retry session" })); + await screen.findByRole("button", { name: "Add passkey to alice" }); + expect(posted(fetcher)).toEqual([]); + expect(goto).not.toHaveBeenCalled(); +}); + +test("logout posts to the session endpoint and confirms anonymous whoami before login navigation", async () => { + const { fetcher } = boundary({ authenticated: true }); + render(Account); + await fireEvent.click(await screen.findByRole("button", { name: "Log out" })); + await waitFor(() => + expect(goto).toHaveBeenCalledWith("/login?returnTo=%2Faccount", { + replaceState: true, + }) + ); + expect(posted(fetcher)).toEqual([ + { path: "/api/auth/logout", body: {}, credentials: "same-origin" }, + ]); + expect(apiPaths(fetcher)).toEqual([ + "/api/auth/whoami", + "/api/auth/logout", + "/api/auth/whoami", + ]); + expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull(); +}); + +test("expired addition revokes stale identity without silently retrying as registration", async () => { + const { fetcher } = boundary({ authenticated: true, completeStatus: 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([ + await screen.findByText(/Your session or authentication challenge expired/); + await screen.findByRole("link", { name: "Log in to your account" }); + expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull(); + expect(screen.queryByText("Passkey added to your account.")).toBeNull(); + expect(posted(fetcher).map(({ path }) => 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: " " } }, - ); +test("pending login disables inputs and rejects duplicate form submits until the credential completes", async () => { + const { fetcher, get } = boundary(); + const credential = gate(); + get.mockReturnValueOnce(credential.promise); + render(Login); + await enterIdentity(); + await submit("Log in with passkey"); + await waitFor(() => expect(get).toHaveBeenCalledOnce()); expect( - (screen.getByRole("button", { - name: "Register new account", - }) as HTMLButtonElement).disabled, + (screen.getByLabelText("Account ID (handle)") as HTMLInputElement).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.getByLabelText( - "User handle (new registration or login)", - ) as HTMLInputElement).disabled, - ).toBe(true); - expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull(); - expect(screen.queryByRole("button", { name: "Register new account" })) - .toBeNull(); -}); - -for (const policy of [false, "error", "loading"] as const) { - test(`policy ${policy} still permits the existing passkey login HTTP flow`, async () => { - const { fetcher, get, create } = boundary(false, 200, policy); - render(Account); - await screen.findByText("No browser session is active."); - await fireEvent.click( - screen.getByRole("button", { name: "Log in with passkey" }), - ); - await screen.findByText("Logged in."); - expect(posted(fetcher).map((request) => request.path)).toEqual([ - "/api/auth/passkeys/login/options", - "/api/auth/passkeys/login/complete", - ]); - expect(get).toHaveBeenCalledOnce(); - expect(create).not.toHaveBeenCalled(); - }); -} - -test("registration disabled during WebAuthn completion shows rejection and reloads policy without fallback", async () => { - const { fetcher } = boundary(false, 403, true); - render(Account); - await fireEvent.click( - await screen.findByRole("button", { name: "Register new account" }), - ); - await screen.findByText("Auth request failed (403)."); - await screen.findByText(/New account registration is disabled/); - expect(screen.queryByRole("button", { name: "Register new account" })) - .toBeNull(); - expect(screen.queryByText("New account registered.")).toBeNull(); - expect(posted(fetcher).map((request) => request.path)).toEqual([ - "/api/auth/passkeys/registration/options", - "/api/auth/passkeys/registration/complete", + await submit("Log in with passkey"); + expect(posted(fetcher).map(({ path }) => path)).toEqual([ + "/api/auth/passkeys/login/options", ]); + expect(goto).not.toHaveBeenCalled(); + credential.resolve(credentialFixture(true)); + await waitFor(() => expect(goto).toHaveBeenCalledOnce()); + expect(get).toHaveBeenCalledOnce(); + expect(posted(fetcher)).toHaveLength(2); }); -for (const policy of [false, "error", "loading"] as const) { - test(`registration policy ${policy} hides registration without disabling login`, async () => { - const { fetcher } = boundary(false, 200, policy); - render(Account); - await screen.findByText("No browser session is active."); - if (policy === false) { - await screen.findByText(/New account registration is disabled/); - } - if (policy === "error") { - await screen.findByText("Registration availability could not be loaded."); - } - if (policy === "loading") { - await screen.findByText("Checking registration availability…"); - } - expect(screen.queryByRole("button", { name: "Register new account" })) - .toBeNull(); +test("WebAuthn cancellation is visible, sends no completion, and allows an explicit retry", async () => { + const { fetcher, get } = boundary(); + get.mockRejectedValueOnce( + new DOMException("sensitive browser detail", "NotAllowedError"), + ); + render(Login); + await enterIdentity(); + await submit("Log in with passkey"); + await screen.findByText( + "Passkey operation was cancelled or timed out. You can try again.", + ); + await waitFor(() => expect( (screen.getByRole("button", { name: "Log in with passkey", }) as HTMLButtonElement).disabled, - ).toBe(false); - expect(posted(fetcher)).toEqual([]); - }); -} + ).toBe(false) + ); + expect(screen.queryByText(/sensitive browser detail/)).toBeNull(); + expect(posted(fetcher).map(({ path }) => path)).toEqual([ + "/api/auth/passkeys/login/options", + ]); + expect(goto).not.toHaveBeenCalled(); + await submit("Log in with passkey"); + await waitFor(() => expect(goto).toHaveBeenCalledOnce()); + expect(get).toHaveBeenCalledTimes(2); +}); -for (const policy of ["error", "loading"] as const) { - test(`policy ${policy} does not block actor-self passkey addition`, async () => { - const { fetcher } = boundary(true, 200, policy); - render(Account); - await fireEvent.click( - await screen.findByRole("button", { name: "Add passkey to alice" }), +test("unmount aborts a pending WebAuthn login and ignores a credential that arrives afterwards", async () => { + const { fetcher, get } = boundary(); + const credential = gate(); + get.mockReturnValueOnce(credential.promise); + const view = render(Login); + await enterIdentity(); + await submit("Log in with passkey"); + await waitFor(() => expect(get).toHaveBeenCalledOnce()); + const signal = get.mock.calls[0][0]?.signal; + expect(signal?.aborted).toBe(false); + view.unmount(); + expect(signal?.aborted).toBe(true); + credential.resolve(credentialFixture(true)); + await credential.promise; + await tick(); + expect(posted(fetcher).map(({ path }) => path)).toEqual([ + "/api/auth/passkeys/login/options", + ]); + expect(goto).not.toHaveBeenCalled(); +}); + +test("device login round trip preserves the code and still requires an explicit approval", async () => { + const { fetcher } = boundary(); + page.url = new URL("https://example.test/login/device?user_code=abcd-efgh"); + const device = render(Device); + const login = await screen.findByRole("link", { name: "Log in to continue" }); + expect(screen.queryByRole("button", { name: "Approve device login" })) + .toBeNull(); + expect(posted(fetcher)).toEqual([]); + const destination = new URL(login.getAttribute("href")!, page.url); + expect(destination.pathname).toBe("/login"); + expect(destination.searchParams.get("returnTo")).toBe( + "/login/device?user_code=abcd-efgh", + ); + device.unmount(); + page.url = destination; + const signIn = render(Login); + await enterIdentity(); + await submit("Log in with passkey"); + await waitFor(() => + expect(goto).toHaveBeenCalledWith("/login/device?user_code=abcd-efgh", { + replaceState: true, + }) + ); + expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve"); + signIn.unmount(); + page.url = new URL("https://example.test/login/device?user_code=abcd-efgh"); + render(Device); + const approve = await screen.findByRole("button", { + name: "Approve device login", + }); + expect((screen.getByLabelText("User code") as HTMLInputElement).value).toBe( + "abcd-efgh", + ); + expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve"); + await fireEvent.submit(approve.closest("form")!); + await screen.findByText("Device login approved. You can return to the CLI."); + await waitFor(() => + expect(screen.queryByRole("button", { name: "Approve device login" })) + .toBeNull() + ); + expect( + posted(fetcher).filter(({ path }) => + path === "/api/auth/device-login/approve" + ), + ) + .toEqual([{ + path: "/api/auth/device-login/approve", + body: { user_code: "ABCD-EFGH" }, + credentials: "same-origin", + }]); + await fireEvent.submit(screen.getByLabelText("User code").closest("form")!); + expect( + posted(fetcher).filter(({ path }) => + path === "/api/auth/device-login/approve" + ), + ).toHaveLength(1); +}); + +test("device approval locks its code and rejects a second submit while the server response is pending", async () => { + const { fetcher } = boundary({ authenticated: true }); + page.url = new URL("https://example.test/login/device?user_code=abcd-efgh"); + render(Device); + const approve = await screen.findByRole("button", { + name: "Approve device login", + }); + const response = gate(); + fetcher.mockReturnValueOnce(response.promise); + const form = approve.closest("form")!; + await fireEvent.submit(form); + await screen.findByText("Approving device login…"); + expect((approve as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByLabelText("User code") as HTMLInputElement).disabled) + .toBe(true); + await fireEvent.submit(form); + expect(posted(fetcher).map(({ path }) => path)).toEqual([ + "/api/auth/device-login/approve", + ]); + response.resolve(Response.json({ status: "approved", user: authUser })); + await screen.findByText("Device login approved. You can return to the CLI."); + expect(posted(fetcher)).toHaveLength(1); +}); + +for (const status of [401, 410]) { + test(`device approval ${status} is visible and never reports approval`, async () => { + const { fetcher } = boundary({ + authenticated: true, + completeStatus: status, + }); + page.url = new URL("https://example.test/login/device?code=abcd-efgh"); + render(Device); + await fireEvent.submit( + (await screen.findByRole("button", { name: "Approve device login" })) + .closest("form")!, ); - await screen.findByText("Passkey added to your account."); - expect(posted(fetcher).map((request) => request.path)).toEqual([ - "/api/auth/passkeys/addition/options", - "/api/auth/passkeys/addition/complete", - ]); + await screen.findByText( + status === 401 + ? /Your session or authentication challenge expired/ + : /The request or authentication challenge is invalid or expired/, + ); + expect( + screen.queryByText("Device login approved. You can return to the CLI."), + ).toBeNull(); + expect(posted(fetcher)).toHaveLength(1); + expect(goto).not.toHaveBeenCalled(); }); } diff --git a/web/workspace/src/routes/activate/+page.svelte b/web/workspace/src/routes/activate/+page.svelte index c27318a3b..1537c2aac 100644 --- a/web/workspace/src/routes/activate/+page.svelte +++ b/web/workspace/src/routes/activate/+page.svelte @@ -29,12 +29,12 @@ notifySessionChanged(); } catch { if (current !== generation) return; - error = 'Registration completion could not be confirmed. If you completed the Passkey prompt, check Passkey login from Account first. Otherwise re-enter the ID and code to restart. Ask your administrator to reissue if the code expired, was locked or was replaced.'; + error = 'Registration completion could not be confirmed. If you completed the Passkey prompt, check the Passkey login page first. Otherwise re-enter the ID and code to restart. Ask your administrator to reissue if the code expired, was locked or was replaced.'; } finally { if (current === generation) { code = ''; busy = false; controller = null; } } } function cancel() { ++generation; controller?.abort(); controller = null; code = ''; busy = false; - error = 'Registration stopped; completion is not confirmed. If you completed the Passkey prompt, check Passkey login from Account first. Otherwise re-enter the ID and code to restart.'; + error = 'Registration stopped; completion is not confirmed. If you completed the Passkey prompt, check the Passkey login page first. Otherwise re-enter the ID and code to restart.'; } onMount(() => { const clear = () => { ++generation; controller?.abort(); code = ''; busy = false; }; @@ -66,7 +66,7 @@ {#if busy}

Passkey registration pending. Account activation has not been confirmed.

{/if} {#if error}{/if} - Already registered or completion uncertain? Check Passkey login from Account + Already registered or completion uncertain? Log in with a Passkey
Restart or get a new code

Allow the browser’s Passkey prompt and save a Passkey. Only a confirmed completion signs you in; checking the code alone gives no normal session, Workspace access or device approval. After cancellation or reload, enter the ID and code again. A correct code is not consumed by starting the prompt. If a request lost its response, try Passkey login before restarting. If no Passkey works, ask your administrator to reissue for the pending ID; reissue invalidates the old code and prompt. This works even when self-registration is disabled.

{/if} diff --git a/web/workspace/src/routes/login/+page.svelte b/web/workspace/src/routes/login/+page.svelte new file mode 100644 index 000000000..26755931b --- /dev/null +++ b/web/workspace/src/routes/login/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/web/workspace/src/routes/login/device/+page.svelte b/web/workspace/src/routes/login/device/+page.svelte index aaca230bb..adfa5a87e 100644 --- a/web/workspace/src/routes/login/device/+page.svelte +++ b/web/workspace/src/routes/login/device/+page.svelte @@ -1,119 +1,55 @@ - - Device Login · Yoi Workspace - - -
-
-

Device Login

-

Approve CLI login

-

Use your browser session to approve a pending CLI/TUI login request.

-
- -
-

Browser session

- {#if loading} -

Loading session…

- {:else if actor} -

Logged in as {actor.display_name} @{actor.handle}.

- {:else} -

You need to log in with a passkey before approving a device login.

-
{ event.preventDefault(); void login(); }}> - - -
+Device Login · Yoi +
+

Approve CLI login

+

Only approve a code shown by a CLI login you started. Logging in to the browser does not approve the device.

+ {#if auth.session.kind === 'loading'} +

Loading session…

+ {:else if auth.session.kind === 'error'} + + + {:else if auth.actor} +

Logged in as {auth.actor.display_name} @{auth.actor.handle}.

+ {:else} +

You need to log in before approving a device login.

+ Log in to continue + {/if} +
{ event.preventDefault(); void approve(); }}> + + {#if auth.actor && !approved} + {/if} -
- -
-

Approval code

- { event.preventDefault(); void approve(); }}> - - - -
- - {#if status} -

{status}

- {/if} - {#if error} -

{error}

- {/if} -
+ + {#if auth.status}

{auth.status}

{/if} + {#if auth.error}{/if} + Open Account + diff --git a/web/workspace/src/routes/register/+page.svelte b/web/workspace/src/routes/register/+page.svelte new file mode 100644 index 000000000..2a2a13236 --- /dev/null +++ b/web/workspace/src/routes/register/+page.svelte @@ -0,0 +1,5 @@ + + + diff --git a/web/workspace/test/activation-browser/activation-shell.browser.test.ts b/web/workspace/test/activation-browser/activation-shell.browser.test.ts index 2b2cdf1a5..d3c464423 100644 --- a/web/workspace/test/activation-browser/activation-shell.browser.test.ts +++ b/web/workspace/test/activation-browser/activation-shell.browser.test.ts @@ -172,7 +172,7 @@ Deno.test("activation standalone shell supports issuance, reissue, denial, resta await checkLink( page.getByRole("link", { name: - "Already registered or completion uncertain? Check Passkey login from Account", + "Already registered or completion uncertain? Log in with a Passkey", }), ); await fillActivation(); diff --git a/web/workspace/test/auth-browser/auth-shell.browser.test.ts b/web/workspace/test/auth-browser/auth-shell.browser.test.ts new file mode 100644 index 000000000..aba522b1f --- /dev/null +++ b/web/workspace/test/auth-browser/auth-shell.browser.test.ts @@ -0,0 +1,704 @@ +/// +// 本番shell/router/clientを合成HTTP/WebAuthnで接続する。Server認証/DBのE2Eではない。 +import { assert, assertEquals } from "@std/assert"; +import { resolve } from "@std/path"; +import { type Browser, chromium, type Page } from "playwright"; +import { + startOwnedProcesses, + stopOwnedProcesses, +} from "../../../../tools/web-ux/src/processes.ts"; + +const root = resolve(import.meta.dirname!, "../../../.."); +const origin = "http://127.0.0.1:15391"; +type Mutation = { path: string; keys: string[] }; +type Harness = { + page: Page; + mutations: Mutation[]; + go: (path: string, mode?: string) => Promise; +}; +const login = async (page: Page) => { + await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice"); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .click(); +}; +const register = async (page: Page) => { + await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice"); + await page.getByLabel("Display name", { exact: true }).fill("Alice"); + await page.getByRole("button", { name: "Register new account", exact: true }) + .click(); +}; +const count = (mutations: Mutation[], path: string) => + mutations.filter((entry) => entry.path === path).length; +const at = async (page: Page, path: string) => { + await page.waitForURL((url) => + url.origin === origin && url.pathname + url.search === path + ); +}; +const noOverflow = async (page: Page) => + assertEquals( + await page.evaluate(() => + document.documentElement.scrollWidth > innerWidth + ), + false, + ); +async function isolated( + browser: Browser, + body: (harness: Harness) => Promise, + colorScheme: "light" | "dark" = "light", + width = 1440, +) { + const context = await browser.newContext({ + viewport: { width, height: 900 }, + colorScheme, + reducedMotion: "reduce", + }); + try { + // 不正returnToでも外部へ接続しない。遷移した事実は下のURL assertionで失敗する。 + await context.route("**/*", (route) => { + const url = new URL(route.request().url()); + return url.origin === origin ? route.continue() : route.abort(); + }); + const page = await context.newPage(); + const mutations: Mutation[] = []; + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("request", (request) => { + const path = new URL(request.url()).pathname; + if (request.method() === "POST" && path.startsWith("/api/")) { + // credential本文/secretは保持せず、対象を自由指定しない契約のfield名だけを観測する。 + mutations.push({ + path, + keys: Object.keys(request.postDataJSON() ?? {}).sort(), + }); + } + }); + await body({ + page, + mutations, + go: async (path, mode = "anonymous") => { + const url = new URL(path, origin); + url.searchParams.set("fixture", mode); + await page.goto(url.href); + }, + }); + await noOverflow(page); + assertEquals(errors, []); + } finally { + await context.close(); + } +} + +Deno.test("standalone auth separates login, registration, own account and explicit device approval", async (t) => { + const processes = await startOwnedProcesses( + [{ + id: "auth-browser", + command: "deno", + cwd: root, + args: [ + "run", + "--config", + "tools/web-ux/deno.json", + "--allow-read", + "--allow-net", + "web/workspace/test/auth-browser/fixture-server.ts", + "15391", + "web/workspace/build", + ], + readyUrl: `${origin}/health`, + }], + `${root}/tools/web-ux/scenarios/anonymous-entry.json`, + `${root}/target/web-ux/auth-interactions/processes`, + [], + ); + let browser: Browser | undefined; + try { + browser = await chromium.launch(); + for (const theme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390]) { + const step = ( + name: string, + body: (harness: Harness) => Promise, + ) => + t.step( + `${theme}/${width}: ${name}`, + () => isolated(browser!, body, theme, width), + ); + await step( + "anonymous account replaces history with login then returns to own account", + async ({ page, go, mutations }) => { + await page.goto(`${origin}/health`); + const historyBefore = await page.evaluate(() => history.length); + await go("/account"); + await at(page, "/login?returnTo=%2Faccount"); + assertEquals( + await page.evaluate(() => history.length), + historyBefore + 1, + ); + const id = page.getByLabel("Account ID (handle)", { exact: true }); + await id.waitFor(); + assertEquals( + await page.getByLabel("Display name", { exact: true }).count(), + 0, + ); + assertEquals(await page.locator("input").count(), 1); + assertEquals( + await page.getByRole("link", { + name: "Create a new account", + exact: true, + }).getAttribute("href"), + "/register?returnTo=%2Faccount", + ); + await page.getByRole("link", { + name: "Activate an account with a code", + exact: true, + }).waitFor(); + await id.fill("alice"); + await id.press("Tab"); + const button = page.getByRole("button", { + name: "Log in with passkey", + exact: true, + }); + assert( + await button.evaluate((element) => + element === document.activeElement && + getComputedStyle(element).outlineStyle !== "none" + ), + ); + await page.keyboard.press("Enter"); + await at(page, "/account"); + await page.getByRole("button", { + name: "Add passkey to alice", + exact: true, + }).waitFor(); + assertEquals(await page.locator("input").count(), 0); + await page.getByText("Alice", { exact: true }).first().waitFor(); + await noOverflow(page); + await page.getByRole("button", { + name: "Add passkey to alice", + exact: true, + }).click(); + await page.getByRole("status").filter({ + hasText: /added|registered/i, + }).waitFor(); + const additions = mutations.filter((entry) => + entry.path.includes("/addition/") + ); + assertEquals(additions.map((entry) => entry.path), [ + "/api/auth/passkeys/addition/options", + "/api/auth/passkeys/addition/complete", + ]); + assertEquals(additions[0].keys, ["browser_origin"]); + assertEquals(additions[1].keys, ["challenge_id", "credential"]); + assertEquals( + mutations.some((entry) => entry.path.includes("/registration/")), + false, + ); + await page.getByRole("button", { name: "Log out", exact: true }) + .click(); + await page.getByRole("button", { + name: "Log in with passkey", + exact: true, + }).waitFor(); + assertEquals(new URL(page.url()).pathname, "/login"); + await page.reload(); + await page.getByRole("button", { + name: "Log in with passkey", + exact: true, + }).waitFor(); + assertEquals(count(mutations, "/api/auth/logout"), 1); + }, + ); + await step( + "direct registration has only ID and display name and returns safely", + async ({ page, go, mutations }) => { + await go("/register?returnTo=%2Faccount"); + await page.getByLabel("Display name", { exact: true }).waitFor(); + assertEquals(await page.locator("input").count(), 2); + await register(page); + await at(page, "/account"); + await page.getByRole("button", { + name: "Add passkey to alice", + exact: true, + }).waitFor(); + assertEquals( + count(mutations, "/api/auth/passkeys/registration/options"), + 1, + ); + assertEquals( + count(mutations, "/api/auth/passkeys/registration/complete"), + 1, + ); + assertEquals( + mutations.some((entry) => entry.path.includes("/addition/")), + false, + ); + }, + ); + await step( + "device login restoration never approves without an explicit separate action", + async ({ page, go, mutations }) => { + await go("/login/device?user_code=ABCD-EFGH"); + await page.getByLabel("User code", { exact: true }).fill( + "WXYZ-1234", + ); + const entry = page.getByRole("link", { + name: "Log in to continue", + exact: true, + }); + const href = await entry.getAttribute("href"); + assert(href); + const returned = new URL(href, origin).searchParams.get("returnTo"); + assert(returned?.startsWith("/login/device?")); + assertEquals( + new URL(returned!, origin).searchParams.get("user_code"), + "WXYZ-1234", + ); + assertEquals( + await page.getByLabel("Account ID (handle)", { exact: true }) + .count(), + 0, + ); + assertEquals(count(mutations, "/api/auth/device-login/approve"), 0); + await entry.click(); + await login(page); + await page.waitForURL((url) => url.pathname === "/login/device"); + const approve = page.getByRole("button", { + name: "Approve device login", + exact: true, + }); + await approve.waitFor(); + assertEquals( + await page.getByLabel("User code", { exact: true }).inputValue(), + "WXYZ-1234", + ); + assertEquals(count(mutations, "/api/auth/device-login/approve"), 0); + await approve.click(); + await page.getByText( + "Device login approved. You can return to the CLI.", + { exact: true }, + ).waitFor(); + assertEquals(count(mutations, "/api/auth/device-login/approve"), 1); + }, + ); + await step( + "disabled registration retains existing-user login", + async ({ page, go, mutations }) => { + await go("/register", "policy-disabled"); + await page.getByText( + "New account registration is disabled. Existing users can still log in.", + { exact: true }, + ).waitFor(); + assertEquals( + await page.getByRole("button", { + name: "Register new account", + exact: true, + }).count(), + 0, + ); + assertEquals( + await page.getByLabel("Account ID (handle)", { exact: true }) + .count(), + 0, + ); + const link = page.locator('a[href^="/login"]').first(); + await link.click(); + await login(page); + await at(page, "/"); + await page.goto(`${origin}/account`); + await page.getByRole("button", { + name: "Add passkey to alice", + exact: true, + }).click(); + await page.getByRole("status").filter({ hasText: /added/i }) + .waitFor(); + assertEquals( + count(mutations, "/api/auth/passkeys/addition/complete"), + 1, + ); + assertEquals( + mutations.some((entry) => entry.path.includes("/registration/")), + false, + ); + }, + ); + await step( + "registration stopped during ceremony displays HTTP403 and removes registration after policy refresh", + async ({ page, go, mutations }) => { + await go("/register", "policy-stopped"); + await register(page); + await page.getByRole("alert").filter({ hasText: "403" }).waitFor(); + await page.getByText( + "New account registration is disabled. Existing users can still log in.", + { exact: true }, + ).waitFor(); + assertEquals(await page.locator("form").count(), 0); + assertEquals( + await page.getByRole("button", { + name: "Register new account", + exact: true, + }).count(), + 0, + ); + await page.getByRole("link", { + name: "Log in with an existing account", + exact: true, + }).waitFor(); + assertEquals(new URL(page.url()).pathname, "/register"); + assertEquals( + count(mutations, "/api/auth/passkeys/registration/complete"), + 1, + ); + await noOverflow(page); + }, + ); + for ( + const mode of [ + "session-error", + "session-malformed", + "session-loading", + ] + ) { + await step( + `${mode} does not become known anonymous or allow mutation`, + async ({ page, go, mutations }) => { + await go("/login", mode); + if (mode === "session-loading") { + await page.getByText("Loading session…", { exact: true }) + .waitFor(); + } else { + await page.getByRole("alert").waitFor(); + await page.getByRole("button", { + name: "Retry session", + exact: true, + }).waitFor(); + } + const loginButton = page.getByRole("button", { + name: "Log in with passkey", + exact: true, + }); + if (await loginButton.count()) { + assertEquals(await loginButton.isEnabled(), false); + } + assertEquals(mutations, []); + }, + ); + } + } + } + for ( + const path of [ + "/account", + "/register", + "/login/device?user_code=ABCD-EFGH", + ] + ) { + await t.step( + `unknown session on ${path} offers only recovery, never anonymous mutations`, + () => + isolated(browser!, async ({ page, go, mutations }) => { + await go(path, "session-error"); + await page.getByRole("button", { + name: "Retry session", + exact: true, + }).waitFor(); + assertEquals( + await page.getByRole("link", { + name: "Log in to continue", + exact: true, + }).count(), + 0, + ); + assertEquals( + await page.getByLabel("Account ID (handle)", { exact: true }) + .count(), + 0, + ); + assertEquals( + await page.getByRole("button", { + name: + /^(Add passkey|Register new account|Approve device login)/, + }).count(), + 0, + ); + assertEquals( + new URL(page.url()).pathname, + new URL(path, origin).pathname, + ); + assertEquals(mutations, []); + }), + ); + } + await t.step( + "policy lookup failure is unknown availability, not disabled or permitted registration", + () => + isolated(browser!, async ({ page, go, mutations }) => { + await go("/register", "policy-error"); + await page.getByRole("alert").waitFor(); + assertEquals( + await page.getByRole("button", { + name: "Register new account", + exact: true, + }).count(), + 0, + ); + await page.getByRole("link", { + name: "Log in with an existing account", + exact: true, + }).waitFor(); + assertEquals(mutations, []); + }), + ); + await t.step( + "leaving a pending login never completes or redirects after navigation", + () => + isolated(browser!, async ({ page, go, mutations }) => { + await go("/login", "login-pending"); + await login(page); + await page.waitForFunction(() => + typeof (window as unknown as { __authFixtureRelease?: unknown }) + .__authFixtureRelease === "function" + ); + await page.getByRole("link", { + name: "Create a new account", + exact: true, + }).click(); + await page.getByLabel("Display name", { exact: true }).waitFor(); + await page.evaluate(() => + (window as unknown as { __authFixtureRelease: () => void }) + .__authFixtureRelease() + ); + // 次のpaintまで旧promiseのmicrotaskを進め、固定sleepで完了を推測しない。 + await page.evaluate(() => + new Promise((resolve) => + requestAnimationFrame(() => + requestAnimationFrame(() => resolve()) + ) + ) + ); + assertEquals(new URL(page.url()).pathname, "/register"); + assertEquals( + count(mutations, "/api/auth/passkeys/login/complete"), + 0, + ); + }), + ); + await t.step( + "retry rechecks unknown session before allowing login", + () => + isolated(browser!, async ({ page, go }) => { + await go("/login", "session-retry"); + await page.getByRole("button", { name: "Retry session", exact: true }) + .click(); + await login(page); + await at(page, "/"); + }), + ); + for (const route of ["login", "register"]) { + await t.step( + `${route} without returnTo uses root fallback`, + () => + isolated(browser!, async ({ page, go }) => { + await go(`/${route}`); + await (route === "login" ? login(page) : register(page)); + await at(page, "/"); + await page.reload(); + assertEquals(new URL(page.url()).pathname, "/"); + }), + ); + for ( + const rejected of [ + "https://example.invalid/", + "//example.invalid/", + "/\\example.invalid/", + "account", + "/login", + "/register?returnTo=%2Flogin", + "/activate", + "/login/../login", + ] + ) { + await t.step( + `${route} rejects external or auth-loop returnTo ${rejected}`, + () => + isolated(browser!, async ({ page, go }) => { + await go(`/${route}?returnTo=${encodeURIComponent(rejected)}`); + await (route === "login" ? login(page) : register(page)); + await at(page, "/"); + }), + ); + } + } + for (const purpose of ["login", "registration", "addition"] as const) { + const route = purpose === "registration" + ? "/register" + : purpose === "addition" + ? "/account" + : "/login"; + for (const failure of ["cancel", "error", "http-error", "expired"]) { + await t.step( + `${purpose} ${failure} is visible and does not claim successful authentication`, + () => + isolated(browser!, async ({ page, go, mutations }) => { + await go(route, `${purpose}-${failure}`); + if (purpose === "login") await login(page); + else if (purpose === "registration") await register(page); + else {await page.getByRole("button", { + name: "Add passkey to alice", + exact: true, + }).click();} + await page.getByRole("alert").waitFor(); + const path = new URL(page.url()).pathname; + assert( + path === route || (purpose === "addition" && path === "/login"), + ); + if ( + failure === "cancel" || failure === "error" || + failure === "http-error" + ) { + assertEquals( + count(mutations, `/api/auth/passkeys/${purpose}/complete`), + 0, + ); + } else {assertEquals( + count(mutations, `/api/auth/passkeys/${purpose}/complete`), + 1, + );} + }), + ); + } + await t.step( + `${purpose} repeated submit while prompt is pending sends one ceremony`, + () => + isolated(browser!, async ({ page, go, mutations }) => { + await go(route, `${purpose}-pending`); + const label = purpose === "login" + ? "Log in with passkey" + : purpose === "registration" + ? "Register new account" + : "Add passkey to alice"; + if (purpose === "login") await login(page); + else if (purpose === "registration") await register(page); + else {await page.getByRole("button", { name: label, exact: true }) + .click();} + await page.waitForFunction(() => + typeof (window as unknown as { __authFixtureRelease?: unknown }) + .__authFixtureRelease === "function" + ); + assertEquals( + count(mutations, `/api/auth/passkeys/${purpose}/options`), + 1, + ); + assertEquals( + count(mutations, `/api/auth/passkeys/${purpose}/complete`), + 0, + ); + await page.getByRole("button", { name: label, exact: true }) + .evaluate((button) => { + button.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + button.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + await page.locator("form").evaluateAll((forms) => { + for (const form of forms) { + form.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + form.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + } + }); + await page.evaluate(() => + (window as unknown as { __authFixtureRelease: () => void }) + .__authFixtureRelease() + ); + if (purpose === "addition") { + await page.getByRole("status").filter({ + hasText: /added|registered/i, + }).waitFor(); + } else await at(page, "/"); + assertEquals( + count(mutations, `/api/auth/passkeys/${purpose}/options`), + 1, + ); + assertEquals( + count(mutations, `/api/auth/passkeys/${purpose}/complete`), + 1, + ); + }), + ); + } + await t.step( + "expired device request stays unapproved", + () => + isolated(browser!, async ({ page, go, mutations }) => { + await go("/login/device?user_code=ABCD-EFGH", "device-expired"); + await page.getByRole("link", { + name: "Log in to continue", + exact: true, + }).click(); + await login(page); + await page.getByRole("button", { + name: "Approve device login", + exact: true, + }).click(); + await page.getByRole("alert").filter({ hasText: /invalid|expired/i }) + .waitFor(); + assertEquals(count(mutations, "/api/auth/device-login/approve"), 1); + }), + ); + await t.step( + "activation entry remains limited until credential completion", + () => + isolated(browser!, async ({ page, go, mutations }) => { + await go("/login", "activation-pending"); + await page.getByRole("link", { + name: "Activate an account with a code", + exact: true, + }).click(); + // 同名labelのlogin入力が残る間にfillしない。遷移先の固有controlで同期する。 + await page.waitForURL((url) => url.pathname === "/activate"); + await page.locator("#activation-handle").fill("alice"); + await page.locator("#activation-code").fill( + "synthetic-input-only", + ); + await page.getByRole("button", { + name: "Register Passkey and activate", + exact: true, + }).click(); + await page.getByText( + "Passkey registration pending. Account activation has not been confirmed.", + { exact: true }, + ).waitFor(); + assertEquals(count(mutations, "/api/auth/activation/complete"), 0); + assertEquals( + mutations.some((entry) => + /device-login|addition|registration/.test(entry.path) + ), + false, + ); + assertEquals(await page.locator(".app-shell").count(), 0); + await page.getByRole("button", { + name: "Cancel registration", + exact: true, + }).click(); + await page.getByRole("alert").waitFor(); + const link = page.locator('a[href="/login"]'); + await link.waitFor(); + await link.click(); + await page.waitForURL((url) => url.pathname === "/login"); + await page.getByRole("button", { + name: "Log in with passkey", + exact: true, + }).waitFor(); + assertEquals(count(mutations, "/api/auth/device-login/approve"), 0); + }), + ); + } finally { + await browser?.close(); + await stopOwnedProcesses(processes); + } +}); diff --git a/web/workspace/test/auth-browser/capture.ts b/web/workspace/test/auth-browser/capture.ts new file mode 100644 index 000000000..3e979fa6e --- /dev/null +++ b/web/workspace/test/auth-browser/capture.ts @@ -0,0 +1,327 @@ +// shared captureがscreenshot/snapshot/review-contextを採取する。合否と本人目視は呼出元が行う。 +// 本番buildに合成HTTP/WebAuthnだけを接続し、撮影専用CSSや認証情報は使わない。 +import { resolve } from "@std/path"; +import { capture } from "../../../../tools/web-ux/src/capture.ts"; +import type { + Interaction, + RouteScenario, + Scenario, +} from "../../../../tools/web-ux/src/types.ts"; + +const root = resolve(import.meta.dirname!, "../../../.."); +const phase = Deno.args[0] ?? "after"; +if (!/^[a-zA-Z0-9_-]+$/.test(phase)) { + throw new Error("Expected safe capture label"); +} +const ready = (selector: string) => ({ kind: "selector" as const, selector }); +const click = (selector: string): Interaction => ({ + action: "click", + selector, +}); +const fill = (selector: string, value: string): Interaction => ({ + action: "fill", + selector, + value, +}); +const id = 'label:has-text("ID (handle)") input'; +const login = [ + fill(id, "alice"), + click('button:text-is("Log in with passkey")'), +]; +const register = [ + fill(id, "alice"), + fill('label:has-text("Display name") input', "Alice"), + click('button:text-is("Register new account")'), +]; +const route = ( + id: string, + path: string, + selector: string, + capturePoints: RouteScenario["capturePoints"] = [{ + id: "initial", + label: id, + fullPage: true, + }], +): RouteScenario => ({ + id, + label: id, + path, + goal: + "Review standalone production auth UI, navigation, focus, state and copy; not Server authorization.", + dataState: `Synthetic ${id}; no real session, credential or activation code.`, + ready: ready(selector), + capturePoints, +}); +const routes: RouteScenario[] = [ + route("login", "/login?fixture=anonymous", `${id}:enabled`, [ + { + id: "initial", + label: "ID-only login; registration and activation entries", + fullPage: true, + }, + { + id: "keyboard-focus", + label: "Login keyboard focus", + fullPage: true, + interaction: [fill(id, "alice"), { + action: "press", + selector: id, + key: "Tab", + }], + ready: ready('button:text-is("Log in with passkey"):focus-visible'), + }, + ]), + route( + "account-redirect", + "/account?fixture=anonymous", + 'button:text-is("Log in with passkey")', + ), + route("register", "/register?fixture=anonymous", `${id}:enabled`, [ + { + id: "initial", + label: "ID and distinct display name; new account only", + fullPage: true, + }, + { + id: "keyboard-focus", + label: "Register keyboard focus", + fullPage: true, + interaction: [ + fill(id, "alice"), + fill('label:has-text("Display name") input', "Alice"), + { + action: "press", + selector: 'label:has-text("Display name") input', + key: "Tab", + }, + ], + ready: ready('button:text-is("Register new account"):focus-visible'), + }, + ]), + route( + "account", + "/account?fixture=account", + 'button:text-is("Add passkey to alice")', + [ + { + id: "initial", + label: "Own account only; no target handle field", + fullPage: true, + }, + { + id: "addition-confirmed", + label: "Own Passkey addition confirmed", + fullPage: true, + interaction: [click('button:text-is("Add passkey to alice")')], + ready: ready('[role="status"]:has-text("Passkey added")'), + }, + { + id: "logout", + label: "Log out returns to login", + fullPage: true, + interaction: [click('button:text-is("Log out")')], + ready: ready('button:text-is("Log in with passkey")'), + }, + ], + ), + route( + "device-anonymous", + "/login/device?user_code=ABCD-EFGH&fixture=anonymous", + 'a:text-is("Log in to continue")', + [ + { + id: "initial", + label: "User code and login continuation; no implicit approval", + fullPage: true, + }, + { + id: "login", + label: "Dedicated login with device returnTo", + fullPage: true, + interaction: [click('a:text-is("Log in to continue")')], + ready: ready(`${id}:enabled`), + }, + { + id: "authenticated-unapproved", + label: "Login complete; explicit approval still required", + fullPage: true, + interaction: login, + ready: ready('button:text-is("Approve device login"):enabled'), + }, + { + id: "approved", + label: "Separate explicit approval confirmed", + fullPage: true, + interaction: [click('button:text-is("Approve device login")')], + ready: ready( + 'text="Device login approved. You can return to the CLI."', + ), + }, + ], + ), + route( + "session-loading", + "/login?fixture=session-loading", + 'text="Loading session…"', + ), + route( + "session-error", + "/login?fixture=session-error", + 'button:text-is("Retry session")', + ), + route( + "account-session-error", + "/account?fixture=session-error", + 'button:text-is("Retry session")', + ), + route( + "register-session-error", + "/register?fixture=session-error", + 'button:text-is("Retry session")', + ), + route( + "device-session-error", + "/login/device?user_code=ABCD-EFGH&fixture=session-error", + 'button:text-is("Retry session")', + ), + route( + "policy-disabled", + "/register?fixture=policy-disabled", + 'text="New account registration is disabled. Existing users can still log in."', + ), + route("policy-error", "/register?fixture=policy-error", '[role="alert"]'), + route("policy-stopped", "/register?fixture=policy-stopped", `${id}:enabled`, [ + { + id: "completion-403", + label: + "Policy refreshed to disabled after intentional registration HTTP403", + fullPage: true, + interaction: register, + ready: ready( + 'text="New account registration is disabled. Existing users can still log in."', + ), + }, + ]), + ...["login", "registration", "addition"].flatMap((purpose) => + ["pending", "cancel", "error", "expired"].map((state) => { + const path = purpose === "registration" + ? "/register" + : purpose === "addition" + ? "/account" + : "/login"; + const interaction = purpose === "login" + ? login + : purpose === "registration" + ? register + : [click('button:text-is("Add passkey to alice")')]; + return route( + `${purpose}-${state}`, + `${path}?fixture=${purpose}-${state}`, + purpose === "addition" + ? 'button:text-is("Add passkey to alice")' + : `${id}:enabled`, + [{ + id: "result", + label: `${purpose} ${state}; not a successful ceremony`, + fullPage: true, + interaction, + ready: ready( + state === "pending" ? '[role="status"]' : '[role="alert"]', + ), + }], + ); + }) + ), + route( + "activation-entry", + "/login?fixture=activation-pending", + 'a:text-is("Activate an account with a code")', + [ + { + id: "entry", + label: "Existing activation route from login", + fullPage: true, + interaction: [click('a:text-is("Activate an account with a code")')], + ready: ready("#activation-code:enabled"), + }, + { + id: "limited", + label: + "No ordinary session or device approval before Passkey completion", + fullPage: true, + interaction: [ + fill("#activation-handle", "alice"), + fill("#activation-code", "synthetic-input-only"), + click('button:text-is("Register Passkey and activate")'), + ], + ready: ready('button:text-is("Cancel registration")'), + }, + { + id: "cancelled", + label: "Activation cancellation and login recovery entry", + fullPage: true, + interaction: [click('button:text-is("Cancel registration")')], + ready: ready('[role="alert"]'), + }, + ], + ), +]; +await Deno.mkdir(`${root}/target/web-ux`, { recursive: true }); +for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390]) { + const scenario: Scenario = { + schemaVersion: 1, + id: `auth-${colorScheme}-${width}`, + title: + "Standalone authentication synthetic projection on production build", + baseUrl: "http://127.0.0.1:15392", + colorScheme, + reducedMotion: "reduce", + personas: [{ + id: "fixture", + label: "Synthetic HTTP/WebAuthn only", + auth: { kind: "anonymous" }, + }], + viewports: [{ width, height: 900, label: `${width}` }], + routes, + redact: { + // 初期撮影後にもfillする。空の入力まで恒久的に隠すと次の操作が不能になる。 + // 合成codeはsubmit直後に破棄され、pendingのdisabled入力だけを隠す。 + selectors: ["#activation-code:disabled", ".secret"], + text: ["synthetic-input-only"], + }, + processes: [{ + id: "auth-projection", + command: "deno", + cwd: root, + args: [ + "run", + "--config", + "tools/web-ux/deno.json", + "--allow-read", + "--allow-net", + "web/workspace/test/auth-browser/fixture-server.ts", + "15392", + "web/workspace/build", + ], + readyUrl: "http://127.0.0.1:15392/health", + }], + }; + const scenarioPath = + `${root}/target/web-ux/auth-${phase}-${colorScheme}-${width}.json`; + await Deno.writeTextFile(scenarioPath, JSON.stringify(scenario, null, 2)); + const result = await capture({ + scenarioPath, + outputDirectory: `${root}/target/web-ux`, + runId: `auth-${phase}-${colorScheme}-${width}`, + }); + console.log( + JSON.stringify({ + colorScheme, + width, + status: result.status, + captures: result.captures.length, + }), + ); + } +} diff --git a/web/workspace/test/auth-browser/fixture-server.ts b/web/workspace/test/auth-browser/fixture-server.ts new file mode 100644 index 000000000..8661f5037 --- /dev/null +++ b/web/workspace/test/auth-browser/fixture-server.ts @@ -0,0 +1,195 @@ +// 本番build/client/shellだけを検証する合成HTTP/WebAuthn境界。 +// 実認証・署名・cookie安全性・policyの永続化は検証しない。本文/credentialは記録しない。 +import { extname, join, resolve } from "@std/path"; +import { setupWebAuthnProjection } from "../hosting-browser/setup-projection.ts"; + +const [port, build] = Deno.args; +if (!port || !build) throw new Error("Expected port and production build root"); +const root = resolve(build); +const user = { + user_id: "fixture-user", + account_id: "fixture-account", + handle: "alice", + display_name: "Alice", +}; +type State = { + mode: string; + signedIn: boolean; + whoamiCalls: number; + selfRegistrationEnabled: boolean; +}; +const sessions = new Map(); +const mime: Record = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".woff2": "font/woff2", + ".png": "image/png", + ".ico": "image/x-icon", +}; +const options = (login: boolean) => ({ + challenge_id: "synthetic-challenge-only", + public_key: login ? { challenge: "dWk" } : { + challenge: "dWk", + rp: { name: "UI projection" }, + user: { id: "dWk", name: "alice", displayName: "Alice" }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + }, +}); + +Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => { + const url = new URL(request.url); + const path = url.pathname; + if (path === "/health") return new Response("ok"); + const selected = url.searchParams.get("fixture"); + let id = request.headers.get("cookie")?.match(/auth-fixture=([\w-]+)/)?.[1]; + // routeごとに独立。クライアントgoto後も同じ合成sessionをwhoamiへ投影する。 + if (selected || !id || !sessions.has(id)) { + id = crypto.randomUUID(); + sessions.set(id, { + mode: selected ?? "anonymous", + signedIn: selected === "account" || + selected?.startsWith("addition-") === true, + whoamiCalls: 0, + selfRegistrationEnabled: selected !== "policy-disabled", + }); + } + const state = sessions.get(id)!; + const { mode } = state; + if (path === "/__ui_fixture/webauthn.js") { + let extra = ""; + if (mode.endsWith("-cancel")) { + extra = + `for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Passkey cancelled','NotAllowedError')}});`; + } else if (mode.endsWith("-error")) { + extra = + `for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Synthetic authenticator failure','UnknownError')}});`; + } else if (mode.endsWith("-pending")) { + extra = + `for (const method of ['get','create']) { const original=navigator.credentials[method].bind(navigator.credentials); Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{ await new Promise(resolve=>{window.__authFixtureRelease=resolve}); return original(); }}); }`; + } + return new Response(setupWebAuthnProjection + extra, { + headers: { + "content-type": "text/javascript", + "cache-control": "no-store", + }, + }); + } + if (path === "/api/auth/whoami") { + state.whoamiCalls++; + if (mode === "session-loading") { + return await new Promise(() => {}); + } + if ( + mode === "session-error" || + (mode === "session-retry" && state.whoamiCalls === 1) + ) { + return new Response(null, { status: 503 }); + } + if (mode === "session-malformed") { + return Response.json({ actor: { handle: "untrusted" } }); + } + return Response.json({ + actor: state.signedIn + ? { ...user, auth_method: "browser_session" } + : null, + }); + } + if (path === "/api/auth/registration-policy") { + if (mode === "policy-error") return new Response(null, { status: 503 }); + return Response.json({ + self_registration_enabled: state.selfRegistrationEnabled, + }); + } + if (path === "/api/hosting/access") return Response.json({ is_admin: false }); + if (path === "/api/workspaces") { + // fallbackは現行Workspace選択まで。公開トップ/T760の挙動を新設しない。 + return state.signedIn + ? Response.json([]) + : new Response(null, { status: 401 }); + } + if (path === "/api/auth/logout") { + state.signedIn = false; + return Response.json({ status: "logged_out" }); + } + if (path === "/api/auth/device-login/approve") { + if (!state.signedIn) return new Response(null, { status: 401 }); + if (mode === "device-expired") return new Response(null, { status: 410 }); + return Response.json({ status: "approved", user }); + } + const passkey = path.match( + /^\/api\/auth\/passkeys\/(login|registration|addition)\/(options|complete)$/, + ); + if (passkey) { + const [, purpose, stage] = passkey; + if (purpose === "addition" && !state.signedIn) { + return new Response(null, { status: 401 }); + } + if (purpose === "registration") { + if (mode === "policy-stopped" && stage === "complete") { + state.selfRegistrationEnabled = false; + } + if (!state.selfRegistrationEnabled) { + return new Response(null, { status: 403 }); + } + } + if (mode.endsWith("-http-error") && stage === "options") { + return new Response(null, { status: 503 }); + } + if (mode === `${purpose}-expired` && stage === "complete") { + if (purpose === "addition") state.signedIn = false; + return new Response(null, { status: 401 }); + } + if (stage === "options") return Response.json(options(purpose === "login")); + state.signedIn = true; + return Response.json({ user }); + } + if (path === "/api/auth/activation/options") { + return Response.json(options(false)); + } + if (path === "/api/auth/activation/complete") { + state.signedIn = true; + return Response.json({ user }); + } + if (path.startsWith("/api/")) return new Response(null, { status: 404 }); + let file: string; + try { + file = join(root, decodeURIComponent(path)); + } catch { + return new Response(null, { status: 400 }); + } + if (!file.startsWith(root + "/")) return new Response(null, { status: 400 }); + let bytes: Uint8Array; + let extension = extname(file); + try { + bytes = await Deno.readFile(file); + } catch (error) { + if ( + !(error instanceof Deno.errors.NotFound) && + !(error instanceof Deno.errors.IsADirectory) + ) throw error; + bytes = await Deno.readFile(join(root, "index.html")); + extension = ".html"; + } + if (extension === ".html") { + bytes = new TextEncoder().encode( + new TextDecoder().decode(bytes).replace( + "", + '', + ), + ); + } + const headers = new Headers({ + "content-type": mime[extension] ?? "application/octet-stream", + "cache-control": "no-store", + }); + if (extension === ".html") { + headers.set( + "set-cookie", + `auth-fixture=${id}; Path=/; SameSite=Strict; HttpOnly`, + ); + } + return new Response(bytes as Uint8Array, { headers }); +}); diff --git a/web/workspace/test/hosting-browser/hosting-shell.browser.test.ts b/web/workspace/test/hosting-browser/hosting-shell.browser.test.ts index 0fa32136f..ce0e29d5b 100644 --- a/web/workspace/test/hosting-browser/hosting-shell.browser.test.ts +++ b/web/workspace/test/hosting-browser/hosting-shell.browser.test.ts @@ -4,7 +4,10 @@ import { assert, assertEquals } from "@std/assert"; import { resolve } from "@std/path"; import { chromium, type Page } from "playwright"; -import { startOwnedProcesses, stopOwnedProcesses } from "../../../../tools/web-ux/src/processes.ts"; +import { + startOwnedProcesses, + stopOwnedProcesses, +} from "../../../../tools/web-ux/src/processes.ts"; const root = resolve(import.meta.dirname!, "../../../.."); const evidence = `${root}/target/web-ux/hosting/interactions`; async function noOverflow(page: Page) { @@ -63,7 +66,9 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa }); page.on("response", (response) => { if (response.status() >= 400) { - httpErrors.push(`${response.status()} ${new URL(response.url()).pathname}`); + httpErrors.push( + `${response.status()} ${new URL(response.url()).pathname}`, + ); } }); page.on( @@ -80,18 +85,34 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa settingsRequests.push(request.method()); } }); - await page.goto("http://127.0.0.1:15379/account?fixture=admin-on"); - await page.getByRole("button", { name: "Add passkey to alice" }).waitFor(); + await page.goto("http://127.0.0.1:15379/?fixture=admin-on"); + await page.getByRole("heading", { name: "Workspaces", exact: true }) + .waitFor(); if ( - await page.getByRole("button", { name: "Show sidebar", exact: true }).isVisible() - ) await page.getByRole("button", { name: "Show sidebar", exact: true }).click(); + await page.getByRole("button", { + name: "Show sidebar", + exact: true, + }).isVisible() + ) { + await page.getByRole("button", { + name: "Show sidebar", + exact: true, + }).click(); + } const nav = page.getByRole("navigation", { name: "Global pages" }); - await nav.getByRole("link", { name: "Hosting settings", exact: true }).waitFor(); + await nav.getByRole("link", { name: "Hosting settings", exact: true }) + .waitFor(); await nav.getByRole("link", { name: "Account", exact: true }).focus(); await page.keyboard.press("Tab"); - assertEquals(await page.locator(":focus").innerText(), "Device Login"); + assertEquals( + await page.locator(":focus").innerText(), + "Device Login", + ); await page.keyboard.press("Tab"); - assertEquals(await page.locator(":focus").innerText(), "Hosting settings"); + assertEquals( + await page.locator(":focus").innerText(), + "Hosting settings", + ); assertEquals( await page.locator(":focus").evaluate((element) => getComputedStyle(element).outlineStyle !== "none" @@ -99,42 +120,80 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa true, ); await page.keyboard.press("Enter"); - const edit = page.getByRole("button", { name: "Edit registration policy" }); + const edit = page.getByRole("button", { + name: "Edit registration policy", + }); await edit.waitFor(); // Globalの入口からhostingへ進んだ後はWorkspace shellを離れる。 // Workspace未作成でも管理できることと整合させる。 assertEquals(await nav.count(), 0); - await page.getByRole("heading", { name: "Hosting settings", exact: true }).waitFor(); + await page.getByRole("heading", { + name: "Hosting settings", + exact: true, + }).waitFor(); if ( - await page.getByRole("button", { name: "Hide sidebar", exact: true }).isVisible() && + await page.getByRole("button", { + name: "Hide sidebar", + exact: true, + }).isVisible() && width < 768 - ) await page.getByRole("button", { name: "Hide sidebar", exact: true }).click(); + ) { + await page.getByRole("button", { + name: "Hide sidebar", + exact: true, + }).click(); + } await noOverflow(page); await edit.focus(); await page.keyboard.press("Enter"); - const checkbox = page.getByRole("checkbox", { name: "Allow new account registration" }); + const checkbox = page.getByRole("checkbox", { + name: "Allow new account registration", + }); assertEquals( - await checkbox.evaluate((element) => element === document.activeElement), + await checkbox.evaluate((element) => + element === document.activeElement + ), true, ); assertEquals(await checkbox.isChecked(), true); await page.keyboard.press("Space"); - await page.getByRole("button", { name: "Cancel", exact: true }).click(); - assertEquals(await edit.evaluate((element) => element === document.activeElement), true); + await page.getByRole("button", { name: "Cancel", exact: true }) + .click(); + assertEquals( + await edit.evaluate((element) => + element === document.activeElement + ), + true, + ); await page.keyboard.press("Enter"); assertEquals(await checkbox.isChecked(), true); await page.keyboard.press("Space"); await page.keyboard.press("Tab"); - assertEquals(await page.locator(":focus").innerText(), "Save settings"); + assertEquals( + await page.locator(":focus").innerText(), + "Save settings", + ); await page.keyboard.press("Enter"); - await page.getByRole("status").filter({ hasText: "Hosting settings saved." }).waitFor(); - assertEquals(await edit.evaluate((element) => element === document.activeElement), true); + await page.getByRole("status").filter({ + hasText: "Hosting settings saved.", + }).waitFor(); + assertEquals( + await edit.evaluate((element) => + element === document.activeElement + ), + true, + ); await page.getByText("Disabled", { exact: true }).waitFor(); - assertEquals(settingsRequests.filter((method) => method === "PUT").length, 1); + assertEquals( + settingsRequests.filter((method) => method === "PUT").length, + 1, + ); await noOverflow(page); for (const mode of ["ordinary", "anonymous"]) { settingsRequests.length = 0; - await page.goto(`http://127.0.0.1:15379/hosting/settings?fixture=${mode}`); + await page.goto( + `http://127.0.0.1:15379/hosting/settings?fixture=${mode}`, + ); await page.getByText( mode === "ordinary" ? "Hosting administrator access is required." @@ -144,41 +203,62 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa assertEquals(await edit.count(), 0); assertEquals(await checkbox.count(), 0); assertEquals( - await nav.getByRole("link", { name: "Hosting settings", exact: true }).count(), + await nav.getByRole("link", { + name: "Hosting settings", + exact: true, + }).count(), 0, ); assertEquals(settingsRequests, []); await noOverflow(page); } - await page.goto("http://127.0.0.1:15379/account?fixture=account-off"); + await page.goto( + "http://127.0.0.1:15379/register?fixture=account-off", + ); await page.getByText( "New account registration is disabled. Existing users can still log in.", ).waitFor(); - assertEquals(await page.getByRole("button", { name: "Register new account" }).count(), 0); assertEquals( - await page.getByRole("button", { name: "Log in with passkey" }).isEnabled(), - true, + await page.getByRole("button", { name: "Register new account" }) + .count(), + 0, ); + await page.getByRole("link", { + name: "Log in with an existing account", + }).waitFor(); await noOverflow(page); - await page.goto("http://127.0.0.1:15379/account?fixture=ordinary-account-off"); - await page.getByRole("button", { name: "Add passkey to alice" }).waitFor(); + await page.goto( + "http://127.0.0.1:15379/account?fixture=ordinary-account-off", + ); + await page.getByRole("button", { name: "Add passkey to alice" }) + .waitFor(); assertEquals( - await page.getByRole("button", { name: "Add passkey to alice" }).isEnabled(), + await page.getByRole("button", { name: "Add passkey to alice" }) + .isEnabled(), true, ); - await page.goto("http://127.0.0.1:15379/hosting/settings?fixture=admin-save-error"); + await page.goto( + "http://127.0.0.1:15379/hosting/settings?fixture=admin-save-error", + ); await edit.click(); await checkbox.press("Space"); - await page.getByRole("button", { name: "Save settings", exact: true }).click(); + await page.getByRole("button", { name: "Save settings", exact: true }) + .click(); await page.getByRole("alert").waitFor(); assertEquals(await checkbox.isChecked(), false); assertEquals(await checkbox.isDisabled(), true); assertEquals( - await page.getByRole("button", { name: "Save settings", exact: true }).isDisabled(), + await page.getByRole("button", { + name: "Save settings", + exact: true, + }).isDisabled(), true, ); await noOverflow(page); - await page.getByRole("button", { name: "Reload settings", exact: true }).click(); + await page.getByRole("button", { + name: "Reload settings", + exact: true, + }).click(); await edit.waitFor(); assertEquals(await page.getByRole("alert").count(), 0); assertEquals(await edit.isEnabled(), true); @@ -189,7 +269,11 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa "/hosting/settings", 'button:has-text("Retry loading settings")', ], - ["account-error", "/account", 'button:has-text("Retry registration availability")'], + [ + "account-error", + "/register", + 'button:has-text("Retry registration availability")', + ], [ "admin-loading", "/hosting/settings", @@ -197,7 +281,7 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa ], [ "account-loading", - "/account", + "/register", '[role="status"]:has-text("Checking registration availability")', ], ] @@ -242,7 +326,10 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa } } } - await Deno.writeTextFile(`${evidence}/results.json`, JSON.stringify(results, null, 2)); + await Deno.writeTextFile( + `${evidence}/results.json`, + JSON.stringify(results, null, 2), + ); } finally { await browser.close(); await stopOwnedProcesses(processes); diff --git a/web/workspace/test/spa-shell.test.ts b/web/workspace/test/spa-shell.test.ts index 6d84bb9f0..d4350e545 100644 --- a/web/workspace/test/spa-shell.test.ts +++ b/web/workspace/test/spa-shell.test.ts @@ -1,10 +1,14 @@ import { assertEquals } from "jsr:@std/assert"; import { load, ssr } from "../src/routes/+layout.ts"; -Deno.test("activation and hosting shells never require Workspace catalog authority", async () => { +Deno.test("authentication and hosting shells never require Workspace catalog authority", async () => { for ( const path of [ "/activate", + "/login", + "/register", + "/account", + "/login/device", "/hosting/accounts", "/hosting/settings", "/hosting/setup",