A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
80
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