Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).

appview/oauth: invalidate sessions if inactive for too long

if sessions are inactive for too long, tokens will not be refreshed, and
calling authorized xrpc methods will error out with invalid_grant. this
changeset does two things:

- tracks the last time a session was active using a new redis pair:
`oauth:session_meta:<did>:<session>`, this is updated every time
`SaveSession` is called
- checks for session inactivity every time `GetSession` is called, and
deletes the session if so

this way, `GetSession` will never return a session with expired tokens.

Signed-off-by: oppiliappan <me@oppi.li>

authored by oppi.li and committed by

Tangled ee08cd02 8f17b7bd

+116 -12
+6 -1
appview/oauth/oauth.go
··· 60 60 61 61 jwksUri := clientUri + "/oauth/jwks.json" 62 62 63 - authStore, err := NewRedisStore(config.Redis.ToURL()) 63 + authStore, err := NewRedisStore(&RedisStoreConfig{ 64 + RedisURL: config.Redis.ToURL(), 65 + SessionExpiryDuration: time.Hour * 24 * 90, 66 + SessionInactivityDuration: time.Hour * 24 * 14, 67 + AuthRequestExpiryDuration: time.Minute * 30, 68 + }) 64 69 if err != nil { 65 70 return nil, err 66 71 }
+110 -11
appview/oauth/store.go
··· 11 11 "github.com/redis/go-redis/v9" 12 12 ) 13 13 14 + type RedisStoreConfig struct { 15 + RedisURL string 16 + 17 + // The purpose of these limits is to avoid dead sessions hanging around in the db indefinitely. 18 + // The durations here should be *at least as long as* the expected duration of the oauth session itself. 19 + SessionExpiryDuration time.Duration // duration since session creation (max TTL) 20 + SessionInactivityDuration time.Duration // duration since last session update 21 + AuthRequestExpiryDuration time.Duration // duration since auth request creation 22 + } 23 + 14 24 // redis-backed implementation of ClientAuthStore. 15 25 type RedisStore struct { 16 - client *redis.Client 17 - SessionTTL time.Duration 18 - AuthRequestTTL time.Duration 26 + client *redis.Client 27 + cfg *RedisStoreConfig 19 28 } 20 29 21 30 var _ oauth.ClientAuthStore = &RedisStore{} 22 31 23 - func NewRedisStore(redisURL string) (*RedisStore, error) { 24 - opts, err := redis.ParseURL(redisURL) 32 + type sessionMetadata struct { 33 + CreatedAt time.Time `json:"created_at"` 34 + UpdatedAt time.Time `json:"updated_at"` 35 + } 36 + 37 + func NewRedisStore(cfg *RedisStoreConfig) (*RedisStore, error) { 38 + if cfg == nil { 39 + return nil, fmt.Errorf("missing cfg") 40 + } 41 + if cfg.RedisURL == "" { 42 + return nil, fmt.Errorf("missing RedisURL") 43 + } 44 + if cfg.SessionExpiryDuration == 0 { 45 + return nil, fmt.Errorf("missing SessionExpiryDuration") 46 + } 47 + if cfg.SessionInactivityDuration == 0 { 48 + return nil, fmt.Errorf("missing SessionInactivityDuration") 49 + } 50 + if cfg.AuthRequestExpiryDuration == 0 { 51 + return nil, fmt.Errorf("missing AuthRequestExpiryDuration") 52 + } 53 + 54 + opts, err := redis.ParseURL(cfg.RedisURL) 25 55 if err != nil { 26 56 return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 57 } ··· 67 37 } 68 38 69 39 return &RedisStore{ 70 - client: client, 71 - SessionTTL: 30 * 24 * time.Hour, // 30 days 72 - AuthRequestTTL: 10 * time.Minute, // 10 minutes 40 + client: client, 41 + cfg: cfg, 73 42 }, nil 74 43 } 75 44 ··· 80 51 return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 81 52 } 82 53 54 + func sessionMetadataKey(did syntax.DID, sessionID string) string { 55 + return fmt.Sprintf("oauth:session_meta:%s:%s", did, sessionID) 56 + } 57 + 83 58 func authRequestKey(state string) string { 84 59 return fmt.Sprintf("oauth:auth_request:%s", state) 85 60 } 86 61 87 62 func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 88 63 key := sessionKey(did, sessionID) 64 + metaKey := sessionMetadataKey(did, sessionID) 65 + 66 + // Check metadata for inactivity expiry 67 + metaData, err := r.client.Get(ctx, metaKey).Bytes() 68 + if err == redis.Nil { 69 + return nil, fmt.Errorf("session not found: %s", did) 70 + } 71 + if err != nil { 72 + return nil, fmt.Errorf("failed to get session metadata: %w", err) 73 + } 74 + 75 + var meta sessionMetadata 76 + if err := json.Unmarshal(metaData, &meta); err != nil { 77 + return nil, fmt.Errorf("failed to unmarshal session metadata: %w", err) 78 + } 79 + 80 + // Check if session has been inactive for too long 81 + inactiveThreshold := time.Now().Add(-r.cfg.SessionInactivityDuration) 82 + if meta.UpdatedAt.Before(inactiveThreshold) { 83 + // Session is inactive, delete it 84 + r.client.Del(ctx, key, metaKey) 85 + return nil, fmt.Errorf("session expired due to inactivity: %s", did) 86 + } 87 + 88 + // Get the actual session data 89 89 data, err := r.client.Get(ctx, key).Bytes() 90 90 if err == redis.Nil { 91 91 return nil, fmt.Errorf("session not found: %s", did) ··· 133 75 134 76 func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 135 77 key := sessionKey(sess.AccountDID, sess.SessionID) 78 + metaKey := sessionMetadataKey(sess.AccountDID, sess.SessionID) 136 79 137 80 data, err := json.Marshal(sess) 138 81 if err != nil { 139 82 return fmt.Errorf("failed to marshal session: %w", err) 140 83 } 141 84 142 - if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 85 + // Check if session already exists to preserve CreatedAt 86 + var meta sessionMetadata 87 + existingMetaData, err := r.client.Get(ctx, metaKey).Bytes() 88 + if err == redis.Nil { 89 + // New session 90 + meta = sessionMetadata{ 91 + CreatedAt: time.Now(), 92 + UpdatedAt: time.Now(), 93 + } 94 + } else if err != nil { 95 + return fmt.Errorf("failed to check existing session metadata: %w", err) 96 + } else { 97 + // Existing session - preserve CreatedAt, update UpdatedAt 98 + if err := json.Unmarshal(existingMetaData, &meta); err != nil { 99 + return fmt.Errorf("failed to unmarshal existing session metadata: %w", err) 100 + } 101 + meta.UpdatedAt = time.Now() 102 + } 103 + 104 + // Calculate remaining TTL based on creation time 105 + remainingTTL := r.cfg.SessionExpiryDuration - time.Since(meta.CreatedAt) 106 + if remainingTTL <= 0 { 107 + return fmt.Errorf("session has expired") 108 + } 109 + 110 + // Use the shorter of: remaining TTL or inactivity duration 111 + ttl := min(r.cfg.SessionInactivityDuration, remainingTTL) 112 + 113 + // Save session data 114 + if err := r.client.Set(ctx, key, data, ttl).Err(); err != nil { 143 115 return fmt.Errorf("failed to save session: %w", err) 116 + } 117 + 118 + // Save metadata 119 + metaData, err := json.Marshal(meta) 120 + if err != nil { 121 + return fmt.Errorf("failed to marshal session metadata: %w", err) 122 + } 123 + if err := r.client.Set(ctx, metaKey, metaData, ttl).Err(); err != nil { 124 + return fmt.Errorf("failed to save session metadata: %w", err) 144 125 } 145 126 146 127 return nil ··· 187 90 188 91 func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 189 92 key := sessionKey(did, sessionID) 190 - if err := r.client.Del(ctx, key).Err(); err != nil { 93 + metaKey := sessionMetadataKey(did, sessionID) 94 + 95 + if err := r.client.Del(ctx, key, metaKey).Err(); err != nil { 191 96 return fmt.Errorf("failed to delete session: %w", err) 192 97 } 193 98 return nil ··· 230 131 return fmt.Errorf("failed to marshal auth request: %w", err) 231 132 } 232 133 233 - if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 134 + if err := r.client.Set(ctx, key, data, r.cfg.AuthRequestExpiryDuration).Err(); err != nil { 234 135 return fmt.Errorf("failed to save auth request: %w", err) 235 136 } 236 137