Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ lto = "thin"
codegen-units = 1

[workspace.package]
version = "0.2.2"
version = "0.3.0"
edition = "2024"
rust-version = "1.88"
license = "Apache-2.0"
Expand Down Expand Up @@ -63,7 +63,7 @@ indicatif = "0.18"
wiremock = "0.6.5"
clap = { version = "4", features = ["derive", "env"] }
fs4 = { version = "0.12.0", features = ["sync"] }
am-core-types = { path = "crates/core-types", version = "0.2.2" }
am-cloud-types = { path = "crates/cloud-types", version = "0.2.2" }
am-cloud-client = { path = "crates/cloud-client", version = "0.2.2" }
atomicmemory = { path = "crates/cli", version = "0.2.2" }
am-core-types = { path = "crates/core-types", version = "0.3.0" }
am-cloud-types = { path = "crates/cloud-types", version = "0.3.0" }
am-cloud-client = { path = "crates/cloud-client", version = "0.3.0" }
atomicmemory = { path = "crates/cli", version = "0.3.0" }
9 changes: 8 additions & 1 deletion crates/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,12 +208,19 @@ machines therefore keep independent credentials.

### Token fallback

Paste a dashboard session JWT when browser OAuth is unavailable:
On a remote or headless host, prefer device login (refreshable Cloud session):

```bash
am auth login --device
```

Paste a dashboard session JWT only for a short session (no refresh token):

```bash
am auth login --token "eyJ..."
```

`--no-browser` uses the same device flow as `--device`.
## Defaults

| Setting | Default |
Expand Down
2 changes: 1 addition & 1 deletion crates/cli/src/auth/claims.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ pub fn token_has_active_org(id_token: &str) -> bool {

pub fn missing_org_login_hint() -> &'static str {
"Session has no active organization — run `am init` to bootstrap a personal workspace, \
or `am auth login --token <jwt>` from memory.dev with an org selected."
`am auth login --device`, or `am auth login --token <jwt>` from memory.dev with an org selected."
}

pub fn decode_id_token(id_token: &str) -> Result<IdClaims> {
Expand Down
84 changes: 74 additions & 10 deletions crates/cli/src/auth/clerk_oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
use anyhow::{Result, bail};

use crate::config::ConfigFile;
use crate::environment::{Environment, is_production_api_url};
use crate::environment::{Environment, is_first_party_cloud_api_url, is_production_api_url};

/// Accept a stored/env OAuth value only if it is not the shipped production
/// credential.
///
/// Reaching this point means the base URL is NOT the production origin, so the
/// production issuer/client_id must never be used: an older CLI seeded them
/// Reaching this point means the base URL is NOT a first-party Cloud origin, so
/// the production issuer/client_id must never be used: an older CLI seeded them
/// into `config.toml`, and reading them back would hand the production identity
/// to an arbitrary `--base-url` — the bearer token is then attached to that
/// origin. Fail closed instead and require explicit configuration.
Expand All @@ -19,7 +19,7 @@ fn usable_for_custom_origin(value: Option<String>, shipped_production: &str) ->

/// Public OAuth `client_id` for end-user login (PKCE). Never uses `CLERK_SECRET_KEY`.
///
/// Production API URL: CLI `--client-id` → baked prod preset → config → env.
/// First-party API URL (prod / Dev / staging): CLI `--client-id` → baked preset → …
/// Custom API URL: `--client-id` → config → env (fail closed — never use prod OAuth).
pub fn resolve_public_client_id(
config: &ConfigFile,
Expand All @@ -29,7 +29,7 @@ pub fn resolve_public_client_id(
if let Some(id) = flag_override {
return Ok(id);
}
if is_production_api_url(base_url) {
if is_first_party_cloud_api_url(base_url) {
return Ok(Environment::PROD_OAUTH_CLIENT_ID.to_string());
}
if let Some(id) = usable_for_custom_origin(
Expand All @@ -49,7 +49,10 @@ pub fn resolve_public_client_id(
bail!(
"browser login is not configured in this CLI build yet.\n\
\n\
Sign in via the web console and run:\n\
Preferred on remote/headless hosts (refreshable):\n\
am auth login --device\n\
\n\
Short paste session from the web console:\n\
am auth login --token <your-session-jwt>\n\
\n\
Or run `am auth doctor` to diagnose OAuth configuration."
Expand All @@ -62,14 +65,16 @@ pub fn resolve_public_client_id(
Set issuer and client_id in config.toml, or run:\n\
am auth login --issuer <clerk-issuer> --client-id <public-client-id>\n\
\n\
Or sign in via the web console:\n\
Or use device login on remote hosts:\n\
am auth login --device\n\
Or sign in via the web console (short paste, no refresh):\n\
am auth login --token <your-session-jwt>"
)
}

/// Resolve the OAuth issuer for a Cloud API base URL.
///
/// Production API URL: CLI `--issuer` → baked prod preset.
/// First-party API URL (prod / Dev / staging): CLI `--issuer` → baked preset.
/// Custom API URL: `--issuer` → env → config (fail closed).
///
/// The production issuer and client_id are shipped as ONE pair. Consulting a
Expand All @@ -85,7 +90,7 @@ fn resolve_issuer(
if let Some(issuer) = flag_override.filter(|s| !s.is_empty()) {
return Ok(issuer);
}
if is_production_api_url(base_url) {
if is_first_party_cloud_api_url(base_url) {
return Ok(Environment::PROD_OAUTH_ISSUER.to_string());
}
if let Some(issuer) = usable_for_custom_origin(
Expand Down Expand Up @@ -117,10 +122,26 @@ pub fn resolve_oauth_pair(
Ok((issuer, client_id))
}

/// Refuse blank `--issuer` / `--client-id` overrides before any login work.
///
/// Both login paths persist overrides into the global `[oauth]` table. A blank
/// value is not "no override": persisted, it replaces a working pair for every
/// profile and every later command. Omitting the flag is the way to use the
/// configured pair.
pub fn reject_blank_oauth_overrides(issuer: Option<&str>, client_id: Option<&str>) -> Result<()> {
for (flag, value) in [("--issuer", issuer), ("--client-id", client_id)] {
if value.is_some_and(|value| value.trim().is_empty()) {
bail!("{flag} must not be blank; omit it to use the configured OAuth pair");
}
}
Ok(())
}

pub fn invalid_client_help() -> &'static str {
"The OAuth client_id in this CLI build is not accepted by Clerk (invalid_client).\n\
Run `am auth doctor` to diagnose (checks env overrides and Clerk registration).\n\
Fallback: am auth login --token <dashboard-jwt>"
Remote/headless: am auth login --device\n\
Short paste: am auth login --token <dashboard-jwt>"
}

#[cfg(test)]
Expand Down Expand Up @@ -213,6 +234,49 @@ mod tests {
assert_eq!(client_id, "staging-client");
}

#[test]
fn blank_oauth_overrides_are_refused_and_omitted_ones_allowed() {
for (issuer, client_id, flag) in [
(Some(""), None, "--issuer"),
(Some(" "), None, "--issuer"),
(None, Some(""), "--client-id"),
(None, Some(" \t"), "--client-id"),
] {
let err = reject_blank_oauth_overrides(issuer, client_id)
.expect_err("blank override must be refused")
.to_string();
assert!(err.contains(&format!("{flag} must not be blank")), "{err}");
}
reject_blank_oauth_overrides(None, None).unwrap();
reject_blank_oauth_overrides(Some("https://clerk.example"), Some("client")).unwrap();
}

#[test]
fn first_party_dev_url_uses_shipped_oauth_pair_without_config() {
// ATO-2321: api.dev is first-party; whoami/doctor must not demand a
// non-production issuer in config.toml (prod issuer there was filtered).
let config = ConfigFile::default();
let (issuer, client_id) =
resolve_oauth_pair(&config, "https://api.dev.atomicstrata.ai", None, None).unwrap();
assert_eq!(issuer, Environment::PROD_OAUTH_ISSUER);
assert_eq!(client_id, Environment::PROD_OAUTH_CLIENT_ID);
}

#[test]
fn first_party_dev_url_ignores_stale_custom_config_pair() {
let config = ConfigFile {
oauth: crate::config::OAuthDefaults {
issuer: Some("https://clerk.custom.example".into()),
client_id: Some("stale-client".into()),
},
..Default::default()
};
let (issuer, client_id) =
resolve_oauth_pair(&config, "https://api.dev.atomicstrata.ai/", None, None).unwrap();
assert_eq!(issuer, Environment::PROD_OAUTH_ISSUER);
assert_eq!(client_id, Environment::PROD_OAUTH_CLIENT_ID);
}

#[test]
fn custom_origin_refuses_the_shipped_production_pair_from_config() {
// `default_config()` used to seed config.toml with the production
Expand Down
Loading
Loading