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
+}