Skip to content

Commit 7b86672

Browse files
authored
feat(auth): upgrade legacy CLI tokens automatically (#143)
* feat(auth): upgrade legacy CLI tokens automatically Detect the API's cli_token_upgrade_required 422 response, call the CLI token upgrade endpoint, persist the returned token/expires_at while preserving legacy local metadata, and retry the original authenticated operation once. Apply the flow to org lookup, session publication create/confirm, and backfill completion sync. Login exchange now tolerates the provider-neutral response without a GitHub login, and tests cover upgrade success, failure, non-upgrade 422s, retry capping, and config persistence. * chore: bump version to 2.6.2
1 parent 38dd622 commit 7b86672

7 files changed

Lines changed: 889 additions & 117 deletions

File tree

‎.release-please-manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
".": "2.6.1"
2+
".": "2.6.2"
33
}

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cadence-cli"
3-
version = "2.6.1"
3+
version = "2.6.2"
44
edition = "2024"
55
default-run = "cadence"
66

‎src/api_client.rs‎

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ use crate::transport;
1616
// ---------------------------------------------------------------------------
1717

1818
const AUTH_EXCHANGE_PATH: &str = "/api/auth/exchange";
19+
const AUTH_TOKEN_UPGRADE_PATH: &str = "/api/auth/token/upgrade";
1920
const AUTH_REVOKE_PATH: &str = "/api/auth";
2021
const BACKFILL_COMPLETE_PATH: &str = "/api/onboarding/backfill-complete";
2122
const USER_ORGS_PATH: &str = "/api/user/orgs";
2223
const SESSION_PUBLICATION_CREATE_PATH: &str = "/api/v2/session-publications";
24+
const TOKEN_UPGRADE_REQUIRED_ERROR: &str = "cli_token_upgrade_required";
2325

2426
// ---------------------------------------------------------------------------
2527
// Response DTOs
@@ -36,8 +38,9 @@ struct ExchangeRequest<'a> {
3638
pub struct CliTokenExchangeResult {
3739
/// Bearer token used by the CLI for authenticated API calls.
3840
pub token: String,
39-
/// GitHub login associated with the authenticated user.
40-
pub login: String,
41+
/// Legacy GitHub login, returned by older API versions.
42+
#[serde(default)]
43+
pub login: Option<String>,
4144
/// RFC 3339 expiration timestamp for the token.
4245
pub expires_at: String,
4346
}
@@ -73,10 +76,15 @@ pub struct BackfillCompleteResponse {
7376
/// Minimal org info from `GET /api/user/orgs`.
7477
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
7578
pub struct UserOrgInfo {
76-
/// GitHub organization id for the account.
77-
pub github_org_id: i64,
78-
/// GitHub login for the org or personal account.
79-
pub github_org_login: String,
79+
/// Legacy GitHub organization id for the account, returned by older API versions.
80+
#[serde(default)]
81+
pub github_org_id: Option<i64>,
82+
/// Provider-specific slug for the org or personal account.
83+
#[serde(default, alias = "org_slug")]
84+
pub github_org_login: Option<String>,
85+
/// Provider that owns this org slug. Missing means legacy GitHub-shaped API response.
86+
#[serde(default)]
87+
pub provider: Option<String>,
8088
/// Display name shown to the user, when available.
8189
pub display_name: Option<String>,
8290
/// Whether this record represents the user's personal account.
@@ -164,6 +172,8 @@ struct ApiResponseEnvelope<T> {
164172
pub enum AuthenticatedRequestError {
165173
/// Authentication failed or the token has expired.
166174
Unauthorized,
175+
/// The API requires this CLI token to be upgraded before retrying.
176+
TokenUpgradeRequired(String),
167177
/// The request conflicted with current server state.
168178
Conflict(String),
169179
/// The target resource could not be found.
@@ -186,6 +196,7 @@ impl std::fmt::Display for AuthenticatedRequestError {
186196
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187197
match self {
188198
Self::Unauthorized => write!(f, "unauthorized"),
199+
Self::TokenUpgradeRequired(msg) => write!(f, "token_upgrade_required: {msg}"),
189200
Self::Conflict(msg) => write!(f, "conflict: {msg}"),
190201
Self::NotFound => write!(f, "not_found"),
191202
Self::Unprocessable(msg) => write!(f, "unprocessable: {msg}"),
@@ -271,6 +282,34 @@ impl ApiClient {
271282
Ok(envelope.data)
272283
}
273284

285+
/// Upgrade an existing CLI token to the current API JWT shape.
286+
pub async fn upgrade_cli_token(
287+
&self,
288+
token: &str,
289+
timeout: Duration,
290+
) -> std::result::Result<CliTokenExchangeResult, AuthenticatedRequestError> {
291+
let url = self.url(AUTH_TOKEN_UPGRADE_PATH);
292+
let resp = self
293+
.client
294+
.post(&url)
295+
.bearer_auth(token)
296+
.timeout(timeout)
297+
.send()
298+
.await
299+
.map_err(map_network_error)?;
300+
301+
if !resp.status().is_success() {
302+
let status = resp.status().as_u16();
303+
let body = resp.text().await.unwrap_or_default();
304+
return Err(map_authenticated_http_error(status, &body));
305+
}
306+
307+
let body = resp.text().await.map_err(map_network_error)?;
308+
let envelope: ApiResponseEnvelope<CliTokenExchangeResult> = serde_json::from_str(&body)
309+
.map_err(|e| AuthenticatedRequestError::Parse(e.to_string()))?;
310+
Ok(envelope.data)
311+
}
312+
274313
/// Revoke a bearer token via `DELETE /api/auth`.
275314
pub async fn revoke_token(
276315
&self,
@@ -505,6 +544,9 @@ fn map_authenticated_http_error(status: u16, body: &str) -> AuthenticatedRequest
505544
400 => AuthenticatedRequestError::BadRequest(detail),
506545
404 => AuthenticatedRequestError::NotFound,
507546
409 => AuthenticatedRequestError::Conflict(detail),
547+
422 if extract_error_code(body).as_deref() == Some(TOKEN_UPGRADE_REQUIRED_ERROR) => {
548+
AuthenticatedRequestError::TokenUpgradeRequired(detail)
549+
}
508550
422 => AuthenticatedRequestError::Unprocessable(detail),
509551
500..=599 => AuthenticatedRequestError::Server(detail),
510552
_ => AuthenticatedRequestError::Unexpected(format!("HTTP {status}: {detail}")),
@@ -565,6 +607,12 @@ fn extract_error_message(body: &str) -> String {
565607
}
566608
}
567609

610+
fn extract_error_code(body: &str) -> Option<String> {
611+
serde_json::from_str::<serde_json::Value>(body)
612+
.ok()
613+
.and_then(|value| value.get("error")?.as_str().map(ToString::to_string))
614+
}
615+
568616
// ---------------------------------------------------------------------------
569617
// Tests
570618
// ---------------------------------------------------------------------------
@@ -596,6 +644,13 @@ mod tests {
596644
map_authenticated_http_error(422, ""),
597645
AuthenticatedRequestError::Unprocessable(_)
598646
));
647+
assert!(matches!(
648+
map_authenticated_http_error(
649+
422,
650+
r#"{"error":"cli_token_upgrade_required","message":"upgrade me"}"#
651+
),
652+
AuthenticatedRequestError::TokenUpgradeRequired(_)
653+
));
599654
assert!(matches!(
600655
map_authenticated_http_error(404, ""),
601656
AuthenticatedRequestError::NotFound
@@ -606,6 +661,44 @@ mod tests {
606661
));
607662
}
608663

664+
#[test]
665+
fn cli_token_exchange_result_allows_missing_legacy_login() {
666+
let envelope: ApiResponseEnvelope<CliTokenExchangeResult> = serde_json::from_str(
667+
r#"{"data":{"token":"token","expires_at":"2099-01-01T00:00:00Z"}}"#,
668+
)
669+
.expect("parse token exchange envelope");
670+
671+
assert_eq!(envelope.data.token, "token");
672+
assert_eq!(envelope.data.login, None);
673+
assert_eq!(envelope.data.expires_at, "2099-01-01T00:00:00Z");
674+
}
675+
676+
#[test]
677+
fn user_orgs_response_allows_current_api_org_slug_shape() {
678+
let envelope: ApiResponseEnvelope<UserOrgsResponse> = serde_json::from_str(
679+
r#"{"data":{"orgs":[{
680+
"org_slug":"cadence-org",
681+
"provider":"github",
682+
"display_name":"Cadence Org",
683+
"avatar_url":"https://example.test/avatar.png",
684+
"is_personal":false,
685+
"is_onboarded":true,
686+
"has_active_installation":true,
687+
"org_id":"org-1",
688+
"role":"manager",
689+
"onboarding_hint":"ready"
690+
}]}}"#,
691+
)
692+
.expect("parse current user orgs envelope");
693+
694+
let org = &envelope.data.orgs[0];
695+
assert_eq!(org.github_org_id, None);
696+
assert_eq!(org.github_org_login.as_deref(), Some("cadence-org"));
697+
assert_eq!(org.provider.as_deref(), Some("github"));
698+
assert_eq!(org.org_id.as_deref(), Some("org-1"));
699+
assert!(org.has_active_installation);
700+
}
701+
609702
#[test]
610703
fn session_upload_request_serializes_upload_sha256_field() {
611704
let request = CreateSessionPublicationRequest {

‎src/config.rs‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,22 @@ impl CliConfig {
143143
self.save().await
144144
}
145145

146+
/// Replace the auth token lifecycle fields and save.
147+
///
148+
/// Preserves `api_url`, legacy `github_login`, and non-auth settings. The
149+
/// login field is retained only as legacy local display metadata because
150+
/// current API tokens are tied to Cadence user IDs, not provider logins.
151+
pub async fn replace_auth_token(&mut self, token: String, expires_at: String) -> Result<()> {
152+
self.replace_auth_token_fields(token, expires_at);
153+
self.save().await
154+
}
155+
156+
/// Replace in-memory token lifecycle fields without saving.
157+
pub(crate) fn replace_auth_token_fields(&mut self, token: String, expires_at: String) {
158+
self.token = Some(token);
159+
self.expires_at = Some(expires_at);
160+
}
161+
146162
/// Returns the persisted auth token when present and non-empty.
147163
pub fn auth_token(&self) -> Option<String> {
148164
non_empty_trimmed(self.token.clone())
@@ -462,6 +478,17 @@ impl CliConfig {
462478
self.expires_at = None;
463479
self.save_to(path).await
464480
}
481+
482+
/// Replace token lifecycle fields and save to a specific path.
483+
async fn replace_auth_token_to(
484+
&mut self,
485+
path: &Path,
486+
token: String,
487+
expires_at: String,
488+
) -> Result<()> {
489+
self.replace_auth_token_fields(token, expires_at);
490+
self.save_to(path).await
491+
}
465492
}
466493

467494
// ---------------------------------------------------------------------------
@@ -744,6 +771,36 @@ mod tests {
744771
assert_eq!(cfg.token, None);
745772
}
746773

774+
#[tokio::test]
775+
async fn test_replace_auth_token_preserves_api_url_and_legacy_login() {
776+
let tmp = TempDir::new().unwrap();
777+
let path = config_path_in(tmp.path());
778+
779+
let mut cfg = CliConfig {
780+
api_url: Some("https://api.example.com".to_string()),
781+
token: Some("old-token".to_string()),
782+
github_login: Some("legacy-login".to_string()),
783+
expires_at: Some("2025-01-01T00:00:00Z".to_string()),
784+
update_check_interval: Some("8h".to_string()),
785+
..Default::default()
786+
};
787+
788+
cfg.replace_auth_token_to(
789+
&path,
790+
"new-token".to_string(),
791+
"2026-01-01T00:00:00Z".to_string(),
792+
)
793+
.await
794+
.unwrap();
795+
796+
let loaded = CliConfig::load_from(&path).await.unwrap();
797+
assert_eq!(loaded.api_url, Some("https://api.example.com".to_string()));
798+
assert_eq!(loaded.token, Some("new-token".to_string()));
799+
assert_eq!(loaded.github_login, Some("legacy-login".to_string()));
800+
assert_eq!(loaded.expires_at, Some("2026-01-01T00:00:00Z".to_string()));
801+
assert_eq!(loaded.update_check_interval, Some("8h".to_string()));
802+
}
803+
747804
#[tokio::test]
748805
async fn test_auth_token_trims_value() {
749806
let cfg = CliConfig {

0 commit comments

Comments
 (0)