Skip to main content

ai_usagebar/nous/
oauth.rs

1//! Nous OAuth device flow and refresh state machine.
2
3use 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
67/// A browser launcher is injected so login tests never spawn a real browser.
68pub 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            // Keep the remotely supplied URL out of `cmd.exe`; metacharacters
90            // such as `&` and `%` are data to Explorer, not shell syntax.
91            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
105/// Browser opening is intentionally best effort.  The CLI always prints the
106/// sanitized verification URL separately, so a missing desktop opener is not a
107/// failed authorization.
108pub 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
162/// Poll the token endpoint no faster than the server-provided interval.
163pub 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        // Portal uses 400 for an expired, revoked, reused, or otherwise invalid
242        // refresh grant. All require a clean login; no body text is surfaced.
243        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
260/// Lock, re-read, refresh at most once, and persist the complete rotated pair
261/// before returning the access credential to an account fetcher.
262pub 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, &current.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}