appview/oauth: invalidate sessions if inactive for too long #722

merged
opened by oppi.li targeting master from push-rnnvqlrqspsv

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

Changed files
+116 -12
appview
+6 -1
appview/oauth/oauth.go
··· 60 61 jwksUri := clientUri + "/oauth/jwks.json" 62 63 - authStore, err := NewRedisStore(config.Redis.ToURL()) 64 if err != nil { 65 return nil, err 66 }
··· 60 61 jwksUri := clientUri + "/oauth/jwks.json" 62 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 + }) 69 if err != nil { 70 return nil, err 71 }
+110 -11
appview/oauth/store.go
··· 11 "github.com/redis/go-redis/v9" 12 ) 13 14 // redis-backed implementation of ClientAuthStore. 15 type RedisStore struct { 16 - client *redis.Client 17 - SessionTTL time.Duration 18 - AuthRequestTTL time.Duration 19 } 20 21 var _ oauth.ClientAuthStore = &RedisStore{} 22 23 - func NewRedisStore(redisURL string) (*RedisStore, error) { 24 - opts, err := redis.ParseURL(redisURL) 25 if err != nil { 26 return nil, fmt.Errorf("failed to parse redis URL: %w", err) 27 } ··· 37 } 38 39 return &RedisStore{ 40 - client: client, 41 - SessionTTL: 30 * 24 * time.Hour, // 30 days 42 - AuthRequestTTL: 10 * time.Minute, // 10 minutes 43 }, nil 44 } 45 ··· 51 return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 52 } 53 54 func authRequestKey(state string) string { 55 return fmt.Sprintf("oauth:auth_request:%s", state) 56 } 57 58 func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 59 key := sessionKey(did, sessionID) 60 data, err := r.client.Get(ctx, key).Bytes() 61 if err == redis.Nil { 62 return nil, fmt.Errorf("session not found: %s", did) ··· 75 76 func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 77 key := sessionKey(sess.AccountDID, sess.SessionID) 78 79 data, err := json.Marshal(sess) 80 if err != nil { 81 return fmt.Errorf("failed to marshal session: %w", err) 82 } 83 84 - if err := r.client.Set(ctx, key, data, r.SessionTTL).Err(); err != nil { 85 return fmt.Errorf("failed to save session: %w", err) 86 } 87 88 return nil 89 } 90 91 func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 92 key := sessionKey(did, sessionID) 93 - if err := r.client.Del(ctx, key).Err(); err != nil { 94 return fmt.Errorf("failed to delete session: %w", err) 95 } 96 return nil ··· 131 return fmt.Errorf("failed to marshal auth request: %w", err) 132 } 133 134 - if err := r.client.Set(ctx, key, data, r.AuthRequestTTL).Err(); err != nil { 135 return fmt.Errorf("failed to save auth request: %w", err) 136 } 137
··· 11 "github.com/redis/go-redis/v9" 12 ) 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 + 24 // redis-backed implementation of ClientAuthStore. 25 type RedisStore struct { 26 + client *redis.Client 27 + cfg *RedisStoreConfig 28 } 29 30 var _ oauth.ClientAuthStore = &RedisStore{} 31 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) 55 if err != nil { 56 return nil, fmt.Errorf("failed to parse redis URL: %w", err) 57 } ··· 67 } 68 69 return &RedisStore{ 70 + client: client, 71 + cfg: cfg, 72 }, nil 73 } 74 ··· 80 return fmt.Sprintf("oauth:session:%s:%s", did, sessionID) 81 } 82 83 + func sessionMetadataKey(did syntax.DID, sessionID string) string { 84 + return fmt.Sprintf("oauth:session_meta:%s:%s", did, sessionID) 85 + } 86 + 87 func authRequestKey(state string) string { 88 return fmt.Sprintf("oauth:auth_request:%s", state) 89 } 90 91 func (r *RedisStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 92 key := sessionKey(did, sessionID) 93 + metaKey := sessionMetadataKey(did, sessionID) 94 + 95 + // Check metadata for inactivity expiry 96 + metaData, err := r.client.Get(ctx, metaKey).Bytes() 97 + if err == redis.Nil { 98 + return nil, fmt.Errorf("session not found: %s", did) 99 + } 100 + if err != nil { 101 + return nil, fmt.Errorf("failed to get session metadata: %w", err) 102 + } 103 + 104 + var meta sessionMetadata 105 + if err := json.Unmarshal(metaData, &meta); err != nil { 106 + return nil, fmt.Errorf("failed to unmarshal session metadata: %w", err) 107 + } 108 + 109 + // Check if session has been inactive for too long 110 + inactiveThreshold := time.Now().Add(-r.cfg.SessionInactivityDuration) 111 + if meta.UpdatedAt.Before(inactiveThreshold) { 112 + // Session is inactive, delete it 113 + r.client.Del(ctx, key, metaKey) 114 + return nil, fmt.Errorf("session expired due to inactivity: %s", did) 115 + } 116 + 117 + // Get the actual session data 118 data, err := r.client.Get(ctx, key).Bytes() 119 if err == redis.Nil { 120 return nil, fmt.Errorf("session not found: %s", did) ··· 133 134 func (r *RedisStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 135 key := sessionKey(sess.AccountDID, sess.SessionID) 136 + metaKey := sessionMetadataKey(sess.AccountDID, sess.SessionID) 137 138 data, err := json.Marshal(sess) 139 if err != nil { 140 return fmt.Errorf("failed to marshal session: %w", err) 141 } 142 143 + // Check if session already exists to preserve CreatedAt 144 + var meta sessionMetadata 145 + existingMetaData, err := r.client.Get(ctx, metaKey).Bytes() 146 + if err == redis.Nil { 147 + // New session 148 + meta = sessionMetadata{ 149 + CreatedAt: time.Now(), 150 + UpdatedAt: time.Now(), 151 + } 152 + } else if err != nil { 153 + return fmt.Errorf("failed to check existing session metadata: %w", err) 154 + } else { 155 + // Existing session - preserve CreatedAt, update UpdatedAt 156 + if err := json.Unmarshal(existingMetaData, &meta); err != nil { 157 + return fmt.Errorf("failed to unmarshal existing session metadata: %w", err) 158 + } 159 + meta.UpdatedAt = time.Now() 160 + } 161 + 162 + // Calculate remaining TTL based on creation time 163 + remainingTTL := r.cfg.SessionExpiryDuration - time.Since(meta.CreatedAt) 164 + if remainingTTL <= 0 { 165 + return fmt.Errorf("session has expired") 166 + } 167 + 168 + // Use the shorter of: remaining TTL or inactivity duration 169 + ttl := min(r.cfg.SessionInactivityDuration, remainingTTL) 170 + 171 + // Save session data 172 + if err := r.client.Set(ctx, key, data, ttl).Err(); err != nil { 173 return fmt.Errorf("failed to save session: %w", err) 174 } 175 176 + // Save metadata 177 + metaData, err := json.Marshal(meta) 178 + if err != nil { 179 + return fmt.Errorf("failed to marshal session metadata: %w", err) 180 + } 181 + if err := r.client.Set(ctx, metaKey, metaData, ttl).Err(); err != nil { 182 + return fmt.Errorf("failed to save session metadata: %w", err) 183 + } 184 + 185 return nil 186 } 187 188 func (r *RedisStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 189 key := sessionKey(did, sessionID) 190 + metaKey := sessionMetadataKey(did, sessionID) 191 + 192 + if err := r.client.Del(ctx, key, metaKey).Err(); err != nil { 193 return fmt.Errorf("failed to delete session: %w", err) 194 } 195 return nil ··· 230 return fmt.Errorf("failed to marshal auth request: %w", err) 231 } 232 233 + if err := r.client.Set(ctx, key, data, r.cfg.AuthRequestExpiryDuration).Err(); err != nil { 234 return fmt.Errorf("failed to save auth request: %w", err) 235 } 236