@@ -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+
2029type 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
183291func (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
351459func (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 {
0 commit comments