Skip to content
Open
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
39 changes: 30 additions & 9 deletions crates/pcb-diode-api/src/auth.rs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Service-account fallback is bypassed

With 31–60 seconds remaining, saved_token renews a service-account token and propagates any failure. The Git exchange never receives its still-valid token.

(Refers to this code)

Prompt for agents
Extend the Git-only refresh fallback to saved service-account authentication. In crates/pcb-diode-api/src/auth.rs, get_valid_token_with_sources returns from service_account::saved_token before the human-token fallback logic. AccessToken::is_valid uses a 60-second margin, so a renewal failure with 31–60 seconds remaining rejects a token that still covers the Git exchange's 30-second timeout. Preserve the existing behavior for normal callers and environment credentials, while allowing the Git-specific helper to reuse the saved service-account token only when its post-failure remaining lifetime exceeds the requested fallback lifetime. Add coverage for failed and successful service-account renewal around this window.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The observation is correct, but this saved-service-account behavior also exists on current origin/main. This PR fixes the human OAuth refresh-token failure path; it leaves service-account renewal and other callers unchanged. Extending fallback into saved_token would change a separate client-credentials path that re-reads credentials under a lock, so it is not included as a regression fix here. This thread remains open for a human scope/follow-up decision; no service-account behavior change is proposed in this PR.

Original file line number Diff line number Diff line change
Expand Up @@ -303,11 +303,12 @@ pub fn refresh_tokens() -> Result<AuthTokens> {
}

pub fn get_valid_token_with_context(ctx: &WorkspaceContext) -> Result<String> {
get_valid_token_with_sources(ctx, refresh_tokens_with_context)
get_valid_token_with_sources(ctx, None, refresh_tokens_with_context)
}

fn get_valid_token_with_sources(
ctx: &WorkspaceContext,
refresh_fallback_lifetime: Option<Duration>,
refresh_tokens: impl Fn(&WorkspaceContext) -> Result<AuthTokens>,
) -> Result<String> {
let not_authenticated = || anyhow::anyhow!(NOT_AUTHENTICATED_MESSAGE);
Expand All @@ -330,7 +331,16 @@ fn get_valid_token_with_sources(

match refresh_tokens(ctx) {
Ok(new_tokens) => Ok(new_tokens.access_token),
Err(_) => Err(not_authenticated()),
Err(_) => {
// Check after refreshing: a slow failure may consume the remaining lifetime.
if let Some(lifetime) = refresh_fallback_lifetime {
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64;
if tokens.expires_at - now > lifetime.as_secs() as i64 {
return Ok(tokens.access_token);
}
}
Err(not_authenticated())
}
}
}

Expand All @@ -352,6 +362,17 @@ pub fn get_api_token_with_context(ctx: &WorkspaceContext) -> Result<Option<Strin
}
}

pub(crate) fn get_api_token_with_refresh_fallback(
ctx: &WorkspaceContext,
lifetime: Duration,
) -> Result<Option<String>> {
if api_auth_disabled() {
Ok(None)
} else {
get_valid_token_with_sources(ctx, Some(lifetime), refresh_tokens_with_context).map(Some)
}
}

pub fn get_api_token() -> Result<Option<String>> {
let ctx = WorkspaceContext::from_cwd().unwrap_or_default();
get_api_token_with_context(&ctx)
Expand Down Expand Up @@ -816,7 +837,7 @@ mod tests {
let (_tempdir, _guard, ctx) = isolated_context();
let refresh_calls = Cell::new(0);

let err = get_valid_token_with_sources(&ctx, |_| {
let err = get_valid_token_with_sources(&ctx, None, |_| {
refresh_calls.set(refresh_calls.get() + 1);
anyhow::bail!("refresh should not be called")
})
Expand Down Expand Up @@ -850,21 +871,21 @@ mod tests {

#[test]
#[serial]
fn expired_auth_file_refresh_failure_returns_not_authenticated() {
fn near_expiry_auth_file_refresh_failure_without_fallback_returns_not_authenticated() {
let (_tempdir, _guard, ctx) = isolated_context();
save_tokens(
&ctx,
"expired-token",
"still-valid-token",
"refresh-token",
unix_now() - 3600,
unix_now() + 200,
Some("user@example.com"),
None,
None,
)
.unwrap();
let refresh_calls = Cell::new(0);

let err = get_valid_token_with_sources(&ctx, |_| {
let err = get_valid_token_with_sources(&ctx, None, |_| {
refresh_calls.set(refresh_calls.get() + 1);
anyhow::bail!("refresh failed")
})
Expand All @@ -890,7 +911,7 @@ mod tests {
.unwrap();
let refresh_calls = Cell::new(0);

let token = get_valid_token_with_sources(&ctx, |_| {
let token = get_valid_token_with_sources(&ctx, None, |_| {
refresh_calls.set(refresh_calls.get() + 1);
Ok(AuthTokens {
access_token: "refreshed-token".to_string(),
Expand Down Expand Up @@ -923,7 +944,7 @@ mod tests {
.unwrap();
let refresh_calls = Cell::new(0);

let token = get_valid_token_with_sources(&ctx, |_| {
let token = get_valid_token_with_sources(&ctx, None, |_| {
refresh_calls.set(refresh_calls.get() + 1);
anyhow::bail!("refresh should not be called")
})
Expand Down
8 changes: 5 additions & 3 deletions crates/pcb-diode-api/src/git_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,17 +167,19 @@ fn exchange_credential(
path: &str,
) -> Result<MintedGitCredential> {
let url = format!("{}/api/git/credentials", ctx.api_base_url());
let timeout = Duration::from_secs(30);
let client = Client::builder()
.user_agent(format!("diode-pcb/{}", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(30))
.timeout(timeout)
.build()
.context("Failed to create Git credential HTTP client")?;

// Only this short exchange may reuse a token after refresh failure.
let token = crate::auth::get_api_token_with_refresh_fallback(ctx, timeout)?;
let request = client
.post(url)
.json(&GitCredentialExchangeRequest { host, path });

let response = crate::auth::apply_api_auth_with_context(ctx, request)?
let response = crate::auth::apply_bearer_auth(request, token.as_deref())
.send()
.context("Failed to exchange Diode authentication for a Git credential")?;

Expand Down
57 changes: 57 additions & 0 deletions crates/pcbc/tests/auth_git.rs
Original file line number Diff line number Diff line change
Expand Up @@ -662,3 +662,60 @@ fn store_and_erase_are_silent_without_exchanging_credentials() {
assert!(output.stderr.is_empty());
}
}

#[test]
fn near_expiry_token_refresh() {
for (remaining_seconds, refresh_status, bearer) in [
(200, 503, Some("Bearer still-valid-token")),
(30, 503, None),
(200, 200, Some("Bearer refreshed-token")),
] {
let server = MockServer::start();
let api_url = server.base_url();
let context = TestContext::new(api_url.clone());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
fs::write(
context
.config_dir
.join("auth")
.join(format!("{}.toml", auth_scope_slug(&api_url))),
format!(
"access_token = \"still-valid-token\"\n\
refresh_token = \"refresh-token\"\n\
expires_at = {}\n\
token_endpoint = \"{api_url}/oauth/token\"\n\
client_id = \"test-client-id\"\n",
now + remaining_seconds,
),
)
.unwrap();
let refresh = server.mock(|when, then| {
when.method(POST).path("/oauth/token");
then.status(refresh_status).json_body(json!({
"access_token": "refreshed-token",
"refresh_token": "new-refresh-token",
"expires_in": 3600,
}));
});
let exchange = mock_exchange(&server, GIT_API_HOST, 200, bearer);
let mut command = context.pcbc();
command.args(["auth", "git", "--host", GIT_API_HOST, "get"]);
let fill = run_with_input(command, &credential_request());
assert_success(&fill);
let stdout = String::from_utf8_lossy(&fill.stdout);
if bearer.is_some() {
assert!(fill.stderr.is_empty(), "{fill:?}");
assert!(!stdout.contains("quit=true"));
assert!(stdout.contains(&format!("credential={REPOSITORY_TOKEN}")));
} else {
assert!(stdout.contains("quit=true"));
assert!(String::from_utf8_lossy(&fill.stderr).contains("Not authenticated"));
assert!(!stdout.contains("credential="));
}
refresh.assert_calls(1);
exchange.assert_calls(usize::from(bearer.is_some()));
}
}
Loading