33use anyhow:: { Result , bail} ;
44
55use 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).
2424pub 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+
120140pub 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