A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

invalidate sessions when scopes change

+580 -3
+10
cmd/appview/serve.go
··· 148 148 } 149 149 fmt.Println("Using full OAuth scopes (including blob: scope)") 150 150 151 + // Invalidate sessions with mismatched scopes on startup 152 + // This ensures all users have the latest required scopes after deployment 153 + desiredScopes := oauth.GetDefaultScopes(defaultHoldDID) 154 + invalidatedCount, err := oauthStore.InvalidateSessionsWithMismatchedScopes(context.Background(), desiredScopes) 155 + if err != nil { 156 + fmt.Printf("Warning: Failed to invalidate sessions with mismatched scopes: %v\n", err) 157 + } else if invalidatedCount > 0 { 158 + fmt.Printf("Invalidated %d OAuth session(s) due to scope changes\n", invalidatedCount) 159 + } 160 + 151 161 // Create oauth token refresher 152 162 refresher := oauth.NewRefresher(oauthApp) 153 163
+83
pkg/appview/db/oauth_store.go
··· 234 234 return nil 235 235 } 236 236 237 + // InvalidateSessionsWithMismatchedScopes removes all sessions whose scopes don't match the desired scopes 238 + // This is called on AppView startup to ensure all sessions have current scopes 239 + // Returns the count of invalidated sessions 240 + func (s *OAuthStore) InvalidateSessionsWithMismatchedScopes(ctx context.Context, desiredScopes []string) (int, error) { 241 + // Query all sessions 242 + rows, err := s.db.QueryContext(ctx, ` 243 + SELECT session_key, account_did, session_id, session_data 244 + FROM oauth_sessions 245 + `) 246 + if err != nil { 247 + return 0, fmt.Errorf("failed to query sessions: %w", err) 248 + } 249 + defer rows.Close() 250 + 251 + var sessionsToDelete []string 252 + for rows.Next() { 253 + var sessionKey, accountDID, sessionID, sessionDataJSON string 254 + if err := rows.Scan(&sessionKey, &accountDID, &sessionID, &sessionDataJSON); err != nil { 255 + fmt.Printf("WARNING [oauth/store]: Failed to scan session row: %v\n", err) 256 + continue 257 + } 258 + 259 + // Parse session data 260 + var sessionData oauth.ClientSessionData 261 + if err := json.Unmarshal([]byte(sessionDataJSON), &sessionData); err != nil { 262 + fmt.Printf("WARNING [oauth/store]: Failed to parse session data for %s: %v\n", sessionKey, err) 263 + // Delete malformed sessions 264 + sessionsToDelete = append(sessionsToDelete, sessionKey) 265 + continue 266 + } 267 + 268 + // Check if scopes match (need to import oauth package for ScopesMatch) 269 + // Since we're in db package, we can't import oauth (circular dependency) 270 + // So we'll implement a simple scope comparison here 271 + if !scopesMatch(sessionData.Scopes, desiredScopes) { 272 + sessionsToDelete = append(sessionsToDelete, sessionKey) 273 + } 274 + } 275 + 276 + if err := rows.Err(); err != nil { 277 + return 0, fmt.Errorf("error iterating sessions: %w", err) 278 + } 279 + 280 + // Delete sessions with mismatched scopes 281 + if len(sessionsToDelete) > 0 { 282 + for _, key := range sessionsToDelete { 283 + _, err := s.db.ExecContext(ctx, ` 284 + DELETE FROM oauth_sessions WHERE session_key = ? 285 + `, key) 286 + if err != nil { 287 + fmt.Printf("WARNING [oauth/store]: Failed to delete session %s: %v\n", key, err) 288 + } 289 + } 290 + fmt.Printf("Invalidated %d OAuth session(s) with mismatched scopes\n", len(sessionsToDelete)) 291 + } 292 + 293 + return len(sessionsToDelete), nil 294 + } 295 + 296 + // scopesMatch checks if two scope lists are equivalent (order-independent) 297 + // Local implementation to avoid circular dependency with oauth package 298 + func scopesMatch(stored, desired []string) bool { 299 + if len(stored) == 0 && len(desired) == 0 { 300 + return true 301 + } 302 + if len(stored) != len(desired) { 303 + return false 304 + } 305 + 306 + desiredMap := make(map[string]bool, len(desired)) 307 + for _, scope := range desired { 308 + desiredMap[scope] = true 309 + } 310 + 311 + for _, scope := range stored { 312 + if !desiredMap[scope] { 313 + return false 314 + } 315 + } 316 + 317 + return true 318 + } 319 + 237 320 // makeSessionKey creates a composite key for session storage 238 321 func makeSessionKey(did, sessionID string) string { 239 322 return fmt.Sprintf("%s:%s", did, sessionID)
+371
pkg/appview/db/oauth_store_test.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + func TestInvalidateSessionsWithMismatchedScopes(t *testing.T) { 13 + // Create in-memory test database 14 + db, err := InitDB(":memory:") 15 + if err != nil { 16 + t.Fatalf("Failed to init database: %v", err) 17 + } 18 + defer db.Close() 19 + 20 + store := NewOAuthStore(db) 21 + ctx := context.Background() 22 + 23 + // Test 1: Empty database - should return 0 24 + count, err := store.InvalidateSessionsWithMismatchedScopes(ctx, []string{"atproto", "blob:image/png"}) 25 + if err != nil { 26 + t.Fatalf("Expected no error with empty DB, got: %v", err) 27 + } 28 + if count != 0 { 29 + t.Errorf("Expected 0 invalidated sessions in empty DB, got %d", count) 30 + } 31 + 32 + // Helper to create session data 33 + createSession := func(did, sessionID string, scopes []string) oauth.ClientSessionData { 34 + parsedDID, _ := syntax.ParseDID(did) 35 + return oauth.ClientSessionData{ 36 + AccountDID: parsedDID, 37 + SessionID: sessionID, 38 + HostURL: "https://bsky.social", 39 + AuthServerURL: "https://bsky.social", 40 + AuthServerTokenEndpoint: "https://bsky.social/oauth/token", 41 + Scopes: scopes, 42 + AccessToken: "test_access_token", 43 + RefreshToken: "test_refresh_token", 44 + DPoPAuthServerNonce: "test_nonce", 45 + DPoPHostNonce: "test_host_nonce", 46 + DPoPPrivateKeyMultibase: "test_key", 47 + } 48 + } 49 + 50 + // Test 2: Session with matching scopes - should not be invalidated 51 + matchingSession := createSession("did:plc:test1", "session1", []string{"atproto", "blob:image/png"}) 52 + if err := store.SaveSession(ctx, matchingSession); err != nil { 53 + t.Fatalf("Failed to save matching session: %v", err) 54 + } 55 + 56 + count, err = store.InvalidateSessionsWithMismatchedScopes(ctx, []string{"atproto", "blob:image/png"}) 57 + if err != nil { 58 + t.Fatalf("Expected no error, got: %v", err) 59 + } 60 + if count != 0 { 61 + t.Errorf("Expected 0 invalidated sessions (all match), got %d", count) 62 + } 63 + 64 + // Verify session still exists 65 + retrieved, err := store.GetSession(ctx, matchingSession.AccountDID, matchingSession.SessionID) 66 + if err != nil { 67 + t.Errorf("Expected session to still exist, got error: %v", err) 68 + } 69 + if retrieved == nil { 70 + t.Error("Expected session to still exist, got nil") 71 + } 72 + 73 + // Test 3: Session with mismatched scopes (missing scope) - should be invalidated 74 + mismatchedSession := createSession("did:plc:test2", "session2", []string{"atproto"}) // Missing blob scope 75 + if err := store.SaveSession(ctx, mismatchedSession); err != nil { 76 + t.Fatalf("Failed to save mismatched session: %v", err) 77 + } 78 + 79 + count, err = store.InvalidateSessionsWithMismatchedScopes(ctx, []string{"atproto", "blob:image/png"}) 80 + if err != nil { 81 + t.Fatalf("Expected no error, got: %v", err) 82 + } 83 + if count != 1 { 84 + t.Errorf("Expected 1 invalidated session, got %d", count) 85 + } 86 + 87 + // Verify mismatched session was deleted 88 + retrieved, err = store.GetSession(ctx, mismatchedSession.AccountDID, mismatchedSession.SessionID) 89 + if err == nil { 90 + t.Error("Expected session to be deleted (should error), but got no error") 91 + } 92 + 93 + // Test 4: Session with extra scopes - should be invalidated 94 + extraScopeSession := createSession("did:plc:test3", "session3", []string{"atproto", "blob:image/png", "extra:scope"}) 95 + if err := store.SaveSession(ctx, extraScopeSession); err != nil { 96 + t.Fatalf("Failed to save extra scope session: %v", err) 97 + } 98 + 99 + count, err = store.InvalidateSessionsWithMismatchedScopes(ctx, []string{"atproto", "blob:image/png"}) 100 + if err != nil { 101 + t.Fatalf("Expected no error, got: %v", err) 102 + } 103 + if count != 1 { 104 + t.Errorf("Expected 1 invalidated session (extra scope), got %d", count) 105 + } 106 + 107 + // Test 5: Multiple sessions with mixed matches - only mismatch should be invalidated 108 + matching1 := createSession("did:plc:test4", "session4", []string{"atproto", "blob:image/png"}) 109 + matching2 := createSession("did:plc:test5", "session5", []string{"blob:image/png", "atproto"}) // Different order 110 + mismatched1 := createSession("did:plc:test6", "session6", []string{"atproto"}) 111 + mismatched2 := createSession("did:plc:test7", "session7", []string{"wrong", "scopes"}) 112 + 113 + for _, sess := range []oauth.ClientSessionData{matching1, matching2, mismatched1, mismatched2} { 114 + if err := store.SaveSession(ctx, sess); err != nil { 115 + t.Fatalf("Failed to save session: %v", err) 116 + } 117 + } 118 + 119 + count, err = store.InvalidateSessionsWithMismatchedScopes(ctx, []string{"atproto", "blob:image/png"}) 120 + if err != nil { 121 + t.Fatalf("Expected no error, got: %v", err) 122 + } 123 + if count != 2 { 124 + t.Errorf("Expected 2 invalidated sessions, got %d", count) 125 + } 126 + 127 + // Verify matching sessions still exist 128 + for _, sess := range []oauth.ClientSessionData{matching1, matching2} { 129 + retrieved, err := store.GetSession(ctx, sess.AccountDID, sess.SessionID) 130 + if err != nil { 131 + t.Errorf("Expected matching session %s to exist, got error: %v", sess.SessionID, err) 132 + } 133 + if retrieved == nil { 134 + t.Errorf("Expected matching session %s to exist, got nil", sess.SessionID) 135 + } 136 + } 137 + 138 + // Test 6: Malformed session data - should be deleted 139 + parsedDID, _ := syntax.ParseDID("did:plc:test8") 140 + _, err = db.ExecContext(ctx, ` 141 + INSERT INTO oauth_sessions (session_key, account_did, session_id, session_data, created_at, updated_at) 142 + VALUES (?, ?, ?, ?, datetime('now'), datetime('now')) 143 + `, makeSessionKey("did:plc:test8", "malformed"), "did:plc:test8", "malformed", "invalid json data") 144 + if err != nil { 145 + t.Fatalf("Failed to insert malformed session: %v", err) 146 + } 147 + 148 + count, err = store.InvalidateSessionsWithMismatchedScopes(ctx, []string{"atproto", "blob:image/png"}) 149 + if err != nil { 150 + t.Fatalf("Expected no error handling malformed data, got: %v", err) 151 + } 152 + if count != 1 { 153 + t.Errorf("Expected 1 invalidated session (malformed), got %d", count) 154 + } 155 + 156 + // Verify malformed session was deleted 157 + retrieved, err = store.GetSession(ctx, parsedDID, "malformed") 158 + if err == nil { 159 + t.Error("Expected malformed session to be deleted, but got no error") 160 + } 161 + } 162 + 163 + func TestScopesMatch(t *testing.T) { 164 + // Test the local scopesMatch function to ensure it matches the oauth.ScopesMatch behavior 165 + tests := []struct { 166 + name string 167 + stored []string 168 + desired []string 169 + expected bool 170 + }{ 171 + { 172 + name: "exact match", 173 + stored: []string{"atproto", "blob:image/png"}, 174 + desired: []string{"atproto", "blob:image/png"}, 175 + expected: true, 176 + }, 177 + { 178 + name: "different order", 179 + stored: []string{"blob:image/png", "atproto"}, 180 + desired: []string{"atproto", "blob:image/png"}, 181 + expected: true, 182 + }, 183 + { 184 + name: "missing scope", 185 + stored: []string{"atproto"}, 186 + desired: []string{"atproto", "blob:image/png"}, 187 + expected: false, 188 + }, 189 + { 190 + name: "extra scope", 191 + stored: []string{"atproto", "blob:image/png", "extra"}, 192 + desired: []string{"atproto", "blob:image/png"}, 193 + expected: false, 194 + }, 195 + { 196 + name: "both empty", 197 + stored: []string{}, 198 + desired: []string{}, 199 + expected: true, 200 + }, 201 + { 202 + name: "nil vs empty", 203 + stored: nil, 204 + desired: []string{}, 205 + expected: true, 206 + }, 207 + } 208 + 209 + for _, tt := range tests { 210 + t.Run(tt.name, func(t *testing.T) { 211 + result := scopesMatch(tt.stored, tt.desired) 212 + if result != tt.expected { 213 + t.Errorf("scopesMatch(%v, %v) = %v, want %v", 214 + tt.stored, tt.desired, result, tt.expected) 215 + } 216 + }) 217 + } 218 + } 219 + 220 + func TestOAuthStoreSessionLifecycle(t *testing.T) { 221 + // Basic test to ensure SaveSession, GetSession, DeleteSession work correctly 222 + db, err := InitDB(":memory:") 223 + if err != nil { 224 + t.Fatalf("Failed to init database: %v", err) 225 + } 226 + defer db.Close() 227 + 228 + store := NewOAuthStore(db) 229 + ctx := context.Background() 230 + 231 + // Create test session 232 + did, _ := syntax.ParseDID("did:plc:testuser") 233 + sessionData := oauth.ClientSessionData{ 234 + AccountDID: did, 235 + SessionID: "test_session_id", 236 + HostURL: "https://bsky.social", 237 + AuthServerURL: "https://bsky.social", 238 + AuthServerTokenEndpoint: "https://bsky.social/oauth/token", 239 + Scopes: []string{"atproto", "blob:image/png"}, 240 + AccessToken: "test_access_token", 241 + RefreshToken: "test_refresh_token", 242 + DPoPAuthServerNonce: "test_nonce", 243 + DPoPHostNonce: "test_host_nonce", 244 + DPoPPrivateKeyMultibase: "test_key", 245 + } 246 + 247 + // Test SaveSession 248 + if err := store.SaveSession(ctx, sessionData); err != nil { 249 + t.Fatalf("Failed to save session: %v", err) 250 + } 251 + 252 + // Test GetSession 253 + retrieved, err := store.GetSession(ctx, did, "test_session_id") 254 + if err != nil { 255 + t.Fatalf("Failed to get session: %v", err) 256 + } 257 + if retrieved == nil { 258 + t.Fatal("Retrieved session is nil") 259 + } 260 + if retrieved.SessionID != sessionData.SessionID { 261 + t.Errorf("Expected session ID %s, got %s", sessionData.SessionID, retrieved.SessionID) 262 + } 263 + if len(retrieved.Scopes) != len(sessionData.Scopes) { 264 + t.Errorf("Expected %d scopes, got %d", len(sessionData.Scopes), len(retrieved.Scopes)) 265 + } 266 + 267 + // Test UpdateSession (upsert) 268 + sessionData.AccessToken = "new_access_token" 269 + if err := store.SaveSession(ctx, sessionData); err != nil { 270 + t.Fatalf("Failed to update session: %v", err) 271 + } 272 + 273 + retrieved, err = store.GetSession(ctx, did, "test_session_id") 274 + if err != nil { 275 + t.Fatalf("Failed to get updated session: %v", err) 276 + } 277 + if retrieved.AccessToken != "new_access_token" { 278 + t.Errorf("Expected updated access token, got %s", retrieved.AccessToken) 279 + } 280 + 281 + // Test DeleteSession 282 + if err := store.DeleteSession(ctx, did, "test_session_id"); err != nil { 283 + t.Fatalf("Failed to delete session: %v", err) 284 + } 285 + 286 + // Verify deletion 287 + retrieved, err = store.GetSession(ctx, did, "test_session_id") 288 + if err == nil { 289 + t.Error("Expected error after deletion, got nil") 290 + } 291 + } 292 + 293 + func TestCleanupOldSessions(t *testing.T) { 294 + db, err := InitDB(":memory:") 295 + if err != nil { 296 + t.Fatalf("Failed to init database: %v", err) 297 + } 298 + defer db.Close() 299 + 300 + store := NewOAuthStore(db) 301 + ctx := context.Background() 302 + 303 + // Insert old session (31 days ago) 304 + did1, _ := syntax.ParseDID("did:plc:old") 305 + oldSessionData := oauth.ClientSessionData{ 306 + AccountDID: did1, 307 + SessionID: "old_session", 308 + HostURL: "https://bsky.social", 309 + AuthServerURL: "https://bsky.social", 310 + AuthServerTokenEndpoint: "https://bsky.social/oauth/token", 311 + Scopes: []string{"atproto"}, 312 + AccessToken: "old_token", 313 + RefreshToken: "old_refresh", 314 + DPoPAuthServerNonce: "old_nonce", 315 + DPoPHostNonce: "old_host_nonce", 316 + DPoPPrivateKeyMultibase: "old_key", 317 + } 318 + 319 + // Save and manually update timestamp to be old 320 + if err := store.SaveSession(ctx, oldSessionData); err != nil { 321 + t.Fatalf("Failed to save old session: %v", err) 322 + } 323 + 324 + // Update timestamp to 31 days ago 325 + oldTime := time.Now().Add(-31 * 24 * time.Hour) 326 + _, err = db.ExecContext(ctx, ` 327 + UPDATE oauth_sessions 328 + SET updated_at = ? 329 + WHERE session_key = ? 330 + `, oldTime, makeSessionKey(did1.String(), "old_session")) 331 + if err != nil { 332 + t.Fatalf("Failed to update session timestamp: %v", err) 333 + } 334 + 335 + // Insert recent session (1 day ago) 336 + did2, _ := syntax.ParseDID("did:plc:recent") 337 + recentSessionData := oauth.ClientSessionData{ 338 + AccountDID: did2, 339 + SessionID: "recent_session", 340 + HostURL: "https://bsky.social", 341 + AuthServerURL: "https://bsky.social", 342 + AuthServerTokenEndpoint: "https://bsky.social/oauth/token", 343 + Scopes: []string{"atproto"}, 344 + AccessToken: "recent_token", 345 + RefreshToken: "recent_refresh", 346 + DPoPAuthServerNonce: "recent_nonce", 347 + DPoPHostNonce: "recent_host_nonce", 348 + DPoPPrivateKeyMultibase: "recent_key", 349 + } 350 + 351 + if err := store.SaveSession(ctx, recentSessionData); err != nil { 352 + t.Fatalf("Failed to save recent session: %v", err) 353 + } 354 + 355 + // Run cleanup (remove sessions older than 30 days) 356 + if err := store.CleanupOldSessions(ctx, 30*24*time.Hour); err != nil { 357 + t.Fatalf("Failed to cleanup old sessions: %v", err) 358 + } 359 + 360 + // Verify old session was deleted 361 + _, err = store.GetSession(ctx, did1, "old_session") 362 + if err == nil { 363 + t.Error("Expected old session to be deleted") 364 + } 365 + 366 + // Verify recent session still exists 367 + _, err = store.GetSession(ctx, did2, "recent_session") 368 + if err != nil { 369 + t.Errorf("Expected recent session to exist, got error: %v", err) 370 + } 371 + }
+1 -1
pkg/appview/db/readonly.go
··· 92 92 93 93 // Start cleanup goroutines for all SQLite stores 94 94 go func() { 95 - ticker := time.NewTicker(5 * time.Minute) 95 + ticker := time.NewTicker(1 * time.Hour) 96 96 defer ticker.Stop() 97 97 for range ticker.C { 98 98 ctx := context.Background()
+7 -1
pkg/appview/templates/pages/repository.html
··· 196 196 {{ end }} 197 197 </div> 198 198 {{ if .IsManifestList }} 199 - <span class="platform-count">{{ .PlatformCount }} platforms</span> 199 + {{ if .Platforms }} 200 + <div class="platforms-inline"> 201 + {{ range .Platforms }} 202 + <span class="platform-badge">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span> 203 + {{ end }} 204 + </div> 205 + {{ end }} 200 206 {{ end }} 201 207 </div> 202 208 </div>
+27
pkg/auth/oauth/client.go
··· 139 139 fmt.Sprintf("repo:%s", atproto.SailorProfileCollection), 140 140 } 141 141 } 142 + 143 + // ScopesMatch checks if two scope lists are equivalent (order-independent) 144 + // Returns true if both lists contain the same scopes, regardless of order 145 + func ScopesMatch(stored, desired []string) bool { 146 + // Handle nil/empty cases 147 + if len(stored) == 0 && len(desired) == 0 { 148 + return true 149 + } 150 + if len(stored) != len(desired) { 151 + return false 152 + } 153 + 154 + // Build map of desired scopes for O(1) lookup 155 + desiredMap := make(map[string]bool, len(desired)) 156 + for _, scope := range desired { 157 + desiredMap[scope] = true 158 + } 159 + 160 + // Check if all stored scopes exist in desired 161 + for _, scope := range stored { 162 + if !desiredMap[scope] { 163 + return false 164 + } 165 + } 166 + 167 + return true 168 + }
+65
pkg/auth/oauth/client_test.go
··· 1 + package oauth 2 + 3 + import "testing" 4 + 5 + func TestScopesMatch(t *testing.T) { 6 + tests := []struct { 7 + name string 8 + stored []string 9 + desired []string 10 + expected bool 11 + }{ 12 + { 13 + name: "exact match", 14 + stored: []string{"atproto", "blob:image/png"}, 15 + desired: []string{"atproto", "blob:image/png"}, 16 + expected: true, 17 + }, 18 + { 19 + name: "different order", 20 + stored: []string{"blob:image/png", "atproto"}, 21 + desired: []string{"atproto", "blob:image/png"}, 22 + expected: true, 23 + }, 24 + { 25 + name: "missing scope in stored", 26 + stored: []string{"atproto"}, 27 + desired: []string{"atproto", "blob:image/png"}, 28 + expected: false, 29 + }, 30 + { 31 + name: "extra scope in stored", 32 + stored: []string{"atproto", "blob:image/png", "extra"}, 33 + desired: []string{"atproto", "blob:image/png"}, 34 + expected: false, 35 + }, 36 + { 37 + name: "both empty", 38 + stored: []string{}, 39 + desired: []string{}, 40 + expected: true, 41 + }, 42 + { 43 + name: "nil vs empty", 44 + stored: nil, 45 + desired: []string{}, 46 + expected: true, 47 + }, 48 + { 49 + name: "completely different", 50 + stored: []string{"foo", "bar"}, 51 + desired: []string{"baz", "qux"}, 52 + expected: false, 53 + }, 54 + } 55 + 56 + for _, tt := range tests { 57 + t.Run(tt.name, func(t *testing.T) { 58 + result := ScopesMatch(tt.stored, tt.desired) 59 + if result != tt.expected { 60 + t.Errorf("ScopesMatch(%v, %v) = %v, want %v", 61 + tt.stored, tt.desired, result, tt.expected) 62 + } 63 + }) 64 + } 65 + }
+16 -1
pkg/auth/oauth/refresher.go
··· 106 106 return nil, fmt.Errorf("store must implement GetLatestSessionForDID (SQLite store required)") 107 107 } 108 108 109 - _, sessionID, err := getter.GetLatestSessionForDID(ctx, did) 109 + sessionData, sessionID, err := getter.GetLatestSessionForDID(ctx, did) 110 110 if err != nil { 111 111 return nil, fmt.Errorf("no session found for DID: %s", did) 112 + } 113 + 114 + // Validate that session scopes match current desired scopes 115 + desiredScopes := r.app.GetConfig().Scopes 116 + if !ScopesMatch(sessionData.Scopes, desiredScopes) { 117 + fmt.Printf("DEBUG [oauth/refresher]: Scope mismatch for DID %s - deleting session\n", did) 118 + fmt.Printf(" Stored scopes: %v\n", sessionData.Scopes) 119 + fmt.Printf(" Desired scopes: %v\n", desiredScopes) 120 + 121 + // Delete the session from database since scopes have changed 122 + if err := r.app.clientApp.Store.DeleteSession(ctx, accountDID, sessionID); err != nil { 123 + fmt.Printf("WARNING [oauth/refresher]: Failed to delete session with mismatched scopes: %v\n", err) 124 + } 125 + 126 + return nil, fmt.Errorf("OAuth scopes changed, re-authentication required") 112 127 } 113 128 114 129 // Resume session