1use std::fmt;
4use std::process::Command;
5use std::time::{Duration, Instant};
6
7use chrono::{DateTime, Utc};
8use serde_json::Value;
9use thiserror::Error;
10
11use super::credentials::{CredentialDocument, CredentialStore, NousCredential};
12use super::types::{DeviceCode, TokenResponse, parse_device_code, parse_token};
13
14pub const CLIENT_ID: &str = "hermes-cli";
15pub const SCOPE: &str = "inference:invoke";
16pub const DEVICE_CODE_URL: &str = "https://portal.nousresearch.com/api/oauth/device/code";
17pub const TOKEN_URL: &str = "https://portal.nousresearch.com/api/oauth/token";
18pub const REFRESH_SKEW_SECONDS: i64 = 120;
19pub const SLOW_DOWN_INCREMENT: Duration = Duration::from_secs(5);
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Endpoints {
23 pub device_code: String,
24 pub token: String,
25}
26
27impl Default for Endpoints {
28 fn default() -> Self {
29 Self {
30 device_code: DEVICE_CODE_URL.into(),
31 token: TOKEN_URL.into(),
32 }
33 }
34}
35
36#[derive(Debug, Error, Clone, PartialEq, Eq)]
37pub enum OAuthError {
38 #[error("OAuth transport failure")]
39 Transport,
40 #[error("OAuth HTTP request returned status {0}")]
41 HttpStatus(u16),
42 #[error("OAuth response schema mismatch")]
43 Schema,
44 #[error("OAuth server returned an unknown error")]
45 UnknownOAuthError,
46 #[error("authorization was denied")]
47 AccessDenied,
48 #[error("device authorization expired")]
49 ExpiredToken,
50 #[error("device authorization deadline elapsed")]
51 Deadline,
52 #[error("refresh authorization was rejected; login is required again")]
53 RefreshTokenRejected,
54 #[error("credential store failure")]
55 Credentials,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum PollState {
60 AuthorizationPending,
61 SlowDown,
62 Success,
63 AccessDenied,
64 ExpiredToken,
65}
66
67pub trait BrowserOpener {
69 fn open(&self, url: &str) -> std::io::Result<()>;
70}
71
72#[derive(Debug, Default, Clone, Copy)]
73pub struct SystemBrowserOpener;
74
75impl BrowserOpener for SystemBrowserOpener {
76 fn open(&self, url: &str) -> std::io::Result<()> {
77 #[cfg(target_os = "linux")]
78 {
79 let _child = Command::new("xdg-open").arg(url).spawn()?;
80 Ok(())
81 }
82 #[cfg(target_os = "macos")]
83 {
84 let _child = Command::new("open").arg(url).spawn()?;
85 Ok(())
86 }
87 #[cfg(target_os = "windows")]
88 {
89 let _child = Command::new("explorer.exe").arg(url).spawn()?;
92 Ok(())
93 }
94 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
95 {
96 let _ = url;
97 Err(std::io::Error::new(
98 std::io::ErrorKind::Unsupported,
99 "browser opening is unsupported",
100 ))
101 }
102 }
103}
104
105pub fn open_verification_url(url: &str, opener: &dyn BrowserOpener) -> bool {
109 if !is_safe_portal_url(url) {
110 return false;
111 }
112 opener.open(url).is_ok()
113}
114
115pub async fn request_device_code(
116 client: &reqwest::Client,
117 endpoints: &Endpoints,
118) -> Result<DeviceCode, OAuthError> {
119 let response = client
120 .post(&endpoints.device_code)
121 .header("content-type", "application/x-www-form-urlencoded")
122 .form(&[("client_id", CLIENT_ID), ("scope", SCOPE)])
123 .send()
124 .await
125 .map_err(|_| OAuthError::Transport)?;
126 let status = response.status();
127 if !status.is_success() {
128 return Err(OAuthError::HttpStatus(status.as_u16()));
129 }
130 let body = crate::vendor::read_body_capped(response, crate::vendor::MAX_BODY_BYTES)
131 .await
132 .map_err(|_| OAuthError::Transport)?;
133 let value: Value = serde_json::from_slice(&body).map_err(|_| OAuthError::Schema)?;
134 parse_device_code(&value).map_err(|_| OAuthError::Schema)
135}
136
137pub fn classify_poll_response(status: u16, value: &Value) -> Result<PollState, OAuthError> {
138 if (200..300).contains(&status) {
139 parse_token(value).map_err(|_| OAuthError::Schema)?;
140 return Ok(PollState::Success);
141 }
142 let code = value
143 .get("error")
144 .and_then(Value::as_str)
145 .ok_or(OAuthError::UnknownOAuthError)?;
146 match code {
147 "authorization_pending" => Ok(PollState::AuthorizationPending),
148 "slow_down" => Ok(PollState::SlowDown),
149 "access_denied" => Ok(PollState::AccessDenied),
150 "expired_token" => Ok(PollState::ExpiredToken),
151 _ => Err(OAuthError::UnknownOAuthError),
152 }
153}
154
155pub fn next_poll_interval(current: Duration, state: PollState) -> Duration {
156 match state {
157 PollState::SlowDown => current.saturating_add(SLOW_DOWN_INCREMENT),
158 _ => current,
159 }
160}
161
162pub async fn poll_for_token(
164 client: &reqwest::Client,
165 endpoint: &str,
166 device: &DeviceCode,
167) -> Result<TokenResponse, OAuthError> {
168 let deadline = Instant::now().checked_add(Duration::from_secs(device.expires_in));
169 let Some(deadline) = deadline else {
170 return Err(OAuthError::Deadline);
171 };
172 let mut interval = Duration::from_secs(device.interval);
173 loop {
174 if Instant::now()
175 .checked_add(interval)
176 .is_none_or(|at| at > deadline)
177 {
178 return Err(OAuthError::Deadline);
179 }
180 tokio::time::sleep(interval).await;
181 if Instant::now() > deadline {
182 return Err(OAuthError::Deadline);
183 }
184 let (status, value) = poll_request(client, endpoint, &device.device_code).await?;
185 match classify_poll_response(status, &value)? {
186 PollState::AuthorizationPending => {}
187 PollState::SlowDown => interval = next_poll_interval(interval, PollState::SlowDown),
188 PollState::Success => return parse_token(&value).map_err(|_| OAuthError::Schema),
189 PollState::AccessDenied => return Err(OAuthError::AccessDenied),
190 PollState::ExpiredToken => return Err(OAuthError::ExpiredToken),
191 }
192 }
193}
194
195async fn poll_request(
196 client: &reqwest::Client,
197 endpoint: &str,
198 device_code: &str,
199) -> Result<(u16, Value), OAuthError> {
200 let response = client
201 .post(endpoint)
202 .header("content-type", "application/x-www-form-urlencoded")
203 .form(&[
204 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
205 ("client_id", CLIENT_ID),
206 ("device_code", device_code),
207 ])
208 .send()
209 .await
210 .map_err(|_| OAuthError::Transport)?;
211 let status = response.status().as_u16();
212 let body = crate::vendor::read_body_capped(response, crate::vendor::MAX_BODY_BYTES)
213 .await
214 .map_err(|_| OAuthError::Transport)?;
215 let value = serde_json::from_slice(&body).map_err(|_| OAuthError::Schema)?;
216 Ok((status, value))
217}
218
219pub async fn refresh_access_token(
220 client: &reqwest::Client,
221 endpoint: &str,
222 refresh_token: &str,
223) -> Result<TokenResponse, OAuthError> {
224 if refresh_token.trim().is_empty() {
225 return Err(OAuthError::Credentials);
226 }
227 let response = client
228 .post(endpoint)
229 .header("content-type", "application/x-www-form-urlencoded")
230 .header("x-nous-refresh-token", refresh_token)
231 .form(&[
232 ("grant_type", "refresh_token"),
233 ("client_id", CLIENT_ID),
234 ("refresh_token", refresh_token),
235 ])
236 .send()
237 .await
238 .map_err(|_| OAuthError::Transport)?;
239 let status = response.status();
240 if !status.is_success() {
241 if status.as_u16() == 400 {
244 return Err(OAuthError::RefreshTokenRejected);
245 }
246 return Err(OAuthError::HttpStatus(status.as_u16()));
247 }
248 let body = crate::vendor::read_body_capped(response, crate::vendor::MAX_BODY_BYTES)
249 .await
250 .map_err(|_| OAuthError::Transport)?;
251 let value: Value = serde_json::from_slice(&body).map_err(|_| OAuthError::Schema)?;
252 parse_token(&value).map_err(|_| OAuthError::Schema)
253}
254
255pub fn needs_refresh(now: DateTime<Utc>, expires_at: DateTime<Utc>) -> bool {
256 now.checked_add_signed(chrono::Duration::seconds(REFRESH_SKEW_SECONDS))
257 .is_none_or(|threshold| expires_at <= threshold)
258}
259
260pub async fn refresh_if_needed(
263 client: &reqwest::Client,
264 store: &CredentialStore,
265 endpoint: &str,
266 now: DateTime<Utc>,
267) -> Result<NousCredential, OAuthError> {
268 let lock = store.acquire_lock().map_err(|_| OAuthError::Credentials)?;
269 let document = store
270 .read_unlocked()
271 .map_err(|_| OAuthError::Credentials)?
272 .ok_or(OAuthError::Credentials)?;
273 let current = document
274 .nous
275 .as_ref()
276 .ok_or(OAuthError::Credentials)?
277 .clone();
278 if !needs_refresh(now, current.expires_at) {
279 drop(lock);
280 return Ok(current);
281 }
282 let token = refresh_access_token(client, endpoint, ¤t.refresh_token).await?;
283 let expires_at = token_expiration(now, token.expires_in)?;
284 let replacement = NousCredential {
285 client_id: CLIENT_ID.into(),
286 access_token: token.access_token,
287 refresh_token: token.refresh_token,
288 expires_at,
289 };
290 replacement.validate().map_err(|_| OAuthError::Schema)?;
291 let mut replacement_document = document;
292 replacement_document.nous = Some(replacement.clone());
293 store
294 .write_locked(&lock, &replacement_document)
295 .map_err(|_| OAuthError::Credentials)?;
296 drop(lock);
297 Ok(replacement)
298}
299
300pub fn credential_from_token(
301 token: TokenResponse,
302 now: DateTime<Utc>,
303) -> Result<NousCredential, OAuthError> {
304 let credential = NousCredential {
305 client_id: CLIENT_ID.into(),
306 access_token: token.access_token,
307 refresh_token: token.refresh_token,
308 expires_at: token_expiration(now, token.expires_in)?,
309 };
310 credential.validate().map_err(|_| OAuthError::Schema)?;
311 Ok(credential)
312}
313
314pub fn persist_credential(
315 store: &CredentialStore,
316 credential: NousCredential,
317) -> Result<(), OAuthError> {
318 let lock = store.acquire_lock().map_err(|_| OAuthError::Credentials)?;
319 let mut document = store
320 .read_unlocked()
321 .map_err(|_| OAuthError::Credentials)?
322 .unwrap_or_else(|| CredentialDocument::new(None));
323 document.nous = Some(credential);
324 store
325 .write_locked(&lock, &document)
326 .map_err(|_| OAuthError::Credentials)
327}
328
329fn token_expiration(now: DateTime<Utc>, expires_in: u64) -> Result<DateTime<Utc>, OAuthError> {
330 let seconds = i64::try_from(expires_in).map_err(|_| OAuthError::Schema)?;
331 now.checked_add_signed(chrono::Duration::seconds(seconds))
332 .ok_or(OAuthError::Schema)
333}
334
335fn is_safe_portal_url(url: &str) -> bool {
336 let Ok(parsed) = reqwest::Url::parse(url) else {
337 return false;
338 };
339 parsed.scheme() == "https"
340 && parsed.host_str() == Some("portal.nousresearch.com")
341 && parsed.port_or_known_default() == Some(443)
342 && parsed.username().is_empty()
343 && parsed.password().is_none()
344}
345
346impl fmt::Display for PollState {
347 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
348 f.write_str(match self {
349 Self::AuthorizationPending => "authorization_pending",
350 Self::SlowDown => "slow_down",
351 Self::Success => "success",
352 Self::AccessDenied => "access_denied",
353 Self::ExpiredToken => "expired_token",
354 })
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use std::time::Duration;
361
362 use chrono::{Duration as ChronoDuration, TimeZone, Utc};
363 use serde_json::json;
364
365 use super::*;
366
367 #[tokio::test]
368 async fn device_request_uses_the_exact_portal_form_and_parses_response() {
369 let mut server = mockito::Server::new_async().await;
370 let mock = server
371 .mock("POST", "/device")
372 .match_header(
373 "content-type",
374 mockito::Matcher::Regex("application/x-www-form-urlencoded.*".into()),
375 )
376 .match_body(mockito::Matcher::AllOf(vec![
377 mockito::Matcher::UrlEncoded("client_id".into(), "hermes-cli".into()),
378 mockito::Matcher::UrlEncoded("scope".into(), "inference:invoke".into()),
379 ]))
380 .with_status(200)
381 .with_body(r#"{"device_code":"test-device","user_code":"TEST","verification_uri":"https://portal.nousresearch.com/device","verification_uri_complete":"https://portal.nousresearch.com/device?user_code=TEST","expires_in":900,"interval":5}"#)
382 .create_async()
383 .await;
384 let endpoints = Endpoints {
385 device_code: format!("{}/device", server.url()),
386 token: format!("{}/token", server.url()),
387 };
388
389 let result = request_device_code(&reqwest::Client::new(), &endpoints)
390 .await
391 .unwrap();
392 assert_eq!(result.device_code, "test-device");
393 mock.assert_async().await;
394 }
395
396 #[test]
397 fn poll_state_classifies_pending_slowdown_success_denial_expiry_and_unknown_errors() {
398 assert_eq!(
399 classify_poll_response(400, &json!({"error":"authorization_pending"})).unwrap(),
400 PollState::AuthorizationPending
401 );
402 assert_eq!(
403 classify_poll_response(400, &json!({"error":"slow_down"})).unwrap(),
404 PollState::SlowDown
405 );
406 assert_eq!(
407 classify_poll_response(400, &json!({"error":"access_denied"})).unwrap(),
408 PollState::AccessDenied
409 );
410 assert_eq!(
411 classify_poll_response(400, &json!({"error":"expired_token"})).unwrap(),
412 PollState::ExpiredToken
413 );
414 assert_eq!(
415 classify_poll_response(
416 200,
417 &json!({"access_token":"test-a","refresh_token":"test-r","token_type":"Bearer","expires_in":3600})
418 )
419 .unwrap(),
420 PollState::Success
421 );
422 assert!(matches!(
423 classify_poll_response(400, &json!({"error":"made_up"})),
424 Err(OAuthError::UnknownOAuthError)
425 ));
426 }
427
428 #[test]
429 fn slow_down_adds_five_seconds_but_pending_keeps_the_authorized_interval() {
430 assert_eq!(
431 next_poll_interval(Duration::from_secs(5), PollState::AuthorizationPending),
432 Duration::from_secs(5)
433 );
434 assert_eq!(
435 next_poll_interval(Duration::from_secs(5), PollState::SlowDown),
436 Duration::from_secs(10)
437 );
438 }
439
440 #[test]
441 fn refresh_threshold_is_exactly_120_seconds() {
442 let now = Utc.with_ymd_and_hms(2026, 8, 16, 12, 0, 0).unwrap();
443 assert!(!needs_refresh(now, now + ChronoDuration::seconds(121)));
444 assert!(needs_refresh(now, now + ChronoDuration::seconds(120)));
445 assert!(needs_refresh(now, now + ChronoDuration::seconds(119)));
446 assert!(needs_refresh(now, now - ChronoDuration::seconds(1)));
447 }
448
449 #[tokio::test]
450 async fn refresh_request_uses_header_and_required_form_without_secret_in_url() {
451 let mut server = mockito::Server::new_async().await;
452 let mock = server
453 .mock("POST", "/token")
454 .match_header("x-nous-refresh-token", "test-old-refresh")
455 .match_header(
456 "content-type",
457 mockito::Matcher::Regex("application/x-www-form-urlencoded.*".into()),
458 )
459 .match_body(mockito::Matcher::AllOf(vec![
460 mockito::Matcher::UrlEncoded("grant_type".into(), "refresh_token".into()),
461 mockito::Matcher::UrlEncoded("client_id".into(), "hermes-cli".into()),
462 mockito::Matcher::UrlEncoded(
463 "refresh_token".into(),
464 "test-old-refresh".into(),
465 ),
466 ]))
467 .with_status(200)
468 .with_body(r#"{"access_token":"test-new-access","refresh_token":"test-new-refresh","token_type":"Bearer","expires_in":3600}"#)
469 .create_async()
470 .await;
471 let token = refresh_access_token(
472 &reqwest::Client::new(),
473 &format!("{}/token", server.url()),
474 "test-old-refresh",
475 )
476 .await
477 .unwrap();
478 assert_eq!(token.access_token, "test-new-access");
479 mock.assert_async().await;
480 }
481
482 #[test]
483 fn browser_open_failure_is_nonfatal_and_error_debug_is_redacted() {
484 struct FailingBrowser;
485 impl BrowserOpener for FailingBrowser {
486 fn open(&self, _url: &str) -> std::io::Result<()> {
487 Err(std::io::Error::other("test failure"))
488 }
489 }
490 assert!(!open_verification_url(
491 "https://portal.nousresearch.com/device",
492 &FailingBrowser
493 ));
494 let error = OAuthError::HttpStatus(401);
495 assert!(!format!("{error:?}").contains("test-access-token"));
496 }
497
498 #[test]
499 fn browser_opener_accepts_only_the_production_portal_origin() {
500 use std::cell::Cell;
501
502 struct RecordingBrowser(Cell<usize>);
503 impl BrowserOpener for RecordingBrowser {
504 fn open(&self, _url: &str) -> std::io::Result<()> {
505 self.0.set(self.0.get() + 1);
506 Ok(())
507 }
508 }
509
510 let browser = RecordingBrowser(Cell::new(0));
511 assert!(open_verification_url(
512 "https://portal.nousresearch.com/device?user_code=TEST",
513 &browser
514 ));
515 for unsafe_url in [
516 "https://portal.nousresearch.com.evil.test/device",
517 "https://evil.test/device&calc.exe",
518 "http://portal.nousresearch.com/device",
519 "https://user@portal.nousresearch.com/device",
520 ] {
521 assert!(!open_verification_url(unsafe_url, &browser));
522 }
523 assert_eq!(browser.0.get(), 1, "rejected URLs must never reach the OS");
524 }
525
526 #[test]
527 fn token_expiration_rejects_overflow_instead_of_wrapping_or_panicking() {
528 let now = Utc.with_ymd_and_hms(2026, 8, 16, 12, 0, 0).unwrap();
529 assert_eq!(
530 token_expiration(now, 3600).unwrap(),
531 now + ChronoDuration::hours(1)
532 );
533 assert_eq!(token_expiration(now, u64::MAX), Err(OAuthError::Schema));
534 assert_eq!(
535 token_expiration(DateTime::<Utc>::MAX_UTC, 1),
536 Err(OAuthError::Schema)
537 );
538 }
539
540 #[tokio::test]
541 async fn any_bad_refresh_grant_requires_a_clean_login() {
542 let mut server = mockito::Server::new_async().await;
543 server
544 .mock("POST", "/token")
545 .with_status(400)
546 .with_body("non-json error body that must not affect classification")
547 .create_async()
548 .await;
549
550 let error = refresh_access_token(
551 &reqwest::Client::new(),
552 &format!("{}/token", server.url()),
553 "test-old-refresh",
554 )
555 .await
556 .unwrap_err();
557 assert_eq!(error, OAuthError::RefreshTokenRejected);
558 }
559}