返回 CodeWhale
auth.rs
根目录 / crates / tui / src / runtime_api / auth.rs
1 use axum::Json;
2 use axum::extract::{Request, State};
3 use axum::http::{Method, StatusCode, header};
4 use axum::middleware::Next;
5 use axum::response::{IntoResponse, Response};
6 use codewhale_core::secret_eq::constant_time_eq;
7 use serde_json::json;
8
9 use super::{RuntimeApiState, mobile, web};
10
11 #[derive(Debug, Clone, PartialEq, Eq)]
12 pub(super) struct ResolvedRuntimeAuth {
13 pub(super) token: Option<String>,
14 pub(super) generated: bool,
15 }
16
17 pub(super) fn resolve_runtime_auth(
18 cli_token: Option<String>,
19 env_token: Option<String>,
20 insecure_no_auth: bool,
21 ) -> ResolvedRuntimeAuth {
22 if let Some(token) = first_nonblank_token(cli_token).or_else(|| first_nonblank_token(env_token))
23 {
24 return ResolvedRuntimeAuth {
25 token: Some(token),
26 generated: false,
27 };
28 }
29 if insecure_no_auth {
30 return ResolvedRuntimeAuth {
31 token: None,
32 generated: false,
33 };
34 }
35 ResolvedRuntimeAuth {
36 token: Some(generate_runtime_token()),
37 generated: true,
38 }
39 }
40
41 pub(super) fn runtime_auth_status_lines(auth: &ResolvedRuntimeAuth) -> Vec<String> {
42 if auth.generated {
43 return vec![
44 "Runtime API auth: generated bearer token for this process (not printed).".to_string(),
45 " Set CODEWHALE_RUNTIME_TOKEN (or DEEPSEEK_RUNTIME_TOKEN as an alias) or pass --auth-token when another client needs to connect.".to_string(),
46 ];
47 }
48 if auth.token.is_some() {
49 return vec!["Runtime API auth: bearer token required for /v1/* routes.".to_string()];
50 }
51 vec!["Runtime API auth: disabled by explicit insecure mode.".to_string()]
52 }
53
54 fn first_nonblank_token(token: Option<String>) -> Option<String> {
55 token
56 .map(|token| token.trim().to_string())
57 .filter(|token| !token.is_empty())
58 }
59
60 fn generate_runtime_token() -> String {
61 format!(
62 "cwrt_{}{}",
63 uuid::Uuid::new_v4().simple(),
64 uuid::Uuid::new_v4().simple()
65 )
66 }
67
68 pub(super) async fn require_runtime_token(
69 State(state): State<RuntimeApiState>,
70 req: Request,
71 next: Next,
72 ) -> Response {
73 if runtime_request_is_authorized(&req, &state) {
74 next.run(req).await
75 } else {
76 runtime_token_required_response()
77 }
78 }
79
80 pub(super) fn runtime_request_is_authorized(req: &Request, state: &RuntimeApiState) -> bool {
81 let Some(expected) = state.runtime_token.as_deref() else {
82 return true;
83 };
84 if request_has_header_runtime_token(req, expected) {
85 return true;
86 }
87 // Device client tokens (`POST /v1/auth/client-tokens`, <= 1 h, revocable)
88 // carry the same `/v1` authority as the master token, except minting.
89 if request_bearer(req).is_some_and(|token| state.computer.client_principal(token).is_some()) {
90 return true;
91 }
92 if state
93 .web
94 .as_ref()
95 .is_some_and(|web| web_session_request_is_authorized(req, state, web))
96 {
97 return true;
98 }
99 state
100 .mobile
101 .as_ref()
102 .is_some_and(|mobile| mobile_session_request_is_authorized(req, state, mobile))
103 }
104
105 fn request_bearer(req: &Request) -> Option<&str> {
106 req.headers()
107 .get(header::AUTHORIZATION)
108 .and_then(|value| value.to_str().ok())
109 .and_then(|raw| raw.strip_prefix("Bearer "))
110 .or_else(|| {
111 req.headers()
112 .get("x-codewhale-runtime-token")
113 .and_then(|value| value.to_str().ok())
114 })
115 }
116
117 pub(super) fn request_has_header_runtime_token(req: &Request, expected: &str) -> bool {
118 req.headers()
119 .get(header::AUTHORIZATION)
120 .and_then(|value| value.to_str().ok())
121 .and_then(|raw| raw.strip_prefix("Bearer "))
122 .is_some_and(|token| constant_time_eq(token.as_bytes(), expected.as_bytes()))
123 || req
124 .headers()
125 .get("x-codewhale-runtime-token")
126 .and_then(|value| value.to_str().ok())
127 .is_some_and(|token| constant_time_eq(token.as_bytes(), expected.as_bytes()))
128 || req
129 .headers()
130 .get("x-deepseek-runtime-token")
131 .and_then(|value| value.to_str().ok())
132 .is_some_and(|token| constant_time_eq(token.as_bytes(), expected.as_bytes()))
133 }
134
135 /// Web fetches require a cookie and an origin-scoped proof; streams consume
136 /// a single-use ticket instead. Origin metadata is an additional check only.
137 pub(super) fn web_session_request_is_authorized(
138 req: &Request,
139 state: &RuntimeApiState,
140 web: &web::RuntimeWebState,
141 ) -> bool {
142 web_request_is_authorized(req, &runtime_http_origin(state), web)
143 }
144
145 fn web_request_is_authorized(
146 req: &Request,
147 expected_origin: &str,
148 web: &web::RuntimeWebState,
149 ) -> bool {
150 if req
151 .headers()
152 .get("sec-fetch-site")
153 .is_some_and(|value| value != "same-origin")
154 || req
155 .headers()
156 .get(header::ORIGIN)
157 .is_some_and(|value| value != expected_origin)
158 {
159 return false;
160 }
161 let cookie = req
162 .headers()
163 .get(header::COOKIE)
164 .and_then(|value| value.to_str().ok());
165 if is_thread_stream_request(req) {
166 return web.consume_stream_ticket(cookie, stream_ticket(req, web::WEB_STREAM_TICKET_QUERY));
167 }
168 web.matches_request(
169 cookie,
170 req.headers()
171 .get(web::WEB_REQUEST_HEADER)
172 .and_then(|value| value.to_str().ok()),
173 )
174 }
175
176 /// Mobile cookies are host-scoped and can be attached to a sibling loopback
177 /// port. A cookie alone is therefore never Runtime authority: normal fetches
178 /// must also present an origin-scoped proof and EventSource requests must
179 /// consume a short-lived stream ticket. The origin/Fetch Metadata check keeps
180 /// a sibling port from replaying a captured value.
181 pub(super) fn mobile_session_request_is_authorized(
182 req: &Request,
183 state: &RuntimeApiState,
184 mobile_state: &mobile::RuntimeMobileState,
185 ) -> bool {
186 if !mobile_cookie_request_is_same_origin(req, state) {
187 return false;
188 }
189 let cookie_header = req
190 .headers()
191 .get(header::COOKIE)
192 .and_then(|value| value.to_str().ok());
193 if is_thread_stream_request(req) {
194 return mobile_state.consume_stream_ticket(
195 cookie_header,
196 stream_ticket(req, mobile::MOBILE_STREAM_TICKET_QUERY),
197 );
198 }
199 mobile_state.matches_request(
200 cookie_header,
201 req.headers()
202 .get(mobile::MOBILE_REQUEST_HEADER)
203 .and_then(|value| value.to_str().ok()),
204 )
205 }
206
207 fn mobile_cookie_request_is_same_origin(req: &Request, state: &RuntimeApiState) -> bool {
208 let fetch_metadata_is_same_origin = req
209 .headers()
210 .get("sec-fetch-site")
211 .and_then(|value| value.to_str().ok())
212 .is_some_and(|site| site.eq_ignore_ascii_case("same-origin"));
213 if req
214 .headers()
215 .get("sec-fetch-site")
216 .and_then(|value| value.to_str().ok())
217 .is_some_and(|site| !site.eq_ignore_ascii_case("same-origin"))
218 {
219 return false;
220 }
221
222 match req
223 .headers()
224 .get(header::ORIGIN)
225 .and_then(|value| value.to_str().ok())
226 {
227 Some(origin) => origin == runtime_http_origin(state),
228 None => fetch_metadata_is_same_origin,
229 }
230 }
231
232 fn runtime_http_origin(state: &RuntimeApiState) -> String {
233 runtime_http_origin_for_bind(&state.bind_host, state.bind_port)
234 }
235
236 fn runtime_http_origin_for_bind(bind_host: &str, bind_port: u16) -> String {
237 let host = match bind_host.parse::<std::net::IpAddr>() {
238 Ok(std::net::IpAddr::V4(address)) => address.to_string(),
239 Ok(std::net::IpAddr::V6(address)) => format!("[{address}]"),
240 Err(_) => bind_host.to_string(),
241 };
242 if bind_port == 80 {
243 format!("http://{host}")
244 } else {
245 format!("http://{host}:{bind_port}")
246 }
247 }
248
249 fn is_thread_stream_request(req: &Request) -> bool {
250 req.method() == Method::GET
251 && req.uri().path().starts_with("/v1/threads/")
252 && req.uri().path().ends_with("/events")
253 }
254
255 fn stream_ticket<'a>(req: &'a Request, query_key: &str) -> Option<&'a str> {
256 let mut tickets = req
257 .uri()
258 .query()?
259 .split('&')
260 .filter_map(|pair| pair.split_once('='))
261 .filter_map(|(key, value)| (key == query_key).then_some(value));
262 let ticket = tickets.next()?;
263 tickets.next().is_none().then_some(ticket)
264 }
265
266 pub(super) fn runtime_token_required_response() -> Response {
267 (
268 StatusCode::UNAUTHORIZED,
269 Json(json!({
270 "error": {
271 "message": "runtime API bearer token required",
272 "status": StatusCode::UNAUTHORIZED.as_u16(),
273 }
274 })),
275 )
276 .into_response()
277 }
278
279 #[cfg(test)]
280 mod tests {
281 use super::runtime_http_origin_for_bind;
282
283 #[test]
284 fn runtime_surface_hardening_web_requests_require_proof() {
285 use super::*;
286 use axum::body::Body;
287 let (web, nonce) = web::RuntimeWebState::new();
288 let (token, proof) = web.consume(&nonce, "127.0.0.1".parse().unwrap()).unwrap();
289 let cookie = format!("codewhale_web_session={token}");
290 let origin = "http://127.0.0.1:7878";
291 let request = |method: Method, path: &str, proof: Option<&str>, metadata: bool| {
292 let mut builder = Request::builder()
293 .method(method)
294 .uri(path)
295 .header(header::COOKIE, &cookie);
296 if metadata {
297 builder = builder
298 .header(header::ORIGIN, origin)
299 .header("sec-fetch-site", "same-origin");
300 }
301 if let Some(proof) = proof {
302 builder = builder.header(web::WEB_REQUEST_HEADER, proof);
303 }
304 builder.body(Body::empty()).unwrap()
305 };
306 for method in [Method::GET, Method::POST, Method::HEAD, Method::OPTIONS] {
307 for metadata in [false, true] {
308 for presented in [None, Some("wrong-proof")] {
309 assert!(!web_request_is_authorized(
310 &request(method.clone(), "/v1/threads", presented, metadata),
311 origin,
312 &web
313 ));
314 }
315 assert!(web_request_is_authorized(
316 &request(method.clone(), "/v1/threads", Some(&proof), metadata),
317 origin,
318 &web
319 ));
320 }
321 }
322 let mut wrong_origin = request(Method::POST, "/v1/threads", Some(&proof), true);
323 wrong_origin
324 .headers_mut()
325 .insert(header::ORIGIN, "http://127.0.0.1:3000".parse().unwrap());
326 assert!(!web_request_is_authorized(&wrong_origin, origin, &web));
327 let mut cross_site = request(Method::GET, "/v1/threads", Some(&proof), true);
328 cross_site
329 .headers_mut()
330 .insert("sec-fetch-site", "same-site".parse().unwrap());
331 assert!(!web_request_is_authorized(&cross_site, origin, &web));
332
333 assert!(web.refresh_stream_ticket(Some(&cookie), None).is_none());
334 let ticket = web
335 .refresh_stream_ticket(Some(&cookie), Some(&proof))
336 .unwrap();
337 let path = format!("/v1/threads/thread-1/events?web_stream_ticket={ticket}");
338 assert!(!web_request_is_authorized(
339 &request(
340 Method::GET,
341 "/v1/threads/thread-1/events",
342 Some(&proof),
343 true
344 ),
345 origin,
346 &web
347 ));
348 assert!(!web_request_is_authorized(
349 &request(
350 Method::GET,
351 &format!("{path}&web_stream_ticket=extra"),
352 None,
353 true
354 ),
355 origin,
356 &web
357 ));
358 assert!(web_request_is_authorized(
359 &request(Method::GET, &path, None, true),
360 origin,
361 &web
362 ));
363 assert!(!web_request_is_authorized(
364 &request(Method::GET, &path, None, true),
365 origin,
366 &web
367 ));
368 }
369
370 #[test]
371 fn expected_origin_canonicalizes_ipv6_loopback_literals() {
372 assert_eq!(
373 runtime_http_origin_for_bind("0:0:0:0:0:0:0:1", 7878),
374 "http://[::1]:7878"
375 );
376 assert_eq!(runtime_http_origin_for_bind("::1", 80), "http://[::1]");
377 }
378 }
379
379 lines RUST