From 774019119483a8b7f4c77f22805a02b64d272128 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 23 Jul 2026 03:14:43 +0900 Subject: [PATCH] web: add account passkey login UI --- crates/workspace-server/src/server.rs | 41 ++++- crates/workspace-server/src/store.rs | 36 ++++ web/workspace/deno.json | 2 +- web/workspace/src/app.css | 39 +++++ web/workspace/src/lib/workspace/auth/api.ts | 102 +++++++++++ .../src/lib/workspace/auth/model.test.ts | 66 ++++++++ web/workspace/src/lib/workspace/auth/model.ts | 142 ++++++++++++++++ .../console/worker-console.ui.test.ts | 43 +++++ .../workspace/sidebar/WorkspaceSidebar.svelte | 12 ++ web/workspace/src/routes/+layout.ts | 3 +- web/workspace/src/routes/account/+page.svelte | 160 ++++++++++++++++++ .../src/routes/login/device/+page.svelte | 118 +++++++++++++ 12 files changed, 760 insertions(+), 4 deletions(-) create mode 100644 web/workspace/src/lib/workspace/auth/api.ts create mode 100644 web/workspace/src/lib/workspace/auth/model.test.ts create mode 100644 web/workspace/src/lib/workspace/auth/model.ts create mode 100644 web/workspace/src/routes/account/+page.svelte create mode 100644 web/workspace/src/routes/login/device/+page.svelte diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index a24dd637..033af463 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -38,8 +38,8 @@ use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeE use crate::auth::{ AuthPublicConfig, AuthenticatedUser, RequestActor, auth_error, is_expired, mint_secret, new_id, - new_user_code, normalize_handle, resolve_request_actor, rfc3339_after, session_set_cookie, - token_hash, + new_user_code, normalize_handle, parse_cookie, resolve_request_actor, rfc3339_after, + session_set_cookie, token_hash, }; use crate::companion::{ CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, @@ -337,6 +337,7 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/auth/passkeys/login/complete", post(post_passkey_login_complete), ) + .route("/api/auth/logout", post(post_auth_logout)) .route("/api/auth/device-login/start", post(post_device_login_start)) .route("/api/auth/device-login/approve", post(post_device_login_approve)) .route("/api/auth/device-login/poll", post(post_device_login_poll)) @@ -2595,6 +2596,11 @@ struct WhoamiResponse { actor: Option, } +#[derive(Debug, Serialize, Deserialize)] +struct LogoutResponse { + status: String, +} + async fn get_auth_config(State(api): State) -> ApiResult> { Ok(Json(auth_public_config(&api.config))) } @@ -3023,6 +3029,37 @@ async fn get_auth_whoami( })) } +async fn post_auth_logout( + State(api): State, + headers: HeaderMap, +) -> ApiResult { + let auth = auth_public_config(&api.config); + if let Some(session_token) = parse_cookie(&headers, &auth.cookie_name) { + let _ = api + .store + .revoke_browser_session(&token_hash(&session_token), &crate::auth::now_rfc3339())?; + } + let mut response_headers = HeaderMap::new(); + response_headers.insert( + SET_COOKIE, + session_set_cookie(&auth.cookie_name, "", 0) + .parse() + .map_err(|error| { + auth_error( + "invalid_session_cookie", + &format!("failed to build logout cookie: {error}"), + ) + })?, + ); + Ok(( + response_headers, + Json(LogoutResponse { + status: "logged_out".to_string(), + }), + ) + .into_response()) +} + fn webauthn(config: &ServerConfig) -> ApiResult { let auth = auth_public_config(config); let origin = Url::parse(&auth.origin) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 1d4067e1..a48ffaea 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -243,6 +243,7 @@ pub trait ControlPlaneStore: Send + Sync { ) -> Result>; fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()>; fn resolve_browser_session(&self, token_hash: &str) -> Result>; + fn revoke_browser_session(&self, token_hash: &str, revoked_at: &str) -> Result; fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()>; fn resolve_api_token(&self, token_hash: &str) -> Result>; fn mark_api_token_used(&self, token_hash: &str, used_at: &str) -> Result<()>; @@ -621,6 +622,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn revoke_browser_session(&self, token_hash: &str, revoked_at: &str) -> Result { + self.with_conn(|conn| { + let changed = conn.execute( + "UPDATE browser_sessions SET revoked_at = ?2 WHERE token_hash = ?1 AND revoked_at IS NULL", + params![token_hash, revoked_at], + )?; + Ok(changed > 0) + }) + } + fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()> { self.with_conn(|conn| { conn.execute( @@ -2455,6 +2466,31 @@ mod tests { None ); + let browser_session = BrowserSessionRecord { + session_id: "session-id".to_string(), + token_hash: "session-hash".to_string(), + user_id: user.user_id.clone(), + created_at: now.clone(), + expires_at: "2026-07-22T12:00:00Z".to_string(), + revoked_at: None, + }; + store.create_browser_session(&browser_session).unwrap(); + assert_eq!( + store.resolve_browser_session("session-hash").unwrap(), + Some(browser_session) + ); + assert!( + store + .revoke_browser_session("session-hash", "2026-07-22T00:02:00Z") + .unwrap() + ); + assert_eq!(store.resolve_browser_session("session-hash").unwrap(), None); + assert!( + !store + .revoke_browser_session("session-hash", "2026-07-22T00:03:00Z") + .unwrap() + ); + let api_token = ApiTokenRecord { token_id: "token-id".to_string(), token_hash: "hash".to_string(), diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 6a62f1d7..ca17f4df 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/api/http.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts", + "test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/app.css b/web/workspace/src/app.css index 6ebc935c..8d047123 100644 --- a/web/workspace/src/app.css +++ b/web/workspace/src/app.css @@ -872,6 +872,45 @@ color: var(--text-muted); } +.error-message { + margin: 0; + color: var(--danger); +} + +.account-details { + display: grid; + gap: var(--space-2); + margin: var(--space-3) 0; +} + +.account-details div { + display: grid; + grid-template-columns: minmax(8rem, 0.35fr) 1fr; + gap: var(--space-3); + align-items: baseline; +} + +.account-details dt { + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; +} + +.account-details dd { + margin: 0; + color: var(--text-strong); +} + +.account-details code { + font-size: 0.78rem; +} + +.account-panel { + display: grid; + gap: var(--space-4); +} + @media (max-width: 760px) { .settings-hero, .settings-notice, diff --git a/web/workspace/src/lib/workspace/auth/api.ts b/web/workspace/src/lib/workspace/auth/api.ts new file mode 100644 index 00000000..262aba36 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/api.ts @@ -0,0 +1,102 @@ +import { + authenticationCredentialToJson, + isPublicKeyCredential, + prepareLoginOptions, + prepareRegistrationOptions, + registrationCredentialToJson, + type DeviceApprovalResponse, + type PasskeyLoginOptionsResponse, + type PasskeyRegistrationOptionsResponse, + type PasskeyUserResponse, + type WhoamiResponse, +} from "./model"; + +async function jsonOrThrow(response: Response): Promise { + const text = await response.text(); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}${text ? `: ${text}` : ""}`); + } + return text ? JSON.parse(text) as T : (null as T); +} + +export async function loadWhoami(fetcher: typeof fetch = fetch): Promise { + return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then(jsonOrThrow); +} + +export async function registerPasskey( + handle: string, + displayName: string, + fetcher: typeof fetch = fetch, +): Promise { + const options = await fetcher("/api/auth/passkeys/registration/options", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ handle, display_name: displayName }), + }).then(jsonOrThrow); + + const credential = await navigator.credentials.create({ + publicKey: prepareRegistrationOptions(options), + }); + if (!isPublicKeyCredential(credential)) { + throw new Error("Passkey registration did not return a public-key credential."); + } + + return await fetcher("/api/auth/passkeys/registration/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ + challenge_id: options.challenge_id, + credential: registrationCredentialToJson(credential), + }), + }).then(jsonOrThrow); +} + +export async function loginWithPasskey( + handle: string, + fetcher: typeof fetch = fetch, +): Promise { + const options = await fetcher("/api/auth/passkeys/login/options", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ handle }), + }).then(jsonOrThrow); + + const credential = await navigator.credentials.get({ + publicKey: prepareLoginOptions(options), + }); + if (!isPublicKeyCredential(credential)) { + throw new Error("Passkey login did not return a public-key credential."); + } + + return await fetcher("/api/auth/passkeys/login/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ + challenge_id: options.challenge_id, + credential: authenticationCredentialToJson(credential), + }), + }).then(jsonOrThrow); +} + +export async function logout(fetcher: typeof fetch = fetch): Promise { + await fetcher("/api/auth/logout", { + method: "POST", + credentials: "same-origin", + }).then(jsonOrThrow); +} + +export async function approveDeviceLogin( + userCode: string, + fetcher: typeof fetch = fetch, +): Promise { + return await fetcher("/api/auth/device-login/approve", { + method: "POST", + headers: { "content-type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ user_code: userCode }), + }).then(jsonOrThrow); +} diff --git a/web/workspace/src/lib/workspace/auth/model.test.ts b/web/workspace/src/lib/workspace/auth/model.test.ts new file mode 100644 index 00000000..443443aa --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/model.test.ts @@ -0,0 +1,66 @@ +import { + base64UrlToBuffer, + bufferToBase64Url, + prepareLoginOptions, + prepareRegistrationOptions, +} from "./model.ts"; + +declare const Deno: { + test(name: string, fn: () => void | Promise): void; +}; + +function assertEquals(actual: T, expected: T): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function bytes(buffer: BufferSource): number[] { + if (buffer instanceof ArrayBuffer) { + return [...new Uint8Array(buffer)]; + } + return [...new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)]; +} + +Deno.test("base64url helpers round-trip binary data", () => { + const source = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255]).buffer; + const encoded = bufferToBase64Url(source); + assertEquals(encoded.includes("+"), false); + assertEquals(encoded.includes("/"), false); + assertEquals(encoded.includes("="), false); + assertEquals(bytes(base64UrlToBuffer(encoded)), bytes(source)); +}); + +Deno.test("prepareRegistrationOptions decodes binary public key fields", () => { + const options = prepareRegistrationOptions({ + challenge_id: "challenge-1", + public_key: { + challenge: "AQID" as unknown as BufferSource, + rp: { id: "localhost", name: "Yoi" }, + user: { + id: "BAUG" as unknown as BufferSource, + name: "local", + displayName: "Local User", + }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + excludeCredentials: [{ type: "public-key", id: "BwgJ" as unknown as BufferSource }], + }, + }); + + assertEquals(bytes(options.challenge), [1, 2, 3]); + assertEquals(bytes(options.user.id), [4, 5, 6]); + assertEquals(bytes(options.excludeCredentials?.[0].id as BufferSource), [7, 8, 9]); +}); + +Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", () => { + const options = prepareLoginOptions({ + challenge_id: "challenge-1", + public_key: { + challenge: "AQID" as unknown as BufferSource, + allowCredentials: [{ type: "public-key", id: "BwgJ" as unknown as BufferSource }], + }, + }); + + assertEquals(bytes(options.challenge), [1, 2, 3]); + assertEquals(bytes(options.allowCredentials?.[0].id as BufferSource), [7, 8, 9]); +}); diff --git a/web/workspace/src/lib/workspace/auth/model.ts b/web/workspace/src/lib/workspace/auth/model.ts new file mode 100644 index 00000000..129641a1 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/model.ts @@ -0,0 +1,142 @@ +export type AuthenticatedUser = { + user_id: string; + account_id: string; + handle: string; + display_name: string; +}; + +export type RequestActor = { + user: AuthenticatedUser; + auth_kind: string; +}; + +export type WhoamiResponse = { + actor: RequestActor | null; +}; + +export type PasskeyRegistrationOptionsResponse = { + challenge_id: string; + public_key: PublicKeyCredentialCreationOptions | { publicKey: PublicKeyCredentialCreationOptions }; +}; + +export type PasskeyLoginOptionsResponse = { + challenge_id: string; + public_key: PublicKeyCredentialRequestOptions | { publicKey: PublicKeyCredentialRequestOptions }; +}; + +export type PasskeyUserResponse = { + user: AuthenticatedUser; +}; + +export type DeviceApprovalResponse = { + status: string; +}; + +export type RegistrationCredentialJson = { + id: string; + rawId: string; + type: string; + response: { + clientDataJSON: string; + attestationObject: string; + transports: string[]; + }; +}; + +export type AuthenticationCredentialJson = { + id: string; + rawId: string; + type: string; + response: { + clientDataJSON: string; + authenticatorData: string; + signature: string; + userHandle: string | null; + }; +}; + +export function base64UrlToBuffer(value: string): ArrayBuffer { + const padding = "=".repeat((4 - (value.length % 4)) % 4); + const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(base64); + return Uint8Array.from(binary, (char) => char.charCodeAt(0)).buffer; +} + +export function bufferToBase64Url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function unwrapPublicKey(value: T | { publicKey: T }): T { + if (value && typeof value === "object" && "publicKey" in value) { + return (value as { publicKey: T }).publicKey; + } + return value as T; +} + +export function prepareRegistrationOptions( + options: PasskeyRegistrationOptionsResponse, +): PublicKeyCredentialCreationOptions { + const publicKey = structuredClone(unwrapPublicKey(options.public_key)); + publicKey.challenge = base64UrlToBuffer(publicKey.challenge as unknown as string); + publicKey.user = { + ...publicKey.user, + id: base64UrlToBuffer(publicKey.user.id as unknown as string), + }; + publicKey.excludeCredentials = publicKey.excludeCredentials?.map((credential) => ({ + ...credential, + id: base64UrlToBuffer(credential.id as unknown as string), + })); + return publicKey; +} + +export function prepareLoginOptions( + options: PasskeyLoginOptionsResponse, +): PublicKeyCredentialRequestOptions { + const publicKey = structuredClone(unwrapPublicKey(options.public_key)); + publicKey.challenge = base64UrlToBuffer(publicKey.challenge as unknown as string); + publicKey.allowCredentials = publicKey.allowCredentials?.map((credential) => ({ + ...credential, + id: base64UrlToBuffer(credential.id as unknown as string), + })); + return publicKey; +} + +export function registrationCredentialToJson( + credential: PublicKeyCredential, +): RegistrationCredentialJson { + const response = credential.response as AuthenticatorAttestationResponse; + return { + id: credential.id, + rawId: bufferToBase64Url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bufferToBase64Url(response.clientDataJSON), + attestationObject: bufferToBase64Url(response.attestationObject), + transports: response.getTransports?.() ?? [], + }, + }; +} + +export function authenticationCredentialToJson( + credential: PublicKeyCredential, +): AuthenticationCredentialJson { + const response = credential.response as AuthenticatorAssertionResponse; + return { + id: credential.id, + rawId: bufferToBase64Url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bufferToBase64Url(response.clientDataJSON), + authenticatorData: bufferToBase64Url(response.authenticatorData), + signature: bufferToBase64Url(response.signature), + userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null, + }, + }; +} + +export function isPublicKeyCredential(credential: Credential | null): credential is PublicKeyCredential { + return credential != null && credential.type === "public-key"; +} 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 73c9bd3d..0b94aebf 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 @@ -373,3 +373,46 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac "target-change effect should load data without depending on manual refresh state reads", ); }); + +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), + ); + const authApi = await Deno.readTextFile( + new URL("../auth/api.ts", import.meta.url), + ); + const rootLayout = await Deno.readTextFile( + new URL("./../../../routes/+layout.ts", 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( + authApi.includes("/api/auth/whoami") && + authApi.includes("/api/auth/passkeys/registration/options") && + authApi.includes("/api/auth/passkeys/login/complete") && + authApi.includes("/api/auth/logout") && + authApi.includes("/api/auth/device-login/approve"), + "Auth model should stay on Backend auth APIs rather than workspace authorization APIs", + ); + assert( + rootLayout.includes('"/account"') && rootLayout.includes('"/login/device"'), + "Root layout should not redirect account and device-login public routes to a workspace", + ); +}); diff --git a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte index 54326508..a1151f8a 100644 --- a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte +++ b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte @@ -28,6 +28,7 @@ let workspaceId = $derived(workspace?.workspace_id ?? ''); let homeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/'); + let accountHref = '/account'; let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings'); @@ -87,6 +88,17 @@ /> + + + { if (!workspaceId) { const workspace = await loadJson(fetch, "/api/workspace"); - if (workspace.data) { + const publicRoutes = new Set(["/account", "/login/device"]); + if (workspace.data && !publicRoutes.has(url.pathname)) { const scopedPath = workspaceRoute(workspace.data.workspace_id); throw redirect(307, `${scopedPath}${url.search}`); } diff --git a/web/workspace/src/routes/account/+page.svelte b/web/workspace/src/routes/account/+page.svelte new file mode 100644 index 00000000..4af3cf5e --- /dev/null +++ b/web/workspace/src/routes/account/+page.svelte @@ -0,0 +1,160 @@ + + + + Account · Yoi Workspace + + +
+
+

Account

+

Account

+

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

+
+ + + +
+

Passkey

+
{ event.preventDefault(); }}> + + +
+ + +
+
+
+ + {#if status} +

{status}

+ {/if} + {#if error} +

{error}

+ {/if} +
diff --git a/web/workspace/src/routes/login/device/+page.svelte b/web/workspace/src/routes/login/device/+page.svelte new file mode 100644 index 00000000..d03a72d1 --- /dev/null +++ b/web/workspace/src/routes/login/device/+page.svelte @@ -0,0 +1,118 @@ + + + + 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.user.display_name} @{actor.user.handle}.

+ {:else} +

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

+
{ event.preventDefault(); void login(); }}> + + +
+ {/if} +
+ +
+

Approval code

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

{status}

+ {/if} + {#if error} +

{error}

+ {/if} +