From 026e4cf90b4d133de9a607dafe71e5db6245ce88 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 23 Jul 2026 01:14:24 +0900 Subject: [PATCH] docs: record backend auth model --- crates/workspace-server/src/server.rs | 125 ++++++++++++++++++++++++++ docs/backend-auth-account-model.md | 55 ++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 docs/backend-auth-account-model.md diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index e0d29d8e..95b6e783 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -8549,6 +8549,131 @@ mod tests { dir, ) } + + #[tokio::test] + async fn auth_passkey_session_and_device_login_flow_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let app = test_app(dir.path()).await; + + let registration_options = post_json( + app.clone(), + "/api/auth/passkeys/registration/options", + json!({ + "handle": "alice", + "display_name": "Alice" + }), + ) + .await; + let registration_challenge = registration_options["challenge"].as_str().unwrap(); + let registered = post_json( + app.clone(), + "/api/auth/passkeys/registration/complete", + json!({ + "challenge": registration_challenge, + "credential_id": "credential-1", + "public_key_cose": "public-key-cose", + "transports": ["internal"] + }), + ) + .await; + assert_eq!(registered["user"]["handle"], "alice"); + + let login_options = post_json( + app.clone(), + "/api/auth/passkeys/login/options", + json!({ "handle": "alice" }), + ) + .await; + assert_eq!( + login_options["allow_credentials"][0]["credential_id"], + "credential-1" + ); + let login_challenge = login_options["challenge"].as_str().unwrap(); + let login_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/passkeys/login/complete") + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "challenge": login_challenge, + "credential_id": "credential-1" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(login_response.status(), StatusCode::OK); + let cookie = login_response + .headers() + .get(SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .to_string(); + let login_bytes = to_bytes(login_response.into_body(), usize::MAX) + .await + .unwrap(); + let login_json: Value = serde_json::from_slice(&login_bytes).unwrap(); + assert_eq!(login_json["user"]["handle"], "alice"); + + let device = post_json( + app.clone(), + "/api/auth/device-login/start", + json!({ "client_name": "test cli" }), + ) + .await; + let approved = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/device-login/approve") + .header(CONTENT_TYPE, "application/json") + .header("Cookie", cookie) + .body(Body::from( + serde_json::to_vec(&json!({ + "user_code": device["user_code"].as_str().unwrap() + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(approved.status(), StatusCode::OK); + + let polled = post_json( + app.clone(), + "/api/auth/device-login/poll", + json!({ "device_code": device["device_code"].as_str().unwrap() }), + ) + .await; + assert_eq!(polled["status"], "approved"); + let access_token = polled["access_token"].as_str().unwrap(); + + let whoami = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/auth/whoami") + .header("Authorization", format!("Bearer {access_token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(whoami.status(), StatusCode::OK); + let whoami_bytes = to_bytes(whoami.into_body(), usize::MAX).await.unwrap(); + let whoami_json: Value = serde_json::from_slice(&whoami_bytes).unwrap(); + assert_eq!(whoami_json["actor"]["handle"], "alice"); + assert_eq!(whoami_json["actor"]["auth_method"], "api_token"); + } + async fn get_json(app: Router, uri: &str) -> Value { let response = app .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) diff --git a/docs/backend-auth-account-model.md b/docs/backend-auth-account-model.md new file mode 100644 index 00000000..9dc5850f --- /dev/null +++ b/docs/backend-auth-account-model.md @@ -0,0 +1,55 @@ +# Backend account model and login flows + +This document records the first Backend identity/auth layer introduced for the multi-workspace Backend direction. + +## Account and owner model + +The Backend store now separates a human user from the owner namespace used by workspace ownership: + +- `accounts`: canonical namespace records. `kind = "user"` is implemented now; `kind = "organization"` is reserved in the schema for a later organization model. +- `users`: human user records. Each user owns exactly one `accounts` row. +- `workspaces.owner_account_id`: optional account owner reference. Existing/local workspaces may keep it unset until account bootstrap/ownership migration code assigns it. + +This avoids baking `owner_user_id` into workspace authority while keeping the current implementation small. + +## Browser login + +Human browser auth is modeled as Passkey/WebAuthn ceremony endpoints: + +- `POST /api/auth/bootstrap-user` +- `POST /api/auth/passkeys/registration/options` +- `POST /api/auth/passkeys/registration/complete` +- `POST /api/auth/passkeys/login/options` +- `POST /api/auth/passkeys/login/complete` +- `GET /api/auth/whoami` + +The store persists registration/login challenges, passkey credential IDs, COSE public key payloads, transports, browser sessions, and challenge consumption. Successful login mints an HttpOnly `SameSite=Lax` cookie session. + +Current boundary: the API and persistence shape are WebAuthn-oriented, but cryptographic attestation/assertion verification is not implemented in this step. The completion endpoints consume the server-issued challenge and validate credential/user binding. A follow-up should wire a WebAuthn verifier crate and replace the provisional completion payloads with verified browser `PublicKeyCredential` responses before this is exposed outside trusted development use. + +## CLI/TUI device login + +CLI/TUI clients use a device-login flow instead of browser cookies: + +- `POST /api/auth/device-login/start` creates a `device_code` and human `user_code`. +- The CLI prints `verification_uri_complete` and polls `POST /api/auth/device-login/poll`. +- A browser-authenticated user approves with `POST /api/auth/device-login/approve`. +- Approval mints a Backend API token and returns it exactly once to the polling client. + +`yoi login [--backend ] [--no-wait]` starts this flow and stores the received token in `$XDG_CONFIG_HOME/yoi/backend-tokens.json` or `$HOME/.config/yoi/backend-tokens.json`. + +## Request actor extraction + +`workspace-server::auth::resolve_request_actor` resolves a typed request actor from either: + +- `Authorization: Bearer ` for CLI/TUI API tokens, or +- the configured browser session cookie. + +The resolved actor includes `user_id`, `account_id`, `handle`, `display_name`, and `auth_method`. This helper is intended to be used by mutating Backend APIs as authorization gets added route-by-route. + +## Non-goals for this step + +- Organization membership and RBAC. +- Password login, OAuth login, or other human auth methods. +- Token scoping beyond Backend API bearer authentication. +- Production WebAuthn cryptographic verification; the schema/API are prepared for it, but the verifier integration remains follow-up work.