From a21ef31ee7d7348ad62f1d21d610ee5031c70805 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 21 Aug 2026 05:04:17 +0900 Subject: [PATCH] fix: make authentication workspace independent --- crates/workspace-server/src/server.rs | 102 ++++++++++++++++++++------ 1 file changed, 78 insertions(+), 24 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 9bc15fd6..132c9d26 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -339,6 +339,21 @@ pub struct WorkspaceApi { worker_control_locks: Arc>>>>, } +#[derive(Clone)] +struct ServerAuthApi { + config: ServerConfig, + store: Arc, +} + +impl From<&WorkspaceApi> for ServerAuthApi { + fn from(api: &WorkspaceApi) -> Self { + Self { + config: api.config.clone(), + store: api.store.clone(), + } + } +} + #[derive(Clone)] struct WorkspaceWorkerRemoveExecutor { workspace_id: String, @@ -931,7 +946,7 @@ fn is_server_global_forward(path: &str) -> bool { } fn is_server_static_forward(path: &str) -> bool { - path == "/" || path.starts_with("/_app/") || path.starts_with("/assets/") + !path.starts_with("/api/") && !path.starts_with("/internal/") } async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Response { @@ -959,15 +974,20 @@ pub async fn build_workspace_server_router( template: ServerConfig, store: Arc, ) -> Result { + let auth = build_server_auth_router(ServerAuthApi { + config: template.clone(), + store: store.clone(), + }); let api = WorkspaceServerApi::new(template, store); api.preload().await?; - Ok(Router::new() + let catalog = Router::new() .route( "/api/workspaces", get(list_server_workspaces).post(create_server_workspace), ) .fallback(dispatch_workspace_request) - .with_state(api)) + .with_state(api); + Ok(auth.merge(catalog)) } impl WorkspaceApi { @@ -1552,7 +1572,7 @@ fn resolve_backend_path(workspace_root: &Path, path: &Path) -> PathBuf { } } -pub fn build_router(api: WorkspaceApi) -> Router { +fn build_server_auth_router(api: ServerAuthApi) -> Router { Router::new() .route("/api/auth/config", get(get_auth_config)) .route("/api/auth/bootstrap-user", post(post_auth_bootstrap_user)) @@ -1573,10 +1593,22 @@ pub fn build_router(api: WorkspaceApi) -> Router { 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/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)) + .with_state(api) +} + +pub fn build_router(api: WorkspaceApi) -> Router { + let auth = build_server_auth_router(ServerAuthApi::from(&api)); + let workspace = Router::new() .route("/api/workspace", get(get_workspace)) .route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace)) .route( @@ -2100,7 +2132,8 @@ pub fn build_router(api: WorkspaceApi) -> Router { get(scoped_list_host_workers), ) .fallback(get(static_or_spa_fallback)) - .with_state(api) + .with_state(api); + auth.merge(workspace) .layer(middleware::from_fn(log_failed_api_response)) } @@ -4536,7 +4569,7 @@ async fn scoped_repair_merge_request_selector( let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; require_workspace_access(&workspace_id, &api)?; reject_non_browser_reopen_auth(&headers)?; - let _actor = require_actor(&api, &headers).await?; + let _actor = require_actor(&ServerAuthApi::from(&api), &headers).await?; if !input.explicit_confirmation { return Err(Error::BrowserReopenConfirmationRequired.into()); } @@ -8317,12 +8350,12 @@ struct LogoutResponse { status: String, } -async fn get_auth_config(State(api): State) -> ApiResult> { +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, + State(api): State, Json(request): Json, ) -> ApiResult> { let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?; @@ -8330,7 +8363,7 @@ async fn post_auth_bootstrap_user( } async fn post_passkey_registration_options( - State(api): State, + State(api): State, headers: HeaderMap, Json(request): Json, ) -> ApiResult> { @@ -8379,7 +8412,7 @@ async fn post_passkey_registration_options( } async fn post_passkey_registration_complete( - State(api): State, + State(api): State, Json(request): Json, ) -> ApiResult { let challenge = api @@ -8448,7 +8481,7 @@ async fn post_passkey_registration_complete( } async fn post_passkey_login_options( - State(api): State, + State(api): State, headers: HeaderMap, Json(request): Json, ) -> ApiResult> { @@ -8499,7 +8532,7 @@ async fn post_passkey_login_options( } async fn post_passkey_login_complete( - State(api): State, + State(api): State, Json(request): Json, ) -> ApiResult { let challenge = api @@ -8582,7 +8615,7 @@ async fn post_passkey_login_complete( issue_browser_session_response(&api, user) } -fn issue_browser_session_response(api: &WorkspaceApi, user: UserRecord) -> ApiResult { +fn issue_browser_session_response(api: &ServerAuthApi, user: UserRecord) -> ApiResult { let session_token = mint_secret("yoi_sess"); api.store.create_browser_session(&BrowserSessionRecord { session_id: new_id("session"), @@ -8615,7 +8648,7 @@ fn issue_browser_session_response(api: &WorkspaceApi, user: UserRecord) -> ApiRe } async fn post_device_login_start( - State(api): State, + State(api): State, Json(request): Json, ) -> ApiResult> { let auth = auth_public_config(&api.config); @@ -8650,7 +8683,7 @@ async fn post_device_login_start( } async fn post_device_login_approve( - State(api): State, + State(api): State, headers: HeaderMap, Json(request): Json, ) -> ApiResult> { @@ -8710,7 +8743,7 @@ async fn post_device_login_approve( } async fn post_device_login_poll( - State(api): State, + State(api): State, Json(request): Json, ) -> ApiResult> { let Some(flow) = api @@ -8751,7 +8784,7 @@ async fn post_device_login_poll( } async fn get_auth_whoami( - State(api): State, + State(api): State, headers: HeaderMap, ) -> ApiResult> { Ok(Json(WhoamiResponse { @@ -8760,7 +8793,7 @@ async fn get_auth_whoami( } async fn post_auth_logout( - State(api): State, + State(api): State, headers: HeaderMap, ) -> ApiResult { let auth = auth_public_config(&api.config); @@ -8849,7 +8882,7 @@ fn webauthn_for_auth(auth: &AuthPublicConfig) -> ApiResult { .map_err(|error| auth_error("webauthn_builder_failed", &error.to_string()).into()) } -fn passkeys_for_user(api: &WorkspaceApi, user_id: &str) -> ApiResult> { +fn passkeys_for_user(api: &ServerAuthApi, user_id: &str) -> ApiResult> { api.store .list_passkey_credentials_for_user(user_id)? .into_iter() @@ -8891,7 +8924,7 @@ fn auth_public_config(config: &ServerConfig) -> AuthPublicConfig { } fn ensure_user_account( - api: &WorkspaceApi, + api: &ServerAuthApi, handle: &str, display_name: Option<&str>, ) -> ApiResult { @@ -8935,12 +8968,15 @@ fn user_response(user: UserRecord) -> AuthenticatedUser { } } -async fn resolve_actor(api: &WorkspaceApi, headers: &HeaderMap) -> ApiResult> { +async fn resolve_actor( + api: &ServerAuthApi, + 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 { +async fn require_actor(api: &ServerAuthApi, headers: &HeaderMap) -> ApiResult { resolve_actor(api, headers).await?.ok_or_else(|| { auth_error( "auth_required", @@ -14471,6 +14507,8 @@ mod tests { for (uri, expected) in [ ("/", "
Workspace chooser
"), + ("/account", "
Workspace chooser
"), + ("/login/device", "
Workspace chooser
"), ( "/_app/immutable/entry/start.js", "console.log('workspace app');", @@ -14495,6 +14533,7 @@ mod tests { } let response = app + .clone() .oneshot( Request::builder() .uri("/api/workspaces") @@ -14506,6 +14545,21 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); assert_eq!(serde_json::from_slice::(&body).unwrap(), json!([])); + + let auth = app + .oneshot( + Request::builder() + .uri("/api/auth/config") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(auth.status(), StatusCode::OK); + let body = to_bytes(auth.into_body(), usize::MAX).await.unwrap(); + let auth: Value = serde_json::from_slice(&body).unwrap(); + assert!(auth["rp_id"].is_string()); + assert!(auth["cookie_name"].is_string()); } #[tokio::test]