Skip to content

Commit 9b493a8

Browse files
authored
Add --device flag for headless OAuth login and fix session token handling (#71)
1 parent 54f6834 commit 9b493a8

17 files changed

Lines changed: 1294 additions & 139 deletions

‎Cargo.lock‎

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ lto = "thin"
1616
codegen-units = 1
1717

1818
[workspace.package]
19-
version = "0.2.2"
19+
version = "0.3.0"
2020
edition = "2024"
2121
rust-version = "1.88"
2222
license = "Apache-2.0"
@@ -63,7 +63,7 @@ indicatif = "0.18"
6363
wiremock = "0.6.5"
6464
clap = { version = "4", features = ["derive", "env"] }
6565
fs4 = { version = "0.12.0", features = ["sync"] }
66-
am-core-types = { path = "crates/core-types", version = "0.2.2" }
67-
am-cloud-types = { path = "crates/cloud-types", version = "0.2.2" }
68-
am-cloud-client = { path = "crates/cloud-client", version = "0.2.2" }
69-
atomicmemory = { path = "crates/cli", version = "0.2.2" }
66+
am-core-types = { path = "crates/core-types", version = "0.3.0" }
67+
am-cloud-types = { path = "crates/cloud-types", version = "0.3.0" }
68+
am-cloud-client = { path = "crates/cloud-client", version = "0.3.0" }
69+
atomicmemory = { path = "crates/cli", version = "0.3.0" }

‎crates/cli/README.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,12 +208,19 @@ machines therefore keep independent credentials.
208208

209209
### Token fallback
210210

211-
Paste a dashboard session JWT when browser OAuth is unavailable:
211+
On a remote or headless host, prefer device login (refreshable Cloud session):
212+
213+
```bash
214+
am auth login --device
215+
```
216+
217+
Paste a dashboard session JWT only for a short session (no refresh token):
212218

213219
```bash
214220
am auth login --token "eyJ..."
215221
```
216222

223+
`--no-browser` uses the same device flow as `--device`.
217224
## Defaults
218225

219226
| Setting | Default |

‎crates/cli/src/auth/claims.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ pub fn token_has_active_org(id_token: &str) -> bool {
4848

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

5454
pub fn decode_id_token(id_token: &str) -> Result<IdClaims> {

‎crates/cli/src/auth/clerk_oauth.rs‎

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
use anyhow::{Result, bail};
44

55
use crate::config::ConfigFile;
6-
use crate::environment::{Environment, is_production_api_url};
6+
use crate::environment::{Environment, is_first_party_cloud_api_url, is_production_api_url};
77

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

2020
/// Public OAuth `client_id` for end-user login (PKCE). Never uses `CLERK_SECRET_KEY`.
2121
///
22-
/// Production API URL: CLI `--client-id` → baked prod preset → config → env.
22+
/// First-party API URL (prod / Dev / staging): CLI `--client-id` → baked preset → …
2323
/// Custom API URL: `--client-id` → config → env (fail closed — never use prod OAuth).
2424
pub fn resolve_public_client_id(
2525
config: &ConfigFile,
@@ -29,7 +29,7 @@ pub fn resolve_public_client_id(
2929
if let Some(id) = flag_override {
3030
return Ok(id);
3131
}
32-
if is_production_api_url(base_url) {
32+
if is_first_party_cloud_api_url(base_url) {
3333
return Ok(Environment::PROD_OAUTH_CLIENT_ID.to_string());
3434
}
3535
if let Some(id) = usable_for_custom_origin(
@@ -49,7 +49,10 @@ pub fn resolve_public_client_id(
4949
bail!(
5050
"browser login is not configured in this CLI build yet.\n\
5151
\n\
52-
Sign in via the web console and run:\n\
52+
Preferred on remote/headless hosts (refreshable):\n\
53+
am auth login --device\n\
54+
\n\
55+
Short paste session from the web console:\n\
5356
am auth login --token <your-session-jwt>\n\
5457
\n\
5558
Or run `am auth doctor` to diagnose OAuth configuration."
@@ -62,14 +65,16 @@ pub fn resolve_public_client_id(
6265
Set issuer and client_id in config.toml, or run:\n\
6366
am auth login --issuer <clerk-issuer> --client-id <public-client-id>\n\
6467
\n\
65-
Or sign in via the web console:\n\
68+
Or use device login on remote hosts:\n\
69+
am auth login --device\n\
70+
Or sign in via the web console (short paste, no refresh):\n\
6671
am auth login --token <your-session-jwt>"
6772
)
6873
}
6974

7075
/// Resolve the OAuth issuer for a Cloud API base URL.
7176
///
72-
/// Production API URL: CLI `--issuer` → baked prod preset.
77+
/// First-party API URL (prod / Dev / staging): CLI `--issuer` → baked preset.
7378
/// Custom API URL: `--issuer` → env → config (fail closed).
7479
///
7580
/// The production issuer and client_id are shipped as ONE pair. Consulting a
@@ -85,7 +90,7 @@ fn resolve_issuer(
8590
if let Some(issuer) = flag_override.filter(|s| !s.is_empty()) {
8691
return Ok(issuer);
8792
}
88-
if is_production_api_url(base_url) {
93+
if is_first_party_cloud_api_url(base_url) {
8994
return Ok(Environment::PROD_OAUTH_ISSUER.to_string());
9095
}
9196
if let Some(issuer) = usable_for_custom_origin(
@@ -117,10 +122,26 @@ pub fn resolve_oauth_pair(
117122
Ok((issuer, client_id))
118123
}
119124

125+
/// Refuse blank `--issuer` / `--client-id` overrides before any login work.
126+
///
127+
/// Both login paths persist overrides into the global `[oauth]` table. A blank
128+
/// value is not "no override": persisted, it replaces a working pair for every
129+
/// profile and every later command. Omitting the flag is the way to use the
130+
/// configured pair.
131+
pub fn reject_blank_oauth_overrides(issuer: Option<&str>, client_id: Option<&str>) -> Result<()> {
132+
for (flag, value) in [("--issuer", issuer), ("--client-id", client_id)] {
133+
if value.is_some_and(|value| value.trim().is_empty()) {
134+
bail!("{flag} must not be blank; omit it to use the configured OAuth pair");
135+
}
136+
}
137+
Ok(())
138+
}
139+
120140
pub fn invalid_client_help() -> &'static str {
121141
"The OAuth client_id in this CLI build is not accepted by Clerk (invalid_client).\n\
122142
Run `am auth doctor` to diagnose (checks env overrides and Clerk registration).\n\
123-
Fallback: am auth login --token <dashboard-jwt>"
143+
Remote/headless: am auth login --device\n\
144+
Short paste: am auth login --token <dashboard-jwt>"
124145
}
125146

126147
#[cfg(test)]
@@ -213,6 +234,49 @@ mod tests {
213234
assert_eq!(client_id, "staging-client");
214235
}
215236

237+
#[test]
238+
fn blank_oauth_overrides_are_refused_and_omitted_ones_allowed() {
239+
for (issuer, client_id, flag) in [
240+
(Some(""), None, "--issuer"),
241+
(Some(" "), None, "--issuer"),
242+
(None, Some(""), "--client-id"),
243+
(None, Some(" \t"), "--client-id"),
244+
] {
245+
let err = reject_blank_oauth_overrides(issuer, client_id)
246+
.expect_err("blank override must be refused")
247+
.to_string();
248+
assert!(err.contains(&format!("{flag} must not be blank")), "{err}");
249+
}
250+
reject_blank_oauth_overrides(None, None).unwrap();
251+
reject_blank_oauth_overrides(Some("https://clerk.example"), Some("client")).unwrap();
252+
}
253+
254+
#[test]
255+
fn first_party_dev_url_uses_shipped_oauth_pair_without_config() {
256+
// ATO-2321: api.dev is first-party; whoami/doctor must not demand a
257+
// non-production issuer in config.toml (prod issuer there was filtered).
258+
let config = ConfigFile::default();
259+
let (issuer, client_id) =
260+
resolve_oauth_pair(&config, "https://api.dev.atomicstrata.ai", None, None).unwrap();
261+
assert_eq!(issuer, Environment::PROD_OAUTH_ISSUER);
262+
assert_eq!(client_id, Environment::PROD_OAUTH_CLIENT_ID);
263+
}
264+
265+
#[test]
266+
fn first_party_dev_url_ignores_stale_custom_config_pair() {
267+
let config = ConfigFile {
268+
oauth: crate::config::OAuthDefaults {
269+
issuer: Some("https://clerk.custom.example".into()),
270+
client_id: Some("stale-client".into()),
271+
},
272+
..Default::default()
273+
};
274+
let (issuer, client_id) =
275+
resolve_oauth_pair(&config, "https://api.dev.atomicstrata.ai/", None, None).unwrap();
276+
assert_eq!(issuer, Environment::PROD_OAUTH_ISSUER);
277+
assert_eq!(client_id, Environment::PROD_OAUTH_CLIENT_ID);
278+
}
279+
216280
#[test]
217281
fn custom_origin_refuses_the_shipped_production_pair_from_config() {
218282
// `default_config()` used to seed config.toml with the production

0 commit comments

Comments
 (0)