1 //! `authenticate` through a URL-mode elicitation (issue #197).
2 //!
3 //! A client that advertises `elicitation.url` is handed the sign-in page
4 //! instead of sigit launching a browser itself. The test plays the client and
5 //! the browser: it answers the `elicitation/create`, follows the authorize URL
6 //! back to sigit's loopback listener, and serves the token and profile
7 //! endpoints from a fake siGit Code Cloud.
8
9 use std::io::{BufRead, BufReader, Read, Write};
10 use std::net::{TcpListener, TcpStream};
11 use std::process::{Child, ChildStdin, Command, Stdio};
12 use std::sync::mpsc::{Receiver, channel};
13 use std::time::{Duration, Instant};
14
15 use serde_json::{Value, json};
16
17 const TIMEOUT: Duration = Duration::from_secs(60);
18
19 /// A fake siGit Code Cloud: answers `POST /oauth/token` with a token and
20 /// `GET /api/v1/user` with an email. Returns its base URL.
21 fn start_fake_cloud() -> String {
22 let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake cloud");
23 let port = listener.local_addr().unwrap().port();
24 std::thread::spawn(move || {
25 for stream in listener.incoming() {
26 let Ok(mut stream) = stream else { continue };
27 let mut reader = BufReader::new(stream.try_clone().unwrap());
28 let mut request_line = String::new();
29 reader.read_line(&mut request_line).ok();
30 let mut content_length = 0usize;
31 loop {
32 let mut line = String::new();
33 if reader.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() {
34 break;
35 }
36 if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
37 content_length = length.trim().parse().unwrap_or(0);
38 }
39 }
40 let mut body = vec![0u8; content_length];
41 reader.read_exact(&mut body).ok();
42 let payload = if request_line.contains("/oauth/token") {
43 json!({"access_token": "tok_from_browser", "token_type": "Bearer"})
44 } else {
45 json!({"email": "dev@sigit.si"})
46 }
47 .to_string();
48 let response = format!(
49 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n\
50 content-length: {}\r\nconnection: close\r\n\r\n{}",
51 payload.len(),
52 payload
53 );
54 stream.write_all(response.as_bytes()).ok();
55 }
56 });
57 format!("http://127.0.0.1:{port}")
58 }
59
60 struct AgentUnderTest {
61 child: Child,
62 stdin: ChildStdin,
63 incoming: Receiver<Value>,
64 }
65
66 fn spawn_agent(cloud: &str, config_dir: &std::path::Path) -> AgentUnderTest {
67 let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
68 .env("SIGIT_API_URL", cloud)
69 .env("SIGIT_CLOUD_URL", cloud)
70 .env("SIGIT_CONFIG_DIR", config_dir)
71 .env("SIGIT_MCP", "off")
72 // Nothing here talks to a model; any endpoint will do.
73 .env("OPENAI_BASE_URL", "http://127.0.0.1:9")
74 .env("OPENAI_API_KEY", "test-key")
75 .env("SIGIT_MODEL", "scripted-model")
76 .env_remove("SIGIT_LOCAL_INFERENCE")
77 .stdin(Stdio::piped())
78 .stdout(Stdio::piped())
79 .stderr(Stdio::null())
80 .spawn()
81 .expect("spawn sigit in ACP mode");
82
83 let stdout = child.stdout.take().unwrap();
84 let (message_tx, incoming) = channel();
85 std::thread::spawn(move || {
86 for line in BufReader::new(stdout).lines() {
87 let Ok(line) = line else { break };
88 if let Ok(message) = serde_json::from_str::<Value>(&line)
89 && message_tx.send(message).is_err()
90 {
91 break;
92 }
93 }
94 });
95 let stdin = child.stdin.take().unwrap();
96 AgentUnderTest {
97 child,
98 stdin,
99 incoming,
100 }
101 }
102
103 impl AgentUnderTest {
104 fn send(&mut self, message: Value) {
105 let mut line = message.to_string();
106 line.push('\n');
107 self.stdin.write_all(line.as_bytes()).unwrap();
108 self.stdin.flush().unwrap();
109 }
110
111 fn next(&mut self, what: &str, matches: impl Fn(&Value) -> bool) -> Value {
112 let deadline = Instant::now() + TIMEOUT;
113 loop {
114 let remaining = deadline.saturating_duration_since(Instant::now());
115 match self.incoming.recv_timeout(remaining) {
116 Ok(message) if matches(&message) => return message,
117 Ok(_) => continue,
118 Err(_) => panic!("timed out waiting for {what}"),
119 }
120 }
121 }
122
123 fn response(&mut self, id: u64) -> Value {
124 self.next(&format!("the response to {id}"), |message| {
125 message["id"] == id && message.get("method").is_none()
126 })
127 }
128 }
129
130 impl Drop for AgentUnderTest {
131 fn drop(&mut self) {
132 let _ = self.child.kill();
133 let _ = self.child.wait();
134 }
135 }
136
137 /// The value of `key` in the authorize URL's query, percent-decoded.
138 fn query_value(url: &str, key: &str) -> String {
139 let query = url.split_once('?').expect("query").1;
140 let raw = query
141 .split('&')
142 .find_map(|pair| pair.strip_prefix(&format!("{key}=")))
143 .unwrap_or_else(|| panic!("{key} missing from {url}"));
144 let mut decoded = String::new();
145 let mut bytes = raw.bytes();
146 while let Some(byte) = bytes.next() {
147 if byte == b'%' {
148 let hex: String = [bytes.next().unwrap(), bytes.next().unwrap()]
149 .iter()
150 .map(|&b| b as char)
151 .collect();
152 decoded.push(u8::from_str_radix(&hex, 16).unwrap() as char);
153 } else {
154 decoded.push(byte as char);
155 }
156 }
157 decoded
158 }
159
160 /// Start `authenticate` from a client that renders URL elicitations, and
161 /// return the agent, the request id, and the elicitation it was sent.
162 fn start_sign_in(tag: &str) -> (AgentUnderTest, std::path::PathBuf, Value) {
163 let cloud = start_fake_cloud();
164 let scratch =
165 std::env::temp_dir().join(format!("sigit_acp_elicit_{tag}_{}", std::process::id()));
166 let config_dir = scratch.join("config");
167 std::fs::create_dir_all(&config_dir).unwrap();
168
169 let mut agent = spawn_agent(&cloud, &config_dir);
170 agent.send(json!({
171 "jsonrpc": "2.0", "id": 1, "method": "initialize",
172 "params": {
173 "protocolVersion": 1,
174 "clientCapabilities": {"elicitation": {"url": {}}},
175 },
176 }));
177 agent.response(1);
178
179 agent.send(json!({
180 "jsonrpc": "2.0", "id": 2, "method": "authenticate",
181 "params": {"methodId": "sigit"},
182 }));
183 let elicitation = agent.next("the sign-in elicitation", |message| {
184 message["method"] == "elicitation/create"
185 });
186 let params = &elicitation["params"];
187 assert_eq!(params["mode"], "url", "{params}");
188 assert_eq!(
189 params["requestId"], 2,
190 "scoped to the authenticate request: {params}"
191 );
192 assert!(
193 params["url"]
194 .as_str()
195 .unwrap_or_default()
196 .starts_with(&format!("{cloud}/oauth/authorize?")),
197 "{params}"
198 );
199 assert!(params["elicitationId"].is_string(), "{params}");
200 (agent, scratch, elicitation)
201 }
202
203 #[test]
204 fn sign_in_hands_the_page_to_a_client_that_renders_urls() {
205 let (mut agent, scratch, elicitation) = start_sign_in("accept");
206 let params = &elicitation["params"];
207 let url = params["url"].as_str().unwrap().to_string();
208
209 agent.send(json!({
210 "jsonrpc": "2.0", "id": elicitation["id"], "result": {"action": "accept"},
211 }));
212
213 // The browser finishes the authorization and comes back to sigit.
214 let redirect = query_value(&url, "redirect_uri");
215 let state = query_value(&url, "state");
216 let address = redirect
217 .strip_prefix("http://")
218 .and_then(|rest| rest.split_once('/'))
219 .expect("loopback redirect")
220 .0
221 .to_string();
222 let mut browser = TcpStream::connect(&address).expect("reach the loopback listener");
223 write!(
224 browser,
225 "GET /callback?code=abc&state={state} HTTP/1.1\r\nHost: {address}\r\n\r\n"
226 )
227 .unwrap();
228 let mut page = String::new();
229 browser.read_to_string(&mut page).ok();
230 assert!(page.contains("Signed in"), "{page}");
231
232 // The client hears that the page is done with, then gets its answer.
233 let complete = agent.next("elicitation/complete", |message| {
234 message["method"] == "elicitation/complete"
235 });
236 assert_eq!(complete["params"]["elicitationId"], params["elicitationId"]);
237 let response = agent.response(2);
238 assert!(response.get("error").is_none(), "{response}");
239 let saved = std::fs::read_to_string(scratch.join("config/credentials.toml")).unwrap();
240 assert!(saved.contains("tok_from_browser"), "{saved}");
241
242 drop(agent);
243 let _ = std::fs::remove_dir_all(&scratch);
244 }
245
246 #[test]
247 fn declining_the_sign_in_page_ends_the_sign_in() {
248 let (mut agent, scratch, elicitation) = start_sign_in("decline");
249
250 agent.send(json!({
251 "jsonrpc": "2.0", "id": elicitation["id"], "result": {"action": "decline"},
252 }));
253
254 let response = agent.response(2);
255 assert!(
256 response["error"]["message"]
257 .as_str()
258 .unwrap_or_default()
259 .contains("Sign-in did not complete"),
260 "{response}"
261 );
262 assert!(!scratch.join("config/credentials.toml").exists());
263
264 drop(agent);
265 let _ = std::fs::remove_dir_all(&scratch);
266 }