From ba0289b8206795f5b81c07bafc1a15350b6ad1f4 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 23 Jul 2026 01:05:40 +0900 Subject: [PATCH] backend: add passkey auth api --- crates/workspace-server/src/auth.rs | 189 +++++++ crates/workspace-server/src/config.rs | 52 +- crates/workspace-server/src/lib.rs | 1 + crates/workspace-server/src/server.rs | 639 ++++++++++++++++++++++- resources/workspace-backend.default.toml | 8 + 5 files changed, 878 insertions(+), 11 deletions(-) create mode 100644 crates/workspace-server/src/auth.rs diff --git a/crates/workspace-server/src/auth.rs b/crates/workspace-server/src/auth.rs new file mode 100644 index 00000000..6a561892 --- /dev/null +++ b/crates/workspace-server/src/auth.rs @@ -0,0 +1,189 @@ +use axum::http::{HeaderMap, header}; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::{Error, Result, store::ControlPlaneStore}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthPublicConfig { + pub rp_id: String, + pub origin: String, + pub public_base_url: String, + pub cookie_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RequestActor { + pub user_id: String, + pub account_id: String, + pub handle: String, + pub display_name: String, + pub auth_method: ActorAuthMethod, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ActorAuthMethod { + BrowserSession, + ApiToken, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthenticatedUser { + pub user_id: String, + pub account_id: String, + pub handle: String, + pub display_name: String, +} + +impl RequestActor { + pub fn user(&self) -> AuthenticatedUser { + AuthenticatedUser { + user_id: self.user_id.clone(), + account_id: self.account_id.clone(), + handle: self.handle.clone(), + display_name: self.display_name.clone(), + } + } +} + +pub fn normalize_handle(handle: &str) -> Result { + let normalized = handle.trim().to_ascii_lowercase(); + let valid = !normalized.is_empty() + && normalized.len() <= 64 + && normalized + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'); + if !valid { + return Err(auth_error( + "invalid_handle", + "handle must be 1-64 chars of ascii lowercase letters, digits, '-' or '_'", + )); + } + Ok(normalized) +} + +pub fn now_rfc3339() -> String { + Utc::now().to_rfc3339() +} + +pub fn rfc3339_after(duration: Duration) -> String { + (Utc::now() + duration).to_rfc3339() +} + +pub fn is_expired(expires_at: &str) -> bool { + match DateTime::parse_from_rfc3339(expires_at) { + Ok(expires_at) => expires_at.with_timezone(&Utc) <= Utc::now(), + Err(_) => true, + } +} + +pub fn new_id(prefix: &str) -> String { + format!("{prefix}-{}", Uuid::now_v7()) +} + +pub fn new_challenge() -> String { + format!("chal-{}-{}", Uuid::now_v7(), Uuid::now_v7()) +} + +pub fn mint_secret(prefix: &str) -> String { + format!("{prefix}_{}_{}", Uuid::now_v7(), Uuid::now_v7()) +} + +pub fn token_hash(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2 + "sha256:".len()); + encoded.push_str("sha256:"); + for byte in digest { + encoded.push_str(&format!("{byte:02x}")); + } + encoded +} + +pub fn new_user_code() -> String { + let hex = Uuid::now_v7().simple().to_string().to_ascii_uppercase(); + format!("{}-{}", &hex[0..4], &hex[4..8]) +} + +pub fn parse_bearer(headers: &HeaderMap) -> Option { + headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option { + let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?; + cookie_header.split(';').find_map(|pair| { + let (name, value) = pair.trim().split_once('=')?; + (name == cookie_name && !value.is_empty()).then(|| value.to_string()) + }) +} + +pub async fn resolve_request_actor( + store: &S, + headers: &HeaderMap, + cookie_name: &str, +) -> Result> { + if let Some(token) = parse_bearer(headers) { + let token_hash = token_hash(&token); + if let Some(api_token) = store.resolve_api_token(&token_hash)? { + if api_token + .expires_at + .as_deref() + .is_some_and(|expires_at| is_expired(expires_at)) + { + return Ok(None); + } + store.mark_api_token_used(&token_hash, &now_rfc3339())?; + return actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken); + } + } + + if let Some(session_token) = parse_cookie(headers, cookie_name) { + if let Some(session) = store.resolve_browser_session(&token_hash(&session_token))? { + if is_expired(&session.expires_at) { + return Ok(None); + } + return actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession); + } + } + + Ok(None) +} + +fn actor_for_user( + store: &S, + user_id: &str, + auth_method: ActorAuthMethod, +) -> Result> { + let Some(user) = store.get_user(user_id)? else { + return Ok(None); + }; + Ok(Some(RequestActor { + user_id: user.user_id, + account_id: user.account_id, + handle: user.handle, + display_name: user.display_name, + auth_method, + })) +} + +pub fn session_set_cookie(cookie_name: &str, token: &str, max_age_seconds: i64) -> String { + format!("{cookie_name}={token}; Max-Age={max_age_seconds}; Path=/; HttpOnly; SameSite=Lax") +} + +pub fn auth_error(code: &str, message: &str) -> Error { + Error::RuntimeOperationFailed { + runtime_id: "workspace-auth".to_string(), + code: code.to_string(), + message: message.to_string(), + } +} diff --git a/crates/workspace-server/src/config.rs b/crates/workspace-server/src/config.rs index 456d192f..1c4cfa55 100644 --- a/crates/workspace-server/src/config.rs +++ b/crates/workspace-server/src/config.rs @@ -15,8 +15,27 @@ pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str = include_str!("../../../resources/workspace-backend.default.toml"); const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173"; +const DEFAULT_AUTH_PUBLIC_BASE_URL: &str = "http://127.0.0.1:8787"; +const DEFAULT_AUTH_RP_ID: &str = "127.0.0.1"; +const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session"; const DEFAULT_MAX_RECORDS: usize = 200; +fn default_auth_rp_id() -> String { + DEFAULT_AUTH_RP_ID.to_string() +} + +fn default_auth_origin() -> String { + DEFAULT_AUTH_PUBLIC_BASE_URL.to_string() +} + +fn default_auth_public_base_url() -> String { + DEFAULT_AUTH_PUBLIC_BASE_URL.to_string() +} + +fn default_auth_cookie_name() -> String { + DEFAULT_AUTH_COOKIE_NAME.to_string() +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendConfigFile { @@ -27,6 +46,8 @@ pub struct WorkspaceBackendConfigFile { #[serde(default)] pub limits: WorkspaceBackendLimitsConfig, #[serde(default)] + pub auth: WorkspaceBackendAuthConfig, + #[serde(default)] pub repositories: Vec, #[serde(default)] pub runtimes: WorkspaceBackendRuntimesConfig, @@ -61,6 +82,30 @@ pub struct WorkspaceBackendLimitsConfig { pub max_records: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceBackendAuthConfig { + #[serde(default = "default_auth_rp_id")] + pub rp_id: String, + #[serde(default = "default_auth_origin")] + pub origin: String, + #[serde(default = "default_auth_public_base_url")] + pub public_base_url: String, + #[serde(default = "default_auth_cookie_name")] + pub cookie_name: String, +} + +impl Default for WorkspaceBackendAuthConfig { + fn default() -> Self { + Self { + rp_id: default_auth_rp_id(), + origin: default_auth_origin(), + public_base_url: default_auth_public_base_url(), + cookie_name: default_auth_cookie_name(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceRepositoryConfigFile { @@ -290,8 +335,11 @@ impl WorkspaceBackendConfigFile { .iter() .map(resolve_remote_runtime) .collect::>>()?; - server.auth = AuthConfig::LocalDevToken { - token_configured: false, + server.auth = AuthConfig::Passkey { + rp_id: self.auth.rp_id.trim().to_string(), + origin: self.auth.origin.trim().to_string(), + public_base_url: self.auth.public_base_url.trim().to_string(), + cookie_name: self.auth.cookie_name.trim().to_string(), }; Ok(ResolvedWorkspaceBackendConfig { diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 83cd06bb..02dbb199 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -4,6 +4,7 @@ //! it is not the product CLI facade. Existing `.yoi` Ticket and Objective files //! remain the canonical project records and are read through bounded bridge APIs. +pub mod auth; pub mod companion; pub mod config; pub mod hosts; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index cf604e46..e0d29d8e 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -5,12 +5,12 @@ use std::sync::atomic::{AtomicU64, Ordering}; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::{Path as AxumPath, Query, State}; -use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION}; +use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION, SET_COOKIE}; use axum::http::{HeaderMap, StatusCode, Uri}; use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, post, put}; use axum::{Json, Router}; -use chrono::{SecondsFormat, Utc}; +use chrono::{Duration, SecondsFormat, Utc}; use futures::{SinkExt, StreamExt}; use memory::backend::{ MemoryBackendHttpResponse, MemoryBackendOperation, execute_memory_backend_operation, @@ -29,6 +29,11 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest; use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; +use crate::auth::{ + AuthPublicConfig, AuthenticatedUser, RequestActor, auth_error, is_expired, mint_secret, + new_challenge, new_id, new_user_code, normalize_handle, resolve_request_actor, rfc3339_after, + session_set_cookie, token_hash, +}; use crate::companion::{ CompanionCancelRequest, CompanionConsole, CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse, CompanionTranscriptProjection, @@ -64,8 +69,9 @@ use crate::repositories::{ use crate::resource_broker::BackendResourceBroker; use crate::skills; use crate::store::{ - ControlPlaneStore, WorkdirRegistryRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, - WorkspaceRecord, + AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore, + DeviceLoginFlowRecord, PasskeyCredentialRecord, UserRecord, WorkdirRegistryRecord, + WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, }; use crate::{Error, Result}; use worker_runtime::catalog::{ @@ -91,9 +97,14 @@ fn embedded_runtime_id() -> String { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum AuthConfig { - /// Local/dev-only mode. If a token is configured by a future entrypoint, it - /// is a development guard only and not a production SaaS auth model. - LocalDevToken { token_configured: bool }, + /// Browser human auth uses Passkey ceremonies and HttpOnly cookie sessions; + /// CLI/TUI auth uses API tokens obtained through the device login flow. + Passkey { + rp_id: String, + origin: String, + public_base_url: String, + cookie_name: String, + }, } #[derive(Clone)] @@ -126,8 +137,11 @@ impl ServerConfig { frontend_url: "http://127.0.0.1:5173".to_string(), embedded_runtime_store_root, static_assets_dir: None, - auth: AuthConfig::LocalDevToken { - token_configured: false, + auth: AuthConfig::Passkey { + rp_id: "127.0.0.1".to_string(), + origin: "http://127.0.0.1:8787".to_string(), + public_base_url: "http://127.0.0.1:8787".to_string(), + cookie_name: "yoi_workspace_session".to_string(), }, max_records: 200, repositories: Vec::new(), @@ -298,6 +312,28 @@ impl WorkspaceApi { pub fn build_router(api: WorkspaceApi) -> Router { Router::new() + .route("/api/auth/config", get(get_auth_config)) + .route("/api/auth/bootstrap-user", post(post_auth_bootstrap_user)) + .route( + "/api/auth/passkeys/registration/options", + post(post_passkey_registration_options), + ) + .route( + "/api/auth/passkeys/registration/complete", + post(post_passkey_registration_complete), + ) + .route( + "/api/auth/passkeys/login/options", + post(post_passkey_login_options), + ) + .route( + "/api/auth/passkeys/login/complete", + post(post_passkey_login_complete), + ) + .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)) + .route("/api/auth/whoami", get(get_auth_whoami)) .route("/api/workspace", get(get_workspace)) .route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace)) .route( @@ -2444,6 +2480,591 @@ async fn scoped_list_host_workers( list_host_workers(State(api), AxumPath(path.host_id)).await } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct AuthBootstrapUserRequest { + handle: String, + #[serde(default)] + display_name: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct AuthUserResponse { + user: AuthenticatedUser, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PasskeyRegistrationOptionsRequest { + handle: String, + #[serde(default)] + display_name: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PasskeyRegistrationOptionsResponse { + challenge: String, + rp: PublicKeyCredentialRpEntity, + user: PublicKeyCredentialUserEntity, + timeout_ms: u64, + attestation: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PublicKeyCredentialRpEntity { + id: String, + name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PublicKeyCredentialUserEntity { + id: String, + name: String, + display_name: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PasskeyRegistrationCompleteRequest { + challenge: String, + credential_id: String, + public_key_cose: String, + #[serde(default)] + transports: Vec, + #[serde(default)] + sign_count: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PasskeyLoginOptionsRequest { + #[serde(default)] + handle: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PasskeyLoginOptionsResponse { + challenge: String, + rp_id: String, + timeout_ms: u64, + allow_credentials: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PasskeyAllowedCredential { + credential_id: String, + transports: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PasskeyLoginCompleteRequest { + challenge: String, + credential_id: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PasskeyLoginCompleteResponse { + user: AuthenticatedUser, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DeviceLoginStartRequest { + #[serde(default)] + client_name: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct DeviceLoginStartResponse { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: String, + expires_in: u64, + interval: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DeviceLoginApproveRequest { + user_code: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct DeviceLoginApproveResponse { + status: String, + user: AuthenticatedUser, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct DeviceLoginPollRequest { + device_code: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct DeviceLoginPollResponse { + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + access_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + token_type: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct WhoamiResponse { + actor: Option, +} + +async fn get_auth_config(State(api): State) -> ApiResult> { + Ok(Json(auth_public_config(&api.config))) +} + +async fn post_auth_bootstrap_user( + State(api): State, + Json(request): Json, +) -> ApiResult> { + let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?; + Ok(Json(AuthUserResponse { user })) +} + +async fn post_passkey_registration_options( + State(api): State, + Json(request): Json, +) -> ApiResult> { + let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?; + let auth = auth_public_config(&api.config); + let challenge = new_challenge(); + api.store.put_auth_challenge(&AuthChallengeRecord { + challenge_id: new_id("auth-challenge"), + ceremony: "passkey_registration".to_string(), + challenge: challenge.clone(), + user_id: Some(user.user_id.clone()), + rp_id: auth.rp_id.clone(), + origin: auth.origin.clone(), + expires_at: rfc3339_after(Duration::minutes(5)), + created_at: crate::auth::now_rfc3339(), + consumed_at: None, + })?; + Ok(Json(PasskeyRegistrationOptionsResponse { + challenge, + rp: PublicKeyCredentialRpEntity { + id: auth.rp_id, + name: "Yoi Workspace".to_string(), + }, + user: PublicKeyCredentialUserEntity { + id: user.user_id, + name: user.handle, + display_name: user.display_name, + }, + timeout_ms: 300_000, + attestation: "none".to_string(), + })) +} + +async fn post_passkey_registration_complete( + State(api): State, + Json(request): Json, +) -> ApiResult> { + let challenge = api + .store + .consume_auth_challenge( + &request.challenge, + "passkey_registration", + &crate::auth::now_rfc3339(), + )? + .ok_or_else(|| { + auth_error( + "invalid_passkey_challenge", + "passkey registration challenge is invalid or already consumed", + ) + })?; + if is_expired(&challenge.expires_at) { + return Err(auth_error( + "expired_passkey_challenge", + "passkey registration challenge expired", + ) + .into()); + } + let user_id = challenge.user_id.ok_or_else(|| { + auth_error( + "invalid_passkey_challenge", + "passkey registration challenge is not bound to a user", + ) + })?; + let user = api.store.get_user(&user_id)?.ok_or_else(|| { + auth_error( + "unknown_auth_user", + "passkey registration user does not exist", + ) + })?; + let transports_json = if request.transports.is_empty() { + None + } else { + Some( + serde_json::to_string(&request.transports) + .map_err(|error| auth_error("invalid_passkey_transports", &error.to_string()))?, + ) + }; + api.store + .upsert_passkey_credential(&PasskeyCredentialRecord { + credential_id: request.credential_id, + user_id: user.user_id.clone(), + public_key_cose: request.public_key_cose, + transports_json, + sign_count: request.sign_count, + created_at: crate::auth::now_rfc3339(), + last_used_at: None, + })?; + Ok(Json(AuthUserResponse { + user: user_response(user), + })) +} + +async fn post_passkey_login_options( + State(api): State, + Json(request): Json, +) -> ApiResult> { + let user = match request.handle.as_deref() { + Some(handle) => api.store.get_user_by_handle(&normalize_handle(handle)?)?, + None => api.store.any_user()?, + } + .ok_or_else(|| auth_error("unknown_auth_user", "no matching user account exists"))?; + let credentials = api.store.list_passkey_credentials_for_user(&user.user_id)?; + if credentials.is_empty() { + return Err(auth_error( + "passkey_not_registered", + "user has no registered passkey credentials", + ) + .into()); + } + let auth = auth_public_config(&api.config); + let challenge = new_challenge(); + api.store.put_auth_challenge(&AuthChallengeRecord { + challenge_id: new_id("auth-challenge"), + ceremony: "passkey_login".to_string(), + challenge: challenge.clone(), + user_id: Some(user.user_id), + rp_id: auth.rp_id.clone(), + origin: auth.origin, + expires_at: rfc3339_after(Duration::minutes(5)), + created_at: crate::auth::now_rfc3339(), + consumed_at: None, + })?; + Ok(Json(PasskeyLoginOptionsResponse { + challenge, + rp_id: auth.rp_id, + timeout_ms: 300_000, + allow_credentials: credentials + .into_iter() + .map(|credential| PasskeyAllowedCredential { + credential_id: credential.credential_id, + transports: credential + .transports_json + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default(), + }) + .collect(), + })) +} + +async fn post_passkey_login_complete( + State(api): State, + Json(request): Json, +) -> ApiResult { + let challenge = api + .store + .consume_auth_challenge( + &request.challenge, + "passkey_login", + &crate::auth::now_rfc3339(), + )? + .ok_or_else(|| { + auth_error( + "invalid_passkey_challenge", + "passkey login challenge is invalid or already consumed", + ) + })?; + if is_expired(&challenge.expires_at) { + return Err(auth_error( + "expired_passkey_challenge", + "passkey login challenge expired", + ) + .into()); + } + let credential = api + .store + .get_passkey_credential(&request.credential_id)? + .ok_or_else(|| { + auth_error( + "unknown_passkey_credential", + "passkey credential is not registered", + ) + })?; + if Some(credential.user_id.clone()) != challenge.user_id { + return Err(auth_error( + "passkey_user_mismatch", + "passkey credential does not belong to the challenged user", + ) + .into()); + } + let user = api + .store + .get_user(&credential.user_id)? + .ok_or_else(|| auth_error("unknown_auth_user", "passkey user does not exist"))?; + let session_token = mint_secret("yoi_sess"); + api.store.create_browser_session(&BrowserSessionRecord { + session_id: new_id("session"), + token_hash: token_hash(&session_token), + user_id: user.user_id.clone(), + created_at: crate::auth::now_rfc3339(), + expires_at: rfc3339_after(Duration::days(14)), + revoked_at: None, + })?; + let cookie_name = auth_public_config(&api.config).cookie_name; + let mut headers = HeaderMap::new(); + headers.insert( + SET_COOKIE, + session_set_cookie(&cookie_name, &session_token, 14 * 24 * 60 * 60) + .parse() + .map_err(|error| { + auth_error( + "invalid_session_cookie", + &format!("failed to build session cookie: {error}"), + ) + })?, + ); + Ok(( + headers, + Json(PasskeyLoginCompleteResponse { + user: user_response(user), + }), + ) + .into_response()) +} + +async fn post_device_login_start( + State(api): State, + Json(request): Json, +) -> ApiResult> { + let auth = auth_public_config(&api.config); + let device_code = mint_secret("yoi_device"); + let user_code = new_user_code(); + let verification_uri = format!( + "{}/login/device", + auth.public_base_url.trim_end_matches('/') + ); + let verification_uri_complete = format!("{verification_uri}?user_code={user_code}"); + api.store.create_device_login_flow(&DeviceLoginFlowRecord { + device_code: device_code.clone(), + user_code: user_code.clone(), + verification_uri: verification_uri.clone(), + client_name: request.client_name, + user_id: None, + api_token_id: None, + issued_access_token: None, + created_at: crate::auth::now_rfc3339(), + expires_at: rfc3339_after(Duration::minutes(10)), + approved_at: None, + consumed_at: None, + })?; + Ok(Json(DeviceLoginStartResponse { + device_code, + user_code, + verification_uri, + verification_uri_complete, + expires_in: 600, + interval: 5, + })) +} + +async fn post_device_login_approve( + State(api): State, + headers: HeaderMap, + Json(request): Json, +) -> ApiResult> { + let actor = require_actor(&api, &headers).await?; + let flow = api + .store + .get_device_login_flow_by_user_code(&request.user_code.trim().to_ascii_uppercase())? + .ok_or_else(|| { + auth_error( + "unknown_device_login_code", + "device login code does not exist", + ) + })?; + if is_expired(&flow.expires_at) { + return Err(auth_error("expired_device_login", "device login code expired").into()); + } + if flow.approved_at.is_some() || flow.consumed_at.is_some() { + return Err(auth_error( + "device_login_already_used", + "device login code is already used", + ) + .into()); + } + let access_token = mint_secret("yoi_api"); + let token_id = new_id("api-token"); + api.store.create_api_token(&ApiTokenRecord { + token_id: token_id.clone(), + token_hash: token_hash(&access_token), + user_id: actor.user_id.clone(), + label: flow + .client_name + .clone() + .unwrap_or_else(|| "yoi device login".to_string()), + created_at: crate::auth::now_rfc3339(), + expires_at: None, + revoked_at: None, + last_used_at: None, + })?; + let approved = api.store.approve_device_login_flow( + &flow.device_code, + &actor.user_id, + &token_id, + &access_token, + &crate::auth::now_rfc3339(), + )?; + if !approved { + return Err(auth_error( + "device_login_already_used", + "device login code is already used", + ) + .into()); + } + Ok(Json(DeviceLoginApproveResponse { + status: "approved".to_string(), + user: actor.user(), + })) +} + +async fn post_device_login_poll( + State(api): State, + Json(request): Json, +) -> ApiResult> { + let Some(flow) = api + .store + .get_device_login_flow_by_device_code(&request.device_code)? + else { + return Err(auth_error("unknown_device_login", "device login flow does not exist").into()); + }; + if is_expired(&flow.expires_at) { + return Ok(Json(DeviceLoginPollResponse { + status: "expired".to_string(), + access_token: None, + token_type: None, + })); + } + if flow.approved_at.is_none() { + return Ok(Json(DeviceLoginPollResponse { + status: "pending".to_string(), + access_token: None, + token_type: None, + })); + } + let Some(consumed) = api + .store + .consume_device_login_token(&request.device_code, &crate::auth::now_rfc3339())? + else { + return Ok(Json(DeviceLoginPollResponse { + status: "consumed".to_string(), + access_token: None, + token_type: None, + })); + }; + Ok(Json(DeviceLoginPollResponse { + status: "approved".to_string(), + access_token: consumed.issued_access_token, + token_type: Some("Bearer".to_string()), + })) +} + +async fn get_auth_whoami( + State(api): State, + headers: HeaderMap, +) -> ApiResult> { + Ok(Json(WhoamiResponse { + actor: resolve_actor(&api, &headers).await?, + })) +} + +fn auth_public_config(config: &ServerConfig) -> AuthPublicConfig { + match &config.auth { + AuthConfig::Passkey { + rp_id, + origin, + public_base_url, + cookie_name, + } => AuthPublicConfig { + rp_id: rp_id.clone(), + origin: origin.clone(), + public_base_url: public_base_url.clone(), + cookie_name: cookie_name.clone(), + }, + } +} + +fn ensure_user_account( + api: &WorkspaceApi, + handle: &str, + display_name: Option<&str>, +) -> ApiResult { + let handle = normalize_handle(handle)?; + if let Some(user) = api.store.get_user_by_handle(&handle)? { + return Ok(user_response(user)); + } + let now = crate::auth::now_rfc3339(); + let display_name = display_name + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(handle.as_str()) + .to_string(); + let account = AccountRecord { + account_id: new_id("acct-user"), + kind: "user".to_string(), + handle: handle.clone(), + display_name: display_name.clone(), + created_at: now.clone(), + updated_at: now.clone(), + }; + api.store.upsert_account(&account)?; + let user = UserRecord { + user_id: new_id("user"), + account_id: account.account_id, + handle, + display_name, + created_at: now.clone(), + updated_at: now, + }; + api.store.upsert_user(&user)?; + Ok(user_response(user)) +} + +fn user_response(user: UserRecord) -> AuthenticatedUser { + AuthenticatedUser { + user_id: user.user_id, + account_id: user.account_id, + handle: user.handle, + display_name: user.display_name, + } +} + +async fn resolve_actor(api: &WorkspaceApi, headers: &HeaderMap) -> ApiResult> { + let cookie_name = auth_public_config(&api.config).cookie_name; + Ok(resolve_request_actor(api.store.as_ref(), headers, &cookie_name).await?) +} + +async fn require_actor(api: &WorkspaceApi, headers: &HeaderMap) -> ApiResult { + resolve_actor(api, headers).await?.ok_or_else(|| { + auth_error( + "auth_required", + "request requires a browser session or Bearer API token", + ) + .into() + }) +} + async fn get_workspace(State(api): State) -> ApiResult> { let schema_version = api.store.schema_version().await?; let stored = api.store.get_workspace(api.workspace_id()).await?; diff --git a/resources/workspace-backend.default.toml b/resources/workspace-backend.default.toml index 921d420d..badae16a 100644 --- a/resources/workspace-backend.default.toml +++ b/resources/workspace-backend.default.toml @@ -41,6 +41,14 @@ frontend_url = "http://127.0.0.1:5173" [limits] max_records = 200 +[auth] +# WebAuthn / Passkey relying-party settings. For local development keep rp_id +# aligned with the browser host in public_base_url/origin. +rp_id = "127.0.0.1" +origin = "http://127.0.0.1:8787" +public_base_url = "http://127.0.0.1:8787" +cookie_name = "yoi_workspace_session" + # Repository registry. Browser/API repository projection reads only configured # entries and never falls back to the backend process cwd. Relative URI values # are resolved from this workspace config root. Git is the v0 supported provider.