fix(coder-lite): validate tokens against /api/v1/models instead of /api/v1/user

b17bf694bdd8 · AtlantisPleb · · parent 2d4480ea0948

fix(coder-lite): validate tokens against /api/v1/models instead of /api/v1/user

The dev server can return 503 github_unavailable when reading the
authenticated user because that path touches GitHub. The /api/v1/models
catalog is a light, non-GitHub endpoint that still requires a valid
bearer token, so we now use it to check OPENAGENTS_API_KEY, stored
tokens, and freshly granted device tokens. The success message is plain
'Authenticated.' to avoid any hyperlink/underline rendering.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified Cargo.lock
  • modified crates/coder-lite/Cargo.toml
  • modified crates/coder-lite/src/interactive.rs

Diff

3 files changed, +29 -16

Cargo.lock modified +1

@@ -457,6 +457,7 @@ dependencies = [

457 457
 "pretty_assertions",
458 458
 "pulldown-cmark",
459 459
 "ratatui",
460
 "reqwest",
460 461
 "serde",
461 462
 "serde_json",
462 463
 "supports-color",
crates/coder-lite/Cargo.toml modified +1

@@ -27,6 +27,7 @@ ratatui = { version = "0.29", default-features = false, features = ["crossterm"]

27 27
futures = "0.3"
28 28
serde = { workspace = true, features = ["derive"] }
29 29
serde_json = { workspace = true }
30
reqwest = { workspace = true }
30 31
31 32
# Streaming markdown engine (src/markdown), ported from xAI grok-build.
32 33
# Versions match grok-build's workspace pins.
crates/coder-lite/src/interactive.rs modified +27 -16

@@ -27,7 +27,6 @@ use crossterm::{

27 27
};
28 28
use openagents_cli::auth::{CredentialStore, DeviceClient, Secret, open_browser};
29 29
use openagents_cli::composer::complete::{Completion, complete};
30
use openagents_cli::repo::RepoClient;
31 30
use openagents_cli::composer::history::History;
32 31
use openagents_cli::composer::ComposerAction;
33 32
use openagents_cli::runtime::Lane;

@@ -219,11 +218,7 @@ async fn ensure_authenticated() -> Result<(), Box<dyn std::error::Error>> {

219 218
220 219
    if let Ok(key) = std::env::var("OPENAGENTS_API_KEY") {
221 220
        let token = Secret::new(key);
222
        if RepoClient::new(&endpoint.origin, Some(token))
223
            .authenticated_user()
224
            .await
225
            .is_ok()
226
        {
221
        if validate_token(&endpoint.origin, &token).await.is_ok() {
227 222
            return Ok(());
228 223
        }
229 224
        eprintln!("OPENAGENTS_API_KEY did not authenticate; it will be ignored.");

@@ -232,11 +227,7 @@ async fn ensure_authenticated() -> Result<(), Box<dyn std::error::Error>> {

232 227
    }
233 228
234 229
    if let Some(held) = store.find_token()? {
235
        if RepoClient::new(&endpoint.origin, Some(held.token.clone()))
236
            .authenticated_user()
237
            .await
238
            .is_ok()
239
        {
230
        if validate_token(&endpoint.origin, &held.token).await.is_ok() {
240 231
            return Ok(());
241 232
        }
242 233
        eprintln!("Stored token did not authenticate; logging in again.");

@@ -257,9 +248,31 @@ async fn ensure_authenticated() -> Result<(), Box<dyn std::error::Error>> {

257 248
    }
258 249
}
259 250
251
/// Check that a token is accepted by the deployment without calling GitHub.
252
/// `GET /api/v1/models` is a light, non-GitHub endpoint that still requires a
253
/// valid bearer token, so a 200 here means the token is good to spend.
254
async fn validate_token(origin: &str, token: &Secret) -> Result<(), Box<dyn std::error::Error>> {
255
    let client = reqwest::Client::builder()
256
        .timeout(Duration::from_secs(10))
257
        .build()?;
258
    let url = format!("{}/api/v1/models", origin.trim_end_matches('/'));
259
    let response = client
260
        .get(&url)
261
        .bearer_auth(token.expose())
262
        .send()
263
        .await
264
        .map_err(|error| format!("could not reach {origin}: {error}"))?;
265
    let status = response.status();
266
    if status.is_success() {
267
        return Ok(());
268
    }
269
    let body = response.text().await.unwrap_or_default();
270
    Err(format!("token rejected by {origin} ({status}): {body}").into())
271
}
272
260 273
/// Start the GitHub device-authorization flow against the current endpoint,
261 274
/// open the approval URL in the browser, poll for the token, store it, and
262
/// verify it by reading the authenticated user.
275
/// verify it against the model catalog.
263 276
pub async fn do_login() -> Result<String, Box<dyn std::error::Error>> {
264 277
    let endpoint = openagents_cli::auth::resolve_endpoint(None, None)?;
265 278
    let client = DeviceClient::new(&endpoint.origin);

@@ -272,10 +285,8 @@ pub async fn do_login() -> Result<String, Box<dyn std::error::Error>> {

272 285
    let store = CredentialStore::for_origin(&endpoint.origin);
273 286
    let _ = store.store(&token)?;
274 287
275
    let user = RepoClient::new(&endpoint.origin, Some(token))
276
        .authenticated_user()
277
        .await?;
278
    Ok(format!("Authenticated as {}.", user.login))
288
    validate_token(&endpoint.origin, &token).await?;
289
    Ok("Authenticated.".to_string())
279 290
}
280 291
281 292
/// The columns the composer soft-wraps to: the frame's width less its border

This page updates live while a promote is in flight · changelog