mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 16:01:16 +00:00
8267458f4e
After /auth/refresh, Postgres got the new access + refresh nonces but the Redis cached session still held the OLD ones. The next request: 1. Frontend uses the new access token (new access_nonce in JWT) 2. Backend ValidateAccessToken → GetSession → hits Redis cache 3. Cached session has the OLD access_nonce 4. session.AccessNonce != t.Nonce → ErrToken (401) 5. Frontend tries to refresh with the new refresh token 6. RefreshToken → GetSession → again hits stale Redis 7. sess.RefreshNonce (old) != t.Nonce (new) → ErrToken 8. Frontend clears tokens and bounces to /auth/login The access token's 10-minute TTL was the trigger window because that's when the first refresh fires. After the first refresh, the stale cache poisoned every subsequent request. Fix: delete the cached session after a successful repository update, mirroring what SwitchOrganization already does for the same reason (it updates current_organization_id in Postgres and then drops the Redis copy). Next GetSession misses, re-reads from Postgres, caches the fresh nonces. The deleteSession failure path is intentionally swallowed — the refresh already succeeded and we returned the new tokens, so worst case is the next request triggers another refresh, not a logout.
85 lines
2.5 KiB
Go
85 lines
2.5 KiB
Go
package token
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/getsentry/sentry-go"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/pkg/crypt"
|
|
)
|
|
|
|
func (s *tokenService) RefreshToken(ctx context.Context, refreshToken string) (*models.Token, *errx.Error) {
|
|
t, err := s.VerifyToken(refreshToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if t.ExpiresAt.Before(time.Now()) {
|
|
return nil, errx.ErrToken
|
|
}
|
|
|
|
sess, err := s.GetSession(ctx, t.SessionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if sess.RefreshNonce != t.Nonce {
|
|
return nil, errx.ErrToken
|
|
}
|
|
|
|
issuedAt := time.Now()
|
|
|
|
accessTokenExpiresAt := issuedAt.Add(AccessTokenLifeTime)
|
|
accessNonce, xerr := crypt.Nonce()
|
|
if xerr != nil {
|
|
sentry.CaptureException(xerr)
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
newAccessToken, xerr := s.GenerateToken(sess.UserID, sess.ID, "", accessNonce, issuedAt, accessTokenExpiresAt)
|
|
if xerr != nil {
|
|
sentry.CaptureException(xerr)
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
refreshTokenExpiresAt := issuedAt.Add(2 * 30 * 24 * time.Hour)
|
|
refreshNonce, xerr := crypt.Nonce()
|
|
if xerr != nil {
|
|
sentry.CaptureException(xerr)
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
newRefreshToken, xerr := s.GenerateToken(sess.UserID, sess.ID, "", refreshNonce, issuedAt, refreshTokenExpiresAt)
|
|
if xerr != nil {
|
|
sentry.CaptureException(xerr)
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
if err := s.tokenRepository.RefreshToken(ctx, sess.ID, t.Nonce, accessNonce, refreshNonce, issuedAt); err != nil {
|
|
sentry.CaptureException(err)
|
|
return nil, errx.InternalError()
|
|
}
|
|
|
|
// Invalidate the cached session. Postgres now has the new nonces, but
|
|
// Redis still has the old ones from when the session was last read. The
|
|
// very next API call would arrive with the NEW access nonce, GetSession
|
|
// would return the stale cached row with the OLD access nonce, the
|
|
// mismatch check would 401, and the frontend would log the user out.
|
|
// Dropping the cache forces the next GetSession to re-read from the
|
|
// updated Postgres row.
|
|
if err := s.deleteSession(ctx, sess.ID); err != nil {
|
|
sentry.CaptureException(err)
|
|
// Don't fail the refresh — the worst case if the delete somehow
|
|
// failed is the user retries; we already returned the new tokens.
|
|
}
|
|
|
|
return &models.Token{
|
|
AccessToken: newAccessToken,
|
|
AccessTokenExpiresAt: accessTokenExpiresAt,
|
|
RefreshToken: newRefreshToken,
|
|
RefreshTokenExpiresAt: refreshTokenExpiresAt,
|
|
}, nil
|
|
}
|