283e8f6b46
Rotated refresh tokens now stay valid for a 60s grace window so concurrent refreshes (e.g. multiple browser tabs) each obtain a valid new pair instead of being logged out. Admin-web additionally serializes refreshes across tabs via the Web Locks API and reuses tokens already rotated by another tab. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
343 lines
7.9 KiB
Go
343 lines
7.9 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
var (
|
|
ErrRefreshSessionNotFound = errors.New("refresh session not found")
|
|
ErrRefreshTokenMismatch = errors.New("refresh token mismatch")
|
|
)
|
|
|
|
var rotateRefreshScript = redis.NewScript(`
|
|
local current = redis.call("GET", KEYS[1])
|
|
if not current then
|
|
return 0
|
|
end
|
|
if current == ARGV[1] then
|
|
redis.call("PSETEX", KEYS[1], ARGV[4], "grace:" .. ARGV[1])
|
|
redis.call("PSETEX", KEYS[2], ARGV[3], ARGV[2])
|
|
return 1
|
|
end
|
|
if current == "grace:" .. ARGV[1] then
|
|
redis.call("PSETEX", KEYS[2], ARGV[3], ARGV[2])
|
|
return 1
|
|
end
|
|
return -1
|
|
`)
|
|
|
|
var setMaxSessionVersionScript = redis.NewScript(`
|
|
local current = redis.call("GET", KEYS[1])
|
|
local current_number = tonumber(current or "0") or 0
|
|
local next_number = tonumber(ARGV[1]) or 0
|
|
if current_number >= next_number then
|
|
return current_number
|
|
end
|
|
redis.call("SET", KEYS[1], ARGV[1])
|
|
return next_number
|
|
`)
|
|
|
|
const sessionVersionFallbackTTL = 30 * 24 * time.Hour
|
|
|
|
// refreshRotationGraceTTL keeps a rotated refresh token usable for a short
|
|
// window so concurrent refreshes (e.g. multiple browser tabs racing with the
|
|
// same token) each obtain a valid new pair instead of being logged out.
|
|
const refreshRotationGraceTTL = 60 * time.Second
|
|
|
|
const rotationGracePrefix = "grace:"
|
|
|
|
type SessionStore struct {
|
|
rdb *redis.Client
|
|
fallback *memorySessionStore
|
|
}
|
|
|
|
func NewSessionStore(rdb *redis.Client) *SessionStore {
|
|
return &SessionStore{
|
|
rdb: rdb,
|
|
fallback: newMemorySessionStore(),
|
|
}
|
|
}
|
|
|
|
func (s *SessionStore) SaveRefresh(ctx context.Context, jti string, tokenHash string, ttl time.Duration) error {
|
|
key := fmt.Sprintf("refresh:%s", jti)
|
|
s.fallback.set(key, tokenHash, ttl)
|
|
if s.rdb == nil {
|
|
return nil
|
|
}
|
|
if err := s.rdb.Set(ctx, key, tokenHash, ttl).Err(); err != nil {
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SessionStore) GetRefresh(ctx context.Context, jti string) (string, error) {
|
|
key := fmt.Sprintf("refresh:%s", jti)
|
|
if s.rdb != nil {
|
|
value, err := s.rdb.Get(ctx, key).Result()
|
|
if err == nil {
|
|
if strings.HasPrefix(value, rotationGracePrefix) {
|
|
return "", ErrRefreshSessionNotFound
|
|
}
|
|
return value, nil
|
|
}
|
|
}
|
|
value, err := s.fallback.get(key)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if strings.HasPrefix(value, rotationGracePrefix) {
|
|
return "", ErrRefreshSessionNotFound
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func (s *SessionStore) DeleteRefresh(ctx context.Context, jti string) error {
|
|
key := fmt.Sprintf("refresh:%s", jti)
|
|
s.fallback.delete(key)
|
|
if s.rdb == nil {
|
|
return nil
|
|
}
|
|
if err := s.rdb.Del(ctx, key).Err(); err != nil {
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SessionStore) RotateRefresh(ctx context.Context, oldJTI, oldHash, newJTI, newHash string, ttl time.Duration) error {
|
|
oldKey := fmt.Sprintf("refresh:%s", oldJTI)
|
|
newKey := fmt.Sprintf("refresh:%s", newJTI)
|
|
|
|
if s.rdb == nil {
|
|
return s.fallback.rotate(oldKey, oldHash, newKey, newHash, ttl)
|
|
}
|
|
|
|
result, err := rotateRefreshScript.Run(
|
|
ctx,
|
|
s.rdb,
|
|
[]string{oldKey, newKey},
|
|
oldHash,
|
|
newHash,
|
|
ttl.Milliseconds(),
|
|
refreshRotationGraceTTL.Milliseconds(),
|
|
).Int()
|
|
if err != nil {
|
|
return s.fallback.rotate(oldKey, oldHash, newKey, newHash, ttl)
|
|
}
|
|
|
|
switch result {
|
|
case 1:
|
|
_ = s.fallback.rotate(oldKey, oldHash, newKey, newHash, ttl)
|
|
return nil
|
|
case 0:
|
|
if err := s.fallback.rotate(oldKey, oldHash, newKey, newHash, ttl); err == nil {
|
|
return nil
|
|
}
|
|
return ErrRefreshSessionNotFound
|
|
case -1:
|
|
return ErrRefreshTokenMismatch
|
|
default:
|
|
return fmt.Errorf("unexpected rotate refresh result: %d", result)
|
|
}
|
|
}
|
|
|
|
func (s *SessionStore) Blacklist(ctx context.Context, accessJTI string, ttl time.Duration) error {
|
|
key := fmt.Sprintf("blacklist:%s", accessJTI)
|
|
s.fallback.set(key, "1", ttl)
|
|
if s.rdb == nil {
|
|
return nil
|
|
}
|
|
if err := s.rdb.Set(ctx, key, "1", ttl).Err(); err != nil {
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *SessionStore) IsBlacklisted(ctx context.Context, accessJTI string) (bool, error) {
|
|
key := fmt.Sprintf("blacklist:%s", accessJTI)
|
|
if _, err := s.fallback.get(key); err == nil {
|
|
return true, nil
|
|
}
|
|
if s.rdb == nil {
|
|
return false, nil
|
|
}
|
|
n, err := s.rdb.Exists(ctx, key).Result()
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
return n > 0, nil
|
|
}
|
|
|
|
func (s *SessionStore) SessionVersion(ctx context.Context, userID int64) (int64, error) {
|
|
if s == nil {
|
|
return 0, nil
|
|
}
|
|
key := sessionVersionKey(userID)
|
|
fallbackVersion := s.fallback.sessionVersion(key)
|
|
redisVersion := int64(0)
|
|
if s.rdb != nil {
|
|
value, err := s.rdb.Get(ctx, key).Result()
|
|
if err == nil {
|
|
version, parseErr := strconv.ParseInt(value, 10, 64)
|
|
if parseErr == nil && version > 0 {
|
|
redisVersion = version
|
|
}
|
|
}
|
|
}
|
|
|
|
version := maxInt64(redisVersion, fallbackVersion)
|
|
if version <= 0 {
|
|
return 0, nil
|
|
}
|
|
s.fallback.set(key, strconv.FormatInt(version, 10), sessionVersionFallbackTTL)
|
|
if s.rdb != nil && version > redisVersion {
|
|
_, _ = setMaxSessionVersionScript.Run(ctx, s.rdb, []string{key}, strconv.FormatInt(version, 10)).Int64()
|
|
}
|
|
return version, nil
|
|
}
|
|
|
|
func (s *SessionStore) BumpSessionVersion(ctx context.Context, userID int64) (int64, error) {
|
|
if s == nil {
|
|
return 0, nil
|
|
}
|
|
key := sessionVersionKey(userID)
|
|
current, _ := s.SessionVersion(ctx, userID)
|
|
next := current + 1
|
|
s.fallback.set(key, strconv.FormatInt(next, 10), sessionVersionFallbackTTL)
|
|
|
|
if s.rdb == nil {
|
|
return next, nil
|
|
}
|
|
version, err := s.rdb.Incr(ctx, key).Result()
|
|
if err != nil {
|
|
return next, nil
|
|
}
|
|
if version < next {
|
|
if maxVersion, maxErr := setMaxSessionVersionScript.Run(ctx, s.rdb, []string{key}, strconv.FormatInt(next, 10)).Int64(); maxErr == nil {
|
|
version = maxVersion
|
|
}
|
|
}
|
|
s.fallback.set(key, strconv.FormatInt(version, 10), sessionVersionFallbackTTL)
|
|
return version, nil
|
|
}
|
|
|
|
func sessionVersionKey(userID int64) string {
|
|
return fmt.Sprintf("session_version:user:%d", userID)
|
|
}
|
|
|
|
func maxInt64(a, b int64) int64 {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
type memorySessionStore struct {
|
|
mu sync.Mutex
|
|
items map[string]memorySessionItem
|
|
}
|
|
|
|
type memorySessionItem struct {
|
|
value string
|
|
expiresAt time.Time
|
|
}
|
|
|
|
func newMemorySessionStore() *memorySessionStore {
|
|
return &memorySessionStore{items: make(map[string]memorySessionItem)}
|
|
}
|
|
|
|
func (s *memorySessionStore) set(key, value string, ttl time.Duration) {
|
|
if s == nil || ttl <= 0 {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.items[key] = memorySessionItem{
|
|
value: value,
|
|
expiresAt: time.Now().Add(ttl),
|
|
}
|
|
}
|
|
|
|
func (s *memorySessionStore) get(key string) (string, error) {
|
|
if s == nil {
|
|
return "", redis.Nil
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
item, ok := s.items[key]
|
|
if !ok {
|
|
return "", redis.Nil
|
|
}
|
|
if !item.expiresAt.After(time.Now()) {
|
|
delete(s.items, key)
|
|
return "", redis.Nil
|
|
}
|
|
return item.value, nil
|
|
}
|
|
|
|
func (s *memorySessionStore) delete(key string) {
|
|
if s == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.items, key)
|
|
}
|
|
|
|
func (s *memorySessionStore) sessionVersion(key string) int64 {
|
|
if s == nil {
|
|
return 0
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if item, ok := s.items[key]; ok && item.expiresAt.After(time.Now()) {
|
|
if parsed, err := strconv.ParseInt(item.value, 10, 64); err == nil && parsed > 0 {
|
|
return parsed
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (s *memorySessionStore) rotate(oldKey, oldHash, newKey, newHash string, ttl time.Duration) error {
|
|
if s == nil {
|
|
return ErrRefreshSessionNotFound
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
item, ok := s.items[oldKey]
|
|
if !ok {
|
|
return ErrRefreshSessionNotFound
|
|
}
|
|
if !item.expiresAt.After(time.Now()) {
|
|
delete(s.items, oldKey)
|
|
return ErrRefreshSessionNotFound
|
|
}
|
|
switch item.value {
|
|
case oldHash:
|
|
s.items[oldKey] = memorySessionItem{
|
|
value: rotationGracePrefix + oldHash,
|
|
expiresAt: time.Now().Add(refreshRotationGraceTTL),
|
|
}
|
|
case rotationGracePrefix + oldHash:
|
|
// Already rotated within the grace window; keep the marker as-is.
|
|
default:
|
|
return ErrRefreshTokenMismatch
|
|
}
|
|
if ttl > 0 {
|
|
s.items[newKey] = memorySessionItem{
|
|
value: newHash,
|
|
expiresAt: time.Now().Add(ttl),
|
|
}
|
|
}
|
|
return nil
|
|
}
|