Skip to content

Add 5-minute token refresh buffer to pre-emptively refresh early - #1535

Merged
mihaimitrea-db merged 8 commits into
mainfrom
mihaimitrea-db/increase-token-refresh-buffer
Mar 17, 2026
Merged

mihaimitrea-db merged 8 commits into
mainfrom
mihaimitrea-db/increase-token-refresh-buffer

Conversation

@mihaimitrea-db

@mihaimitrea-db mihaimitrea-db commented Mar 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Proactively refresh OAuth tokens that will expire within 5 minutes, preventing callers of databricks auth token from receiving near-expired tokens that may expire before they can be used.

The CLI uses the SDK's logic to obtain tokens either from the server of from the cache. This change is intended to affect the CLI.

Why

The oauth2 library's default expiryDelta is 10 seconds, meaning Token.Valid() only triggers a refresh in the last 10 seconds before expiry. External consumers that use databricks auth token as a credential helper can receive a token with very little time remaining, which may expire before the next request is made: Please support customizing expiryDelta for auth token command

This PR adds a hardcoded 5-minute refresh buffer so that tokens are refreshed early enough to be usable if received even right before a refresh.

What changed

Interface changes

None.

Behavioral changes

  • PersistentAuth.Token() now refreshes the token when it is valid but will expire within 5 minutes, instead of waiting until the last 10 seconds (the oauth2 library default).
  • Tokens with a zero expiry (never-expiring tokens) are not affected.

Internal changes

  • Added tokenRefreshBuffer constant (5 minutes) in credentials/u2m/persistent_auth.go.
  • Added tokenExpiringSoon function that checks whether a token is within the buffer window.
  • Added forceRefresh method that creates a shallow copy of the token with a past expiry before calling refresh, bypassing the oauth2 library's internal validity check.
  • Updated existing tests to use 1-hour token expiry (previously 1 minute) so they remain outside the new buffer window.

How is this tested?

Unit tests covering the three key scenarios:

  • Token expiring in 4 minutes (within buffer) triggers a refresh.
  • Token expiring in 6 minutes (outside buffer) is returned as-is.
  • Token with zero expiry does not trigger a refresh.

Proactively refresh OAuth tokens that will expire within 5 minutes,
preventing callers of `databricks auth token` from receiving
near-expired tokens that may expire before they can be used. The
oauth2 library's default expiry delta of 10 seconds is too short
for external consumers.
@mihaimitrea-db mihaimitrea-db self-assigned this Mar 11, 2026
@mihaimitrea-db
mihaimitrea-db requested review from renaudhartert-db and simonfaltum and removed request for simonfaltum March 11, 2026 16:00

@renaudhartert-db renaudhartert-db left a comment •

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 change looks great overall. It's a shame that we have to do it in the SDK rather than in the CLI though. We will ultimately have to find a way to build a better separation of concerns here.

I think the structure could be simplified though. Right now there are two separate "should we refresh" decisions (Valid() from oauth2 and tokenExpiringSoon) plus a forceRefresh method that exists solely to trick the oauth2 library. I think we can collapse this into a single decision point and a single refresh path.

(i) Replace tokenExpiringSoon + forceRefresh with a single isExpired function that encapsulates the full refresh decision, including the buffer:

func isExpired(t *oauth2.Token) bool {
	if !t.Valid() {
		return true
	}
	return !t.Expiry.IsZero() && time.Until(t.Expiry) < tokenRefreshBuffer
}

Then Token() becomes:

if isExpired(t) {
	t, err = a.refresh(t)
	if err != nil {
		return nil, fmt.Errorf("token refresh: %w", err)
	}
}

(ii) In refresh, always set the expiry to the past before passing to TokenSource. PersistentAuth owns the "when to refresh" decision (including the proactive buffer), so the oauth2 library should always perform the refresh when asked -- no need for two code paths depending on whether oauth2 would agree the token is expired:

func (a *PersistentAuth) refresh(oldToken *oauth2.Token) (*oauth2.Token, error) {
	cfg, err := a.oauth2Config()
	if err != nil {
		return nil, err
	}
	ctx := a.setOAuthContext(a.ctx)
	// Force the oauth2 library to refresh by ensuring the token appears
	// expired. PersistentAuth owns the refresh decision (including the
	// proactive buffer), so the oauth2 library should always perform the
	// refresh when asked.
	expired := *oldToken
	expired.Expiry = time.Now().Add(-time.Minute)
	t, err := cfg.TokenSource(ctx, &expired).Token()
	// ...
}

This removes forceRefresh entirely. One decision point, one refresh path.

Comment thread credentials/u2m/persistent_auth.go Resolved
Comment thread credentials/u2m/persistent_auth.go Resolved
Address review feedback: collapse tokenExpiringSoon + forceRefresh into
a single isExpired function and move the force-expire logic into refresh
so there is one decision point and one refresh path.

Signed-off-by: Mihai Mitrea <[email protected]>

@simonfaltum simonfaltum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review by OpenCode + Isaac (2-round swarm review). 1 Major, 2 Nits.

Comment thread credentials/u2m/persistent_auth.go Resolved
Comment thread credentials/u2m/persistent_auth.go Resolved
Comment thread credentials/u2m/persistent_auth_test.go Resolved
Keep the 5-minute proactive refresh buffer without regressing callers that still have a usable token when refresh fails. Rename the refresh helper for clarity, simplify the refresh branch, and harden the non-refresh tests to fail clearly on unexpected refresh attempts.

Signed-off-by: Mihai Mitrea <[email protected]>
Comment thread NEXT_CHANGELOG.md Resolved

@renaudhartert-db renaudhartert-db left a comment

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.

LGTM modulo open comment.

Address review comment: clarify that proactive refresh is best-effort
and that existing valid tokens are returned on refresh failure.

Co-authored-by: Isaac
Signed-off-by: Mihai Mitrea <[email protected]>
@github-actions

Copy link
Copy Markdown

If integration tests don't run automatically, an authorized user can run them manually by following the instructions below:

Trigger:
go/deco-tests-run/sdk-go

Inputs:

  • PR number: 1535
  • Commit SHA: 80994b0b5fb62c74990b03e00160419d7f900ffa

Checks will be approved automatically on success.

@mihaimitrea-db
mihaimitrea-db added this pull request to the merge queue Mar 17, 2026
Merged via the queue into main with commit 8c24b9f Mar 17, 2026
15 checks passed
@mihaimitrea-db
mihaimitrea-db deleted the mihaimitrea-db/increase-token-refresh-buffer branch March 17, 2026 09:14
github-merge-queue Bot pushed a commit that referenced this pull request Mar 19, 2026
## Summary

This PR adds an explicit `PersistentAuth.ForceRefreshToken()` path for
cached U2M OAuth tokens so the CLI can implement `databricks auth token
--force-refresh` on top of the 5-minute proactive refresh buffer
introduced in #1535.

For maintainability reasons it also splits token loading from refresh
decisions and documents a cross-process refresh race for a follow-up
fix.

## Why

#1535 changed the default U2M token path to proactively refresh tokens
that are within 5 minutes of expiry so callers of `databricks auth
token` are less likely to receive a token that expires before it can be
used. That addresses the "give me a useful token" case, which came out
of the `auth token` discussion in [cli issue
#4564](databricks/cli#4564 (comment)).

That proactive refresh is intentionally best-effort, though.
`PersistentAuth.Token()` still returns the existing access token when it
is valid and a proactive refresh fails, because callers did not
explicitly ask to block on a fresh token. That behavior is appropriate
for the default path, but it is not strong enough for integrations that
want to treat the CLI as a token minter or that want to manage their own
cache/TTL policy. In those cases, "return a still-usable token" is
different from "refresh now and give me a newly minted token or fail."

This PR adds that stricter SDK primitive. It gives the CLI a clean way
to implement `--force-refresh` without changing the semantics of the
existing `Token()` flow.

The raw oauth2 error text for forced refresh failures can be misleading.
In particular, when a forced refresh is requested for a cached access
token that is still valid but does not include a refresh token, the
default oauth2 error suggests that the token is expired even though the
real problem is that the cached token is not refresh-capable.

This PR surfaces explicit refresh-token errors instead of relying on the
default oauth2 wording, so callers can distinguish "the current access
token is expired" from "a new token could not be minted because the
refresh token is missing or was rejected by the IdP".

## What changed

### Interface changes

- Added `(*PersistentAuth).ForceRefreshToken() (*oauth2.Token, error)`.
This always attempts to refresh the cached U2M token, regardless of its
current expiry, and redacts the refresh token from the returned value
just like `Token()` does.

### Behavioral changes

- For `Token()`, the only behavioral change is that expired tokens with
no refresh token now return `ErrMissingRefreshToken` instead of a less
specific oauth2 library error. Callers can use `errors.Is(err,
ErrMissingRefreshToken)` to detect this case.
- `PersistentAuth.ForceRefreshToken()` always tries to refresh, even
when the cached access token is still valid.
- If forced refresh fails, `ForceRefreshToken()` returns an error
instead of falling back to the existing token.
- If the cached token does not contain a refresh token,
`ForceRefreshToken()` fails explicitly.

### Internal changes

- Extracted cache lookup into `loadToken()` so token loading and refresh
policy are separated.
- Reused the existing refresh path so forced refresh still goes through
the same endpoint resolution, token exchange, cache update, and
refresh-token redaction logic as the normal path.
- Added dedicated tests for force-refresh success, missing refresh
token, transient refresh failure, invalid refresh token handling, and
the profile-to-host fallback path.
- Documented the remaining cross-process refresh race in `refresh()`
with a follow-up TODO rather than expanding the scope of this PR to add
inter-process coordination.
- Added `ErrMissingRefreshToken` for the "cached token has no refresh
token" case.
- Added missing-refresh-token check into the shared `refresh()` path so
both `Token()` and `ForceRefreshToken()` wrap the same semantic error.

## How is this tested?

- `go test ./credentials/u2m`
- Added unit tests covering:
  - refreshing a still-valid cached token via `ForceRefreshToken()`
- failing `ForceRefreshToken()` when the cached token has no refresh
token
  - surfacing the exact wrapped error on refresh failure
  - preserving `InvalidRefreshTokenError` behavior
- exercising forced refresh through the existing profile-key to host-key
fallback path

---------

Signed-off-by: Mihai Mitrea <[email protected]>
github-merge-queue Bot pushed a commit to databricks/cli that referenced this pull request Mar 27, 2026
Wire a new `--force-refresh` CLI flag that delegates to the SDK's
`ForceRefreshToken()` method, bypassing the cached token validity check.
The default path through `Token()` is unchanged.

> **Note:** this will not compile until the SDK ships
`ForceRefreshToken()`
([databricks/databricks-sdk-go#1552](databricks/databricks-sdk-go#1552))
and the CLI''s Go SDK dependency is bumped to v0.126.

## Changes

- Add a `--force-refresh` boolean flag to `databricks auth token`. When
set, the command calls `PersistentAuth.ForceRefreshToken()` instead of
`PersistentAuth.Token()`, which always performs a token refresh against
the IdP regardless of the cached token's remaining TTL.
- The error handling is shared between both paths: invalid refresh
tokens produce the same actionable "To reauthenticate, run..." message,
and the backward-compat `cache.ErrNotFound` rewrite is preserved.
- Add three acceptance tests covering the force-refresh flag end-to-end:
- **`force-refresh-success`**: pre-populated cache with a valid token,
`--force-refresh` returns the server's new token (not the cached one).
- **`force-refresh-invalid-refresh-token`**: server returns 401 with
invalid refresh token; asserts the actionable re-login error message.
- **`force-refresh-no-cache`**: no cached token exists; asserts the
backward-compat error message is preserved for `--force-refresh`.
- Add unit tests verifying the default path still returns a cached valid
token without refreshing, and that `--force-refresh` correctly delegates
to `ForceRefreshToken()` for both success and failure cases.

## Why

[#4564](#4564) reports that
external consumers using `databricks auth token` as a credential helper
(e.g. Claude Code via `apiKeyHelper`) can receive near-expired tokens
that expire before they can be used.


[databricks/databricks-sdk-go#1535](databricks/databricks-sdk-go#1535)
addressed the common case by adding a 5-minute proactive refresh buffer
to `Token()`. However, that proactive refresh is intentionally
best-effort: `Token()` still returns the existing access token when it
is valid and a proactive refresh fails, because callers did not
explicitly ask for a fresh token.

For integrations that treat the CLI as a token minter or want to manage
their own cache/TTL policy, "return a still-usable token" is different
from "refresh now and give me a newly minted token or fail." The
`--force-refresh` flag gives those integrations an explicit way to
guarantee a fresh token.

## Tests

- Unit tests in `cmd/auth/token_test.go`: table-driven cases covering
default-path cache reuse, force-refresh success, and force-refresh error
preservation.
- Acceptance tests in
`acceptance/cmd/auth/token/force-refresh-{success,invalid-refresh-token,no-cache}/`:
end-to-end tests against the mock OIDC server, run for both `terraform`
and `direct` engine variants.

---------

Co-authored-by: simon <[email protected]>
denik pushed a commit to databricks/cli that referenced this pull request May 20, 2026
Wire a new `--force-refresh` CLI flag that delegates to the SDK's
`ForceRefreshToken()` method, bypassing the cached token validity check.
The default path through `Token()` is unchanged.

> **Note:** this will not compile until the SDK ships
`ForceRefreshToken()`
([databricks/databricks-sdk-go#1552](databricks/databricks-sdk-go#1552))
and the CLI''s Go SDK dependency is bumped to v0.126.

## Changes

- Add a `--force-refresh` boolean flag to `databricks auth token`. When
set, the command calls `PersistentAuth.ForceRefreshToken()` instead of
`PersistentAuth.Token()`, which always performs a token refresh against
the IdP regardless of the cached token's remaining TTL.
- The error handling is shared between both paths: invalid refresh
tokens produce the same actionable "To reauthenticate, run..." message,
and the backward-compat `cache.ErrNotFound` rewrite is preserved.
- Add three acceptance tests covering the force-refresh flag end-to-end:
- **`force-refresh-success`**: pre-populated cache with a valid token,
`--force-refresh` returns the server's new token (not the cached one).
- **`force-refresh-invalid-refresh-token`**: server returns 401 with
invalid refresh token; asserts the actionable re-login error message.
- **`force-refresh-no-cache`**: no cached token exists; asserts the
backward-compat error message is preserved for `--force-refresh`.
- Add unit tests verifying the default path still returns a cached valid
token without refreshing, and that `--force-refresh` correctly delegates
to `ForceRefreshToken()` for both success and failure cases.

## Why

[#4564](#4564) reports that
external consumers using `databricks auth token` as a credential helper
(e.g. Claude Code via `apiKeyHelper`) can receive near-expired tokens
that expire before they can be used.


[databricks/databricks-sdk-go#1535](databricks/databricks-sdk-go#1535)
addressed the common case by adding a 5-minute proactive refresh buffer
to `Token()`. However, that proactive refresh is intentionally
best-effort: `Token()` still returns the existing access token when it
is valid and a proactive refresh fails, because callers did not
explicitly ask for a fresh token.

For integrations that treat the CLI as a token minter or want to manage
their own cache/TTL policy, "return a still-usable token" is different
from "refresh now and give me a newly minted token or fail." The
`--force-refresh` flag gives those integrations an explicit way to
guarantee a fresh token.

## Tests

- Unit tests in `cmd/auth/token_test.go`: table-driven cases covering
default-path cache reuse, force-refresh success, and force-refresh error
preservation.
- Acceptance tests in
`acceptance/cmd/auth/token/force-refresh-{success,invalid-refresh-token,no-cache}/`:
end-to-end tests against the mock OIDC server, run for both `terraform`
and `direct` engine variants.

---------

Co-authored-by: simon <[email protected]>

This branch was previously deployed

1 inactive deployment
test-trigger-is — 80994b0b Deployed Mar 17, 2026 by mihaimitrea-db via Trigger Tests #1488
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants