返回 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 serde_json::json;
7
8 use super::RuntimeApiState;
9
10 const RUNTIME_TOKEN_COOKIE: &str = "codewhale_runtime_token";
11
12 #[derive(Debug, Clone, PartialEq, Eq)]
13 pub(super) struct ResolvedRuntimeAuth {
14 pub(super) token: Option<String>,
15 pub(super) generated: bool,
16 }
17
18 pub(super) fn resolve_runtime_auth(
19 cli_token: Option<String>,
20 env_token: Option<String>,
21 insecure_no_auth: bool,
22 ) -> ResolvedRuntimeAuth {
23 if let Some(token) = first_nonblank_token(cli_token).or_else(|| first_nonblank_token(env_token))
24 {
25 return ResolvedRuntimeAuth {
26 token: Some(token),
27 generated: false,
28 };
29 }
30 if insecure_no_auth {
31 return ResolvedRuntimeAuth {
32 token: None,
33 generated: false,
34 };
35 }
36 ResolvedRuntimeAuth {
37 token: Some(generate_runtime_token()),
38 generated: true,
39 }
40 }
41
42 pub(super) fn runtime_auth_status_lines(auth: &ResolvedRuntimeAuth) -> Vec<String> {
43 if auth.generated {
44 return vec![
45 "Runtime API auth: generated bearer token for this process (not printed).".to_string(),
46 " Set CODEWHALE_RUNTIME_TOKEN (or DEEPSEEK_RUNTIME_TOKEN as an alias) or pass --auth-token when another client needs to connect.".to_string(),
47 ];
48 }
49 if auth.token.is_some() {
50 return vec!["Runtime API auth: bearer token required for /v1/* routes.".to_string()];
51 }
52 vec!["Runtime API auth: disabled by explicit insecure mode.".to_string()]
53 }
54
55 fn first_nonblank_token(token: Option<String>) -> Option<String> {
56 token
57 .map(|token| token.trim().to_string())
58 .filter(|token| !token.is_empty())
59 }
60
61 fn generate_runtime_token() -> String {
62 format!(
63 "cwrt_{}{}",
64 uuid::Uuid::new_v4().simple(),
65 uuid::Uuid::new_v4().simple()
66 )
67 }
68
69 pub(super) async fn require_runtime_token(
70 State(state): State<RuntimeApiState>,
71 req: Request,
72 next: Next,
73 ) -> Response {
74 if runtime_request_is_authorized(&req, &state) {
75 next.run(req).await
76 } else {
77 runtime_token_required_response()
78 }
79 }
80
81 pub(super) fn runtime_request_is_authorized(req: &Request, state: &RuntimeApiState) -> bool {
82 let Some(expected) = state.runtime_token.as_deref() else {
83 return true;
84 };
85 let cookie_authorized = request_has_runtime_cookie(req, expected)
86 || state.web.as_ref().is_some_and(|web| {
87 web.matches_session_cookie(
88 req.headers()
89 .get(header::COOKIE)
90 .and_then(|value| value.to_str().ok()),
91 )
92 });
93 request_has_header_runtime_token(req, expected)
94 || (cookie_authorized && web_cookie_request_is_same_origin(req, state))
95 }
96
97 fn request_has_header_runtime_token(req: &Request, expected: &str) -> bool {
98 req.headers()
99 .get(header::AUTHORIZATION)
100 .and_then(|value| value.to_str().ok())
101 .and_then(|raw| raw.strip_prefix("Bearer "))
102 .is_some_and(|token| token == expected)
103 || req
104 .headers()
105 .get("x-codewhale-runtime-token")
106 .and_then(|value| value.to_str().ok())
107 .is_some_and(|token| token == expected)
108 || req
109 .headers()
110 .get("x-deepseek-runtime-token")
111 .and_then(|value| value.to_str().ok())
112 .is_some_and(|token| token == expected)
113 }
114
115 fn request_has_runtime_cookie(req: &Request, expected: &str) -> bool {
116 token_from_cookie_header(
117 req.headers()
118 .get(header::COOKIE)
119 .and_then(|value| value.to_str().ok()),
120 )
121 .is_some_and(|token| token == expected)
122 }
123
124 /// The web bootstrap adds cookie authentication to the existing bearer/header
125 /// boundary. SameSite is site-scoped rather than origin-scoped, so a sibling
126 /// loopback port can still receive the cookie. Require browser-origin evidence
127 /// for unsafe methods and reject Fetch Metadata that identifies any
128 /// cross-origin cookie request. Bearer and explicit runtime-token headers keep
129 /// their existing behavior.
130 fn web_cookie_request_is_same_origin(req: &Request, state: &RuntimeApiState) -> bool {
131 if state.web.is_none() {
132 return true;
133 }
134
135 if req
136 .headers()
137 .get("sec-fetch-site")
138 .and_then(|value| value.to_str().ok())
139 .is_some_and(|site| !site.eq_ignore_ascii_case("same-origin"))
140 {
141 return false;
142 }
143
144 let expected_origin = if state.bind_port == 80 {
145 format!("http://{}", state.bind_host)
146 } else {
147 format!("http://{}:{}", state.bind_host, state.bind_port)
148 };
149 if let Some(origin) = req
150 .headers()
151 .get(header::ORIGIN)
152 .and_then(|value| value.to_str().ok())
153 {
154 return origin == expected_origin;
155 }
156
157 matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS)
158 }
159
160 fn runtime_token_required_response() -> Response {
161 (
162 StatusCode::UNAUTHORIZED,
163 Json(json!({
164 "error": {
165 "message": "runtime API bearer token required",
166 "status": StatusCode::UNAUTHORIZED.as_u16(),
167 }
168 })),
169 )
170 .into_response()
171 }
172
173 pub(super) fn token_from_cookie_header(cookie: Option<&str>) -> Option<String> {
174 cookie.and_then(|cookie| {
175 cookie.split(';').find_map(|pair| {
176 let pair = pair.trim();
177 let (key, value) = pair.split_once('=')?;
178 (key == RUNTIME_TOKEN_COOKIE)
179 .then(|| percent_decode_query_component(value.trim()))
180 .flatten()
181 })
182 })
183 }
184
185 fn percent_decode_query_component(value: &str) -> Option<String> {
186 let bytes = value.as_bytes();
187 let mut decoded = Vec::with_capacity(bytes.len());
188 let mut index = 0;
189 while index < bytes.len() {
190 match bytes[index] {
191 b'%' => {
192 let hi = *bytes.get(index + 1)?;
193 let lo = *bytes.get(index + 2)?;
194 let hi = (hi as char).to_digit(16)? as u8;
195 let lo = (lo as char).to_digit(16)? as u8;
196 decoded.push((hi << 4) | lo);
197 index += 3;
198 }
199 b'+' => {
200 decoded.push(b' ');
201 index += 1;
202 }
203 byte => {
204 decoded.push(byte);
205 index += 1;
206 }
207 }
208 }
209 String::from_utf8(decoded).ok()
210 }
211
211 lines RUST