Skip to content

Commit f4d1c7b

Browse files
committed
feat(auth): refresh access token proactively before it expires
The previous behavior waited until an API call returned 401 to trigger the refresh path. That keeps one unnecessary round-trip on the critical path of every call that races the token's exp boundary, and it surfaces transient auth-provider network failures as hard failures rather than retries. This change lets the CLI refresh ahead of expiry: - `AuthTokens` gains optional `AccessTokenExp` and `IssuedAt` fields (omitempty). They are populated from the access JWT's `exp` / `iat` claims at save time, and fall back to live JWT parsing for credential files written by older CLI versions. No migration needed. - `GetFreshAccessTokenOrNil` refreshes when the access token is invalid OR when it is valid but within `refreshBeforeExpiry` (5 min) of exp. The proactive branch is tolerant of refresh failure: if the IdP is briefly unreachable, the CLI keeps using the still-valid access token and retries on the next call rather than logging the user out. - `getNewTokensWithRefreshOrNil` classifies refresh errors. Network- level failures (timeouts, connection refused, DNS) are wrapped with a clearer message; authoritative IdP rejection produces a one-line stderr prompt ("re-run `brev login`") rather than a stack-trace dump. Builds on the reactive 401/403 retry from the prior auth fix.
1 parent 9f010a5 commit f4d1c7b

2 files changed

Lines changed: 138 additions & 14 deletions

File tree

pkg/auth/auth.go

Lines changed: 130 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ import (
44
"bufio"
55
"errors"
66
"fmt"
7+
"net"
8+
"net/url"
79
"os"
810
"os/exec"
911
"runtime"
1012
"strings"
13+
"time"
1114

1215
"github.com/brevdev/brev-cli/pkg/config"
1316
"github.com/brevdev/brev-cli/pkg/entity"
@@ -17,6 +20,12 @@ import (
1720
"github.com/pkg/browser"
1821
)
1922

23+
// refreshBeforeExpiry is how far in advance of access-token expiration the
24+
// CLI refreshes. Using a window larger than typical request RTTs avoids 401
25+
// round-trips at the tail of a token's life, at the cost of refreshing a
26+
// small number of still-valid tokens.
27+
const refreshBeforeExpiry = 5 * time.Minute
28+
2029
type LoginAuth struct {
2130
Auth
2231
}
@@ -161,24 +170,123 @@ func (t Auth) GetFreshAccessTokenOrNil() (string, error) {
161170
if err != nil {
162171
return "", breverrors.WrapAndTrace(err)
163172
}
164-
if !isAccessTokenValid {
173+
174+
// Trigger a refresh when the token is invalid OR when it is still valid
175+
// but close enough to expiry that the next API call is likely to race
176+
// the exp boundary. The proactive branch is tolerant of refresh failure:
177+
// if the IdP is briefly unreachable, fall back to the (still-valid)
178+
// current access token rather than logging the user out.
179+
expiringSoon := isAccessTokenValid && tokens.RefreshToken != "" && accessTokenExpiresSoon(tokens)
180+
if !isAccessTokenValid || expiringSoon {
165181
if tokens.RefreshToken == "" {
166182
// Access token is expired and we have no refresh token. Returning
167183
// the expired token here would just cause a 401 on the next API
168184
// call; return empty so callers can prompt for re-login instead.
169185
return "", nil
170186
}
171-
tokens, err = t.getNewTokensWithRefreshOrNil(tokens.RefreshToken)
172-
if err != nil {
173-
return "", breverrors.WrapAndTrace(err)
187+
newTokens, refreshErr := t.getNewTokensWithRefreshOrNil(tokens.RefreshToken)
188+
if refreshErr != nil {
189+
if expiringSoon {
190+
// Current token still validates; swallow the transient
191+
// failure and try again on the next call.
192+
return tokens.AccessToken, nil
193+
}
194+
return "", breverrors.WrapAndTrace(refreshErr)
174195
}
175-
if tokens == nil {
196+
if newTokens == nil {
176197
return "", nil
177198
}
199+
tokens = newTokens
178200
}
179201
return tokens.AccessToken, nil
180202
}
181203

204+
// accessTokenExpiresSoon reports whether the stored access token's
205+
// expiration is within refreshBeforeExpiry of now. It prefers the persisted
206+
// AccessTokenExp field (written by populateTokenTimestamps on save) and
207+
// falls back to decoding the access JWT for files written by older CLI
208+
// versions that never persisted the claim.
209+
func accessTokenExpiresSoon(tokens *entity.AuthTokens) bool {
210+
var exp time.Time
211+
if tokens.AccessTokenExp != nil {
212+
exp = *tokens.AccessTokenExp
213+
} else {
214+
exp, _ = accessTokenClaims(tokens.AccessToken)
215+
}
216+
if exp.IsZero() {
217+
return false
218+
}
219+
return time.Until(exp) < refreshBeforeExpiry
220+
}
221+
222+
// accessTokenClaims parses the access JWT without signature verification
223+
// and returns its exp and iat claims. Missing or malformed claims are
224+
// returned as the zero time.Time; the caller is responsible for guarding
225+
// with IsZero().
226+
func accessTokenClaims(token string) (exp, iat time.Time) {
227+
if token == "" {
228+
return time.Time{}, time.Time{}
229+
}
230+
parser := jwt.Parser{}
231+
ptoken, _, err := parser.ParseUnverified(token, jwt.MapClaims{})
232+
if err != nil {
233+
return time.Time{}, time.Time{}
234+
}
235+
claims, ok := ptoken.Claims.(jwt.MapClaims)
236+
if !ok {
237+
return time.Time{}, time.Time{}
238+
}
239+
if v, ok := claims["exp"].(float64); ok {
240+
exp = time.Unix(int64(v), 0)
241+
}
242+
if v, ok := claims["iat"].(float64); ok {
243+
iat = time.Unix(int64(v), 0)
244+
}
245+
return exp, iat
246+
}
247+
248+
// populateTokenTimestamps fills in AccessTokenExp and IssuedAt from the
249+
// access JWT when they are not already set. Safe to call on any AuthTokens
250+
// value; missing or non-JWT access tokens leave the fields nil.
251+
func populateTokenTimestamps(tokens *entity.AuthTokens) {
252+
if tokens == nil || tokens.AccessToken == "" {
253+
return
254+
}
255+
exp, iat := accessTokenClaims(tokens.AccessToken)
256+
if tokens.AccessTokenExp == nil && !exp.IsZero() {
257+
tokens.AccessTokenExp = &exp
258+
}
259+
if tokens.IssuedAt == nil && !iat.IsZero() {
260+
tokens.IssuedAt = &iat
261+
}
262+
}
263+
264+
// isTransientRefreshError reports whether an error from the OAuth refresh
265+
// call is a transient network condition (timeout, connection refused,
266+
// DNS failure, etc.) as opposed to an authoritative rejection of the
267+
// refresh token by the IdP. Transient errors should not force the user to
268+
// re-login.
269+
func isTransientRefreshError(err error) bool {
270+
if err == nil {
271+
return false
272+
}
273+
var urlErr *url.Error
274+
if errors.As(err, &urlErr) {
275+
if urlErr.Timeout() {
276+
return true
277+
}
278+
}
279+
var netErr net.Error
280+
if errors.As(err, &netErr) && netErr.Timeout() {
281+
return true
282+
}
283+
// DNS / connection-refused / TLS handshake errors surface as url.Error
284+
// wrapping an *net.OpError. Treat connection-level failures as
285+
// transient: the refresh token is probably fine, the network isn't.
286+
var opErr *net.OpError
287+
return errors.As(err, &opErr)
288+
}
289+
182290
// Prompts for login and returns tokens, and saves to store
183291
func (t Auth) PromptForLogin() (*LoginTokens, error) {
184292
shouldLogin, err := t.shouldLogin()
@@ -228,22 +336,22 @@ func (t Auth) LoginWithToken(token string) error {
228336
// path correctly recognizes there is nothing to refresh and prompts
229337
// for a fresh login exactly once.
230338
fmt.Fprintln(os.Stderr, "Note: tokens from --token cannot be refreshed; re-run `brev login` when the session expires.")
231-
err := t.authStore.SaveAuthTokens(entity.AuthTokens{
339+
tokens := entity.AuthTokens{
232340
AccessToken: token,
233341
RefreshToken: "",
234-
})
235-
if err != nil {
342+
}
343+
populateTokenTimestamps(&tokens)
344+
if err := t.authStore.SaveAuthTokens(tokens); err != nil {
236345
return breverrors.WrapAndTrace(err)
237346
}
238347
} else {
239348
// The token is not a JWT, assume it is a refresh token. The access
240349
// token slot is filled with the sentinel so the first API call
241350
// triggers a refresh to populate a real access token.
242-
err := t.authStore.SaveAuthTokens(entity.AuthTokens{
351+
if err := t.authStore.SaveAuthTokens(entity.AuthTokens{
243352
AccessToken: autoLoginSentinel,
244353
RefreshToken: token,
245-
})
246-
if err != nil {
354+
}); err != nil {
247355
return breverrors.WrapAndTrace(err)
248356
}
249357
}
@@ -350,20 +458,28 @@ func (t Auth) getSavedTokensOrNil() (*entity.AuthTokens, error) {
350458
// gets new access and refresh token or returns nil if refresh token expired, and updates store
351459
func (t Auth) getNewTokensWithRefreshOrNil(refreshToken string) (*entity.AuthTokens, error) {
352460
tokens, err := t.oauth.GetNewAuthTokensWithRefresh(refreshToken)
353-
// TODO 2 handle if 403 invalid grant
354-
// https://stackoverflow.com/questions/57383523/how-to-detect-when-an-oauth2-refresh-token-expired
355461
if err != nil {
356462
if strings.Contains(err.Error(), "not implemented") {
357463
return nil, nil
358464
}
359-
return nil, breverrors.WrapAndTrace(err)
465+
if isTransientRefreshError(err) {
466+
// Network hiccup; do not clear the user's session. Surface the
467+
// error so the caller can decide whether to swallow it (when
468+
// the current access token is still valid) or propagate it.
469+
return nil, breverrors.WrapAndTrace(fmt.Errorf("could not reach auth provider to refresh session: %w", err))
470+
}
471+
// Definitive rejection from the IdP. Tell the user in plain
472+
// language rather than burying it in a stack trace.
473+
fmt.Fprintln(os.Stderr, "Your brev session could not be refreshed; re-run `brev login`.")
474+
return nil, nil
360475
}
361476
if tokens == nil {
362477
return nil, nil
363478
}
364479
if tokens.RefreshToken == "" {
365480
tokens.RefreshToken = refreshToken
366481
}
482+
populateTokenTimestamps(tokens)
367483

368484
err = t.authStore.SaveAuthTokens(*tokens)
369485
if err != nil {

pkg/entity/entity.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ var LegacyWorkspaceGroups = map[string]bool{
2727
type AuthTokens struct {
2828
AccessToken string `json:"access_token"`
2929
RefreshToken string `json:"refresh_token"`
30+
// AccessTokenExp and IssuedAt are populated from the access JWT's `exp`
31+
// and `iat` claims when available. They let the CLI refresh proactively
32+
// before the access token expires, and let UX surfaces like `brev
33+
// status` display session lifetime without re-parsing the JWT. Both are
34+
// optional: files written by older CLI versions lack these fields, and
35+
// tokens whose JWTs do not carry the claims will leave them nil.
36+
AccessTokenExp *time.Time `json:"access_token_exp,omitempty"`
37+
IssuedAt *time.Time `json:"issued_at,omitempty"`
3038
}
3139

3240
type IDEConfig struct {

0 commit comments

Comments
 (0)