Skip to content

Commit f0c8b7f

Browse files
authored
[APP-3801] implement remote environments auth (#9331)
1 parent c325d14 commit f0c8b7f

21 files changed

Lines changed: 685 additions & 69 deletions

File tree

app/src/lib.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -565,12 +565,12 @@ pub fn run() -> Result<()> {
565565
}
566566
}
567567
#[cfg(not(target_family = "wasm"))]
568-
warp_cli::Command::Worker(warp_cli::WorkerCommand::RemoteServerProxy) => {
569-
return crate::remote_server::run_proxy();
568+
warp_cli::Command::Worker(warp_cli::WorkerCommand::RemoteServerProxy(args)) => {
569+
return crate::remote_server::run_proxy(args.identity_key.clone());
570570
}
571571
#[cfg(not(target_family = "wasm"))]
572-
warp_cli::Command::Worker(warp_cli::WorkerCommand::RemoteServerDaemon) => {
573-
return crate::remote_server::run_daemon();
572+
warp_cli::Command::Worker(warp_cli::WorkerCommand::RemoteServerDaemon(args)) => {
573+
return crate::remote_server::run_daemon(args.identity_key.clone());
574574
}
575575
#[cfg(not(target_family = "wasm"))]
576576
warp_cli::Command::Worker(warp_cli::WorkerCommand::RipgrepSearch {
@@ -1253,6 +1253,8 @@ fn initialize_app(
12531253
ctx.add_singleton_model(|_ctx| SyncedInputState::new());
12541254

12551255
ctx.add_singleton_model(remote_server::manager::RemoteServerManager::new);
1256+
#[cfg(not(target_family = "wasm"))]
1257+
remote_server::wire_auth_token_rotation(ctx);
12561258

12571259
log::info!(
12581260
"Starting warp with channel state {} and version {:?}",
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
use std::sync::Arc;
2+
3+
use remote_server::auth::RemoteServerAuthContext;
4+
use warpui::r#async::BoxFuture;
5+
6+
use crate::auth::auth_state::AuthState;
7+
use crate::server::server_api::auth::AuthClient;
8+
9+
/// Builds the app-wide auth context used by remote-server connections.
10+
pub fn server_api_auth_context(
11+
auth_state: Arc<AuthState>,
12+
auth_client: Arc<dyn AuthClient>,
13+
) -> RemoteServerAuthContext {
14+
let token_auth_state = auth_state.clone();
15+
let token_auth_client = auth_client;
16+
let identity_auth_state = auth_state;
17+
18+
RemoteServerAuthContext::new(
19+
move || -> BoxFuture<'static, Option<String>> {
20+
if !use_authenticated_user_identity(&token_auth_state) {
21+
return Box::pin(async { None });
22+
}
23+
24+
let auth_client = token_auth_client.clone();
25+
Box::pin(async move {
26+
match auth_client.get_or_refresh_access_token().await {
27+
Ok(token) => token.bearer_token(),
28+
Err(_) => None,
29+
}
30+
})
31+
},
32+
move || remote_server_identity_key(&identity_auth_state),
33+
)
34+
}
35+
36+
fn use_authenticated_user_identity(auth_state: &AuthState) -> bool {
37+
auth_state.is_logged_in() && !auth_state.is_user_anonymous().unwrap_or(true)
38+
}
39+
40+
fn remote_server_identity_key(auth_state: &AuthState) -> String {
41+
if use_authenticated_user_identity(auth_state) {
42+
auth_state
43+
.user_id()
44+
.map(|uid| uid.as_string())
45+
.unwrap_or_else(|| auth_state.anonymous_id())
46+
} else {
47+
auth_state.anonymous_id()
48+
}
49+
}

app/src/remote_server/auth_provider.rs

Whitespace-only changes.

app/src/remote_server/mod.rs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1+
#[cfg(not(target_family = "wasm"))]
2+
use crate::server::server_api::{ServerApiEvent, ServerApiProvider};
3+
#[cfg(not(target_family = "wasm"))]
4+
use remote_server::manager::RemoteServerManager;
5+
#[cfg(not(target_family = "wasm"))]
6+
use warpui::SingletonEntity;
17
// Re-export everything from the `remote_server` crate so existing
28
// `crate::remote_server::*` imports in `app` continue to work.
39
pub use remote_server::*;
410

11+
#[cfg(not(target_family = "wasm"))]
12+
pub mod auth_context;
513
#[cfg(not(target_family = "wasm"))]
614
pub mod server_model;
715
#[cfg(not(target_family = "wasm"))]
@@ -11,23 +19,23 @@ pub mod unix;
1119

1220
/// Run the `remote-server-proxy` subcommand.
1321
#[cfg(unix)]
14-
pub fn run_proxy() -> anyhow::Result<()> {
15-
unix::run_proxy()
22+
pub fn run_proxy(identity_key: String) -> anyhow::Result<()> {
23+
unix::run_proxy(identity_key)
1624
}
1725

1826
#[cfg(not(unix))]
19-
pub fn run_proxy() -> anyhow::Result<()> {
27+
pub fn run_proxy(_identity_key: String) -> anyhow::Result<()> {
2028
anyhow::bail!("remote-server-proxy is not supported on this platform")
2129
}
2230

2331
/// Run the `remote-server-daemon` subcommand.
2432
#[cfg(unix)]
25-
pub fn run_daemon() -> anyhow::Result<()> {
26-
unix::run_daemon()
33+
pub fn run_daemon(identity_key: String) -> anyhow::Result<()> {
34+
unix::run_daemon(identity_key)
2735
}
2836

2937
#[cfg(not(unix))]
30-
pub fn run_daemon() -> anyhow::Result<()> {
38+
pub fn run_daemon(_identity_key: String) -> anyhow::Result<()> {
3139
anyhow::bail!("remote-server-daemon is not supported on this platform")
3240
}
3341

@@ -79,3 +87,17 @@ pub(super) fn run_daemon_app(
7987
})?;
8088
Ok(())
8189
}
90+
91+
/// Forwards app auth-token rotation events to the remote-server manager.
92+
#[cfg(not(target_family = "wasm"))]
93+
pub fn wire_auth_token_rotation(ctx: &mut warpui::AppContext) {
94+
let server_api = ServerApiProvider::handle(ctx);
95+
let manager = RemoteServerManager::handle(ctx);
96+
ctx.subscribe_to_model(&server_api, move |_, event, ctx| {
97+
if let ServerApiEvent::AccessTokenRefreshed { token } = event {
98+
manager.update(ctx, |manager, _| {
99+
manager.rotate_auth_token(token.clone());
100+
});
101+
}
102+
});
103+
}

app/src/remote_server/server_model.rs

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@ use warp_util::file::FileId;
1818

1919
use super::proto::{
2020
client_message, delete_file_response, run_command_response, server_message,
21-
write_file_response, Abort, ClientMessage, DeleteFile, DeleteFileResponse, DeleteFileSuccess,
22-
ErrorCode, ErrorResponse, FailedFileRead, FileContextProto, FileOperationError,
23-
InitializeResponse, NavigatedToDirectory, NavigatedToDirectoryResponse,
24-
ReadFileContextResponse, RunCommandError, RunCommandErrorCode, RunCommandRequest,
25-
RunCommandResponse, RunCommandSuccess, ServerMessage, SessionBootstrapped, WriteFile,
26-
WriteFileResponse, WriteFileSuccess,
21+
write_file_response, Abort, Authenticate, ClientMessage, DeleteFile, DeleteFileResponse,
22+
DeleteFileSuccess, ErrorCode, ErrorResponse, FailedFileRead, FileContextProto,
23+
FileOperationError, Initialize, InitializeResponse, NavigatedToDirectory,
24+
NavigatedToDirectoryResponse, ReadFileContextResponse, RunCommandError, RunCommandErrorCode,
25+
RunCommandRequest, RunCommandResponse, RunCommandSuccess, ServerMessage, SessionBootstrapped,
26+
WriteFile, WriteFileResponse, WriteFileSuccess,
2727
};
2828

2929
/// How long the daemon waits with no connections before exiting.
@@ -164,6 +164,13 @@ pub struct ServerModel {
164164
executors: HashMap<SessionId, Arc<LocalCommandExecutor>>,
165165
/// Tracks in-flight file write/delete operations and handles cleanup.
166166
pending_file_ops: PendingFileOps,
167+
/// Daemon-wide bearer credential for the identity-scoped daemon.
168+
///
169+
/// The token is written by Initialize when the client supplies a
170+
/// non-empty credential, or by Authenticate during token rotation. It is
171+
/// intentionally retained across proxy connection teardown and cleared
172+
/// only by daemon process exit.
173+
auth_token: Option<String>,
167174
}
168175

169176
impl Entity for ServerModel {
@@ -188,6 +195,7 @@ impl ServerModel {
188195
host_id,
189196
executors: HashMap::new(),
190197
pending_file_ops: PendingFileOps::new(),
198+
auth_token: None,
191199
};
192200
// Subscribe to FileModel and RepoMetadataModel events
193201
// file operation results and repo metadata pushes are forwarded to all
@@ -377,7 +385,13 @@ impl ServerModel {
377385
let request_id = RequestId::from(msg.request_id);
378386

379387
let outcome = match msg.message {
380-
Some(client_message::Message::Initialize(_)) => self.handle_initialize(&request_id),
388+
Some(client_message::Message::Initialize(msg)) => {
389+
self.handle_initialize(msg, &request_id)
390+
}
391+
Some(client_message::Message::Authenticate(msg)) => {
392+
self.handle_authenticate(msg);
393+
return;
394+
}
381395
Some(client_message::Message::SessionBootstrapped(msg)) => {
382396
self.handle_session_bootstrapped(msg);
383397
return;
@@ -497,8 +511,11 @@ impl ServerModel {
497511
}
498512

499513
/// Handles `Initialize` by returning the server version and host id.
500-
fn handle_initialize(&self, request_id: &RequestId) -> HandlerOutcome {
514+
fn handle_initialize(&mut self, msg: Initialize, request_id: &RequestId) -> HandlerOutcome {
501515
log::info!("Handling Initialize (request_id={request_id})");
516+
if !msg.auth_token.is_empty() {
517+
self.auth_token = Some(msg.auth_token);
518+
}
502519
let server_version = ChannelState::app_version()
503520
.unwrap_or(env!("CARGO_PKG_VERSION"))
504521
.to_string();
@@ -510,6 +527,20 @@ impl ServerModel {
510527
))
511528
}
512529

530+
/// Handles `Authenticate` by replacing the daemon-wide credential.
531+
/// This is a notification — no response is sent.
532+
fn handle_authenticate(&mut self, msg: Authenticate) {
533+
if msg.auth_token.is_empty() {
534+
log::warn!("Received Authenticate notification with empty auth token; ignoring");
535+
return;
536+
}
537+
self.auth_token = Some(msg.auth_token);
538+
}
539+
540+
pub fn auth_token(&self) -> Option<&str> {
541+
self.auth_token.as_deref()
542+
}
543+
513544
/// Handles `Abort` by cancelling the in-progress request it targets.
514545
/// This is a notification — no response is sent.
515546
fn handle_abort(&mut self, abort: Abort, request_id: &RequestId) {
@@ -1074,3 +1105,7 @@ fn file_context_result_to_proto(result: ReadFileContextResult) -> ReadFileContex
10741105
failed_files,
10751106
}
10761107
}
1108+
1109+
#[cfg(test)]
1110+
#[path = "server_model_tests.rs"]
1111+
mod tests;
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
use std::collections::HashMap;
2+
3+
use super::super::proto::{Authenticate, Initialize};
4+
use super::super::protocol::RequestId;
5+
use super::{PendingFileOps, ServerModel};
6+
7+
fn test_model() -> ServerModel {
8+
ServerModel {
9+
connection_senders: HashMap::new(),
10+
snapshot_sent_roots_by_connection: HashMap::new(),
11+
grace_timer_cancel: None,
12+
in_progress: HashMap::new(),
13+
host_id: "test-host-id".to_string(),
14+
executors: HashMap::new(),
15+
pending_file_ops: PendingFileOps::new(),
16+
auth_token: None,
17+
}
18+
}
19+
20+
fn request_id() -> RequestId {
21+
RequestId::from("test-request".to_string())
22+
}
23+
24+
#[test]
25+
fn fresh_model_starts_without_auth_token() {
26+
let model = test_model();
27+
28+
assert_eq!(model.auth_token(), None);
29+
}
30+
31+
#[test]
32+
fn initialize_with_auth_token_stores_token() {
33+
let mut model = test_model();
34+
35+
model.handle_initialize(
36+
Initialize {
37+
auth_token: "initial-token".to_string(),
38+
},
39+
&request_id(),
40+
);
41+
42+
assert_eq!(model.auth_token(), Some("initial-token"));
43+
}
44+
45+
#[test]
46+
fn empty_initialize_preserves_existing_auth_token() {
47+
let mut model = test_model();
48+
model.handle_initialize(
49+
Initialize {
50+
auth_token: "initial-token".to_string(),
51+
},
52+
&request_id(),
53+
);
54+
55+
model.handle_initialize(
56+
Initialize {
57+
auth_token: String::new(),
58+
},
59+
&request_id(),
60+
);
61+
62+
assert_eq!(model.auth_token(), Some("initial-token"));
63+
}
64+
65+
#[test]
66+
fn authenticate_with_auth_token_replaces_auth_token() {
67+
let mut model = test_model();
68+
model.handle_initialize(
69+
Initialize {
70+
auth_token: "initial-token".to_string(),
71+
},
72+
&request_id(),
73+
);
74+
75+
model.handle_authenticate(Authenticate {
76+
auth_token: "rotated-token".to_string(),
77+
});
78+
79+
assert_eq!(model.auth_token(), Some("rotated-token"));
80+
}
81+
82+
#[test]
83+
fn empty_authenticate_preserves_existing_auth_token() {
84+
let mut model = test_model();
85+
model.handle_initialize(
86+
Initialize {
87+
auth_token: "initial-token".to_string(),
88+
},
89+
&request_id(),
90+
);
91+
92+
model.handle_authenticate(Authenticate {
93+
auth_token: String::new(),
94+
});
95+
96+
assert_eq!(model.auth_token(), Some("initial-token"));
97+
}

0 commit comments

Comments
 (0)