invalidate sessions when scopes change

This commit is contained in:
Evan Jarrett
2025-10-21 09:54:23 -05:00
parent ce7160cdca
commit 08fb8abb41
8 changed files with 580 additions and 3 deletions
+10
View File
@@ -148,6 +148,16 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
}
fmt.Println("Using full OAuth scopes (including blob: scope)")
// Invalidate sessions with mismatched scopes on startup
// This ensures all users have the latest required scopes after deployment
desiredScopes := oauth.GetDefaultScopes(defaultHoldDID)
invalidatedCount, err := oauthStore.InvalidateSessionsWithMismatchedScopes(context.Background(), desiredScopes)
if err != nil {
fmt.Printf("Warning: Failed to invalidate sessions with mismatched scopes: %v\n", err)
} else if invalidatedCount > 0 {
fmt.Printf("Invalidated %d OAuth session(s) due to scope changes\n", invalidatedCount)
}
// Create oauth token refresher
refresher := oauth.NewRefresher(oauthApp)
+83
View File
@@ -234,6 +234,89 @@ func (s *OAuthStore) CleanupExpiredAuthRequests(ctx context.Context) error {
return nil
}
// InvalidateSessionsWithMismatchedScopes removes all sessions whose scopes don't match the desired scopes
// This is called on AppView startup to ensure all sessions have current scopes
// Returns the count of invalidated sessions
func (s *OAuthStore) InvalidateSessionsWithMismatchedScopes(ctx context.Context, desiredScopes []string) (int, error) {
// Query all sessions
rows, err := s.db.QueryContext(ctx, `
SELECT session_key, account_did, session_id, session_data
FROM oauth_sessions
`)
if err != nil {
return 0, fmt.Errorf("failed to query sessions: %w", err)
}
defer rows.Close()
var sessionsToDelete []string
for rows.Next() {
var sessionKey, accountDID, sessionID, sessionDataJSON string
if err := rows.Scan(&sessionKey, &accountDID, &sessionID, &sessionDataJSON); err != nil {
fmt.Printf("WARNING [oauth/store]: Failed to scan session row: %v\n", err)
continue
}
// Parse session data
var sessionData oauth.ClientSessionData
if err := json.Unmarshal([]byte(sessionDataJSON), &sessionData); err != nil {
fmt.Printf("WARNING [oauth/store]: Failed to parse session data for %s: %v\n", sessionKey, err)
// Delete malformed sessions
sessionsToDelete = append(sessionsToDelete, sessionKey)
continue
}
// Check if scopes match (need to import oauth package for ScopesMatch)
// Since we're in db package, we can't import oauth (circular dependency)
// So we'll implement a simple scope comparison here
if !scopesMatch(sessionData.Scopes, desiredScopes) {
sessionsToDelete = append(sessionsToDelete, sessionKey)
}
}
if err := rows.Err(); err != nil {
return 0, fmt.Errorf("error iterating sessions: %w", err)
}
// Delete sessions with mismatched scopes
if len(sessionsToDelete) > 0 {
for _, key := range sessionsToDelete {
_, err := s.db.ExecContext(ctx, `
DELETE FROM oauth_sessions WHERE session_key = ?
`, key)
if err != nil {
fmt.Printf("WARNING [oauth/store]: Failed to delete session %s: %v\n", key, err)
}
}
fmt.Printf("Invalidated %d OAuth session(s) with mismatched scopes\n", len(sessionsToDelete))
}
return len(sessionsToDelete), nil
}
// scopesMatch checks if two scope lists are equivalent (order-independent)
// Local implementation to avoid circular dependency with oauth package
func scopesMatch(stored, desired []string) bool {
if len(stored) == 0 && len(desired) == 0 {
return true
}
if len(stored) != len(desired) {
return false
}
desiredMap := make(map[string]bool, len(desired))
for _, scope := range desired {
desiredMap[scope] = true
}
for _, scope := range stored {
if !desiredMap[scope] {
return false
}
}
return true
}
// makeSessionKey creates a composite key for session storage
func makeSessionKey(did, sessionID string) string {
return fmt.Sprintf("%s:%s", did, sessionID)
+371
View File
@@ -0,0 +1,371 @@
package db
import (
"context"
"testing"
"time"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/syntax"
)
func TestInvalidateSessionsWithMismatchedScopes(t *testing.T) {
// Create in-memory test database
db, err := InitDB(":memory:")
if err != nil {
t.Fatalf("Failed to init database: %v", err)
}
defer db.Close()
store := NewOAuthStore(db)
ctx := context.Background()
// Test 1: Empty database - should return 0
count, err := store.InvalidateSessionsWithMismatchedScopes(ctx, []string{"atproto", "blob:image/png"})
if err != nil {
t.Fatalf("Expected no error with empty DB, got: %v", err)
}
if count != 0 {
t.Errorf("Expected 0 invalidated sessions in empty DB, got %d", count)
}
// Helper to create session data
createSession := func(did, sessionID string, scopes []string) oauth.ClientSessionData {
parsedDID, _ := syntax.ParseDID(did)
return oauth.ClientSessionData{
AccountDID: parsedDID,
SessionID: sessionID,
HostURL: "https://bsky.social",
AuthServerURL: "https://bsky.social",
AuthServerTokenEndpoint: "https://bsky.social/oauth/token",
Scopes: scopes,
AccessToken: "test_access_token",
RefreshToken: "test_refresh_token",
DPoPAuthServerNonce: "test_nonce",
DPoPHostNonce: "test_host_nonce",
DPoPPrivateKeyMultibase: "test_key",
}
}
// Test 2: Session with matching scopes - should not be invalidated
matchingSession := createSession("did:plc:test1", "session1", []string{"atproto", "blob:image/png"})
if err := store.SaveSession(ctx, matchingSession); err != nil {
t.Fatalf("Failed to save matching session: %v", err)
}
count, err = store.InvalidateSessionsWithMismatchedScopes(ctx, []string{"atproto", "blob:image/png"})
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if count != 0 {
t.Errorf("Expected 0 invalidated sessions (all match), got %d", count)
}
// Verify session still exists
retrieved, err := store.GetSession(ctx, matchingSession.AccountDID, matchingSession.SessionID)
if err != nil {
t.Errorf("Expected session to still exist, got error: %v", err)
}
if retrieved == nil {
t.Error("Expected session to still exist, got nil")
}
// Test 3: Session with mismatched scopes (missing scope) - should be invalidated
mismatchedSession := createSession("did:plc:test2", "session2", []string{"atproto"}) // Missing blob scope
if err := store.SaveSession(ctx, mismatchedSession); err != nil {
t.Fatalf("Failed to save mismatched session: %v", err)
}
count, err = store.InvalidateSessionsWithMismatchedScopes(ctx, []string{"atproto", "blob:image/png"})
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if count != 1 {
t.Errorf("Expected 1 invalidated session, got %d", count)
}
// Verify mismatched session was deleted
retrieved, err = store.GetSession(ctx, mismatchedSession.AccountDID, mismatchedSession.SessionID)
if err == nil {
t.Error("Expected session to be deleted (should error), but got no error")
}
// Test 4: Session with extra scopes - should be invalidated
extraScopeSession := createSession("did:plc:test3", "session3", []string{"atproto", "blob:image/png", "extra:scope"})
if err := store.SaveSession(ctx, extraScopeSession); err != nil {
t.Fatalf("Failed to save extra scope session: %v", err)
}
count, err = store.InvalidateSessionsWithMismatchedScopes(ctx, []string{"atproto", "blob:image/png"})
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if count != 1 {
t.Errorf("Expected 1 invalidated session (extra scope), got %d", count)
}
// Test 5: Multiple sessions with mixed matches - only mismatch should be invalidated
matching1 := createSession("did:plc:test4", "session4", []string{"atproto", "blob:image/png"})
matching2 := createSession("did:plc:test5", "session5", []string{"blob:image/png", "atproto"}) // Different order
mismatched1 := createSession("did:plc:test6", "session6", []string{"atproto"})
mismatched2 := createSession("did:plc:test7", "session7", []string{"wrong", "scopes"})
for _, sess := range []oauth.ClientSessionData{matching1, matching2, mismatched1, mismatched2} {
if err := store.SaveSession(ctx, sess); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
}
count, err = store.InvalidateSessionsWithMismatchedScopes(ctx, []string{"atproto", "blob:image/png"})
if err != nil {
t.Fatalf("Expected no error, got: %v", err)
}
if count != 2 {
t.Errorf("Expected 2 invalidated sessions, got %d", count)
}
// Verify matching sessions still exist
for _, sess := range []oauth.ClientSessionData{matching1, matching2} {
retrieved, err := store.GetSession(ctx, sess.AccountDID, sess.SessionID)
if err != nil {
t.Errorf("Expected matching session %s to exist, got error: %v", sess.SessionID, err)
}
if retrieved == nil {
t.Errorf("Expected matching session %s to exist, got nil", sess.SessionID)
}
}
// Test 6: Malformed session data - should be deleted
parsedDID, _ := syntax.ParseDID("did:plc:test8")
_, err = db.ExecContext(ctx, `
INSERT INTO oauth_sessions (session_key, account_did, session_id, session_data, created_at, updated_at)
VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
`, makeSessionKey("did:plc:test8", "malformed"), "did:plc:test8", "malformed", "invalid json data")
if err != nil {
t.Fatalf("Failed to insert malformed session: %v", err)
}
count, err = store.InvalidateSessionsWithMismatchedScopes(ctx, []string{"atproto", "blob:image/png"})
if err != nil {
t.Fatalf("Expected no error handling malformed data, got: %v", err)
}
if count != 1 {
t.Errorf("Expected 1 invalidated session (malformed), got %d", count)
}
// Verify malformed session was deleted
retrieved, err = store.GetSession(ctx, parsedDID, "malformed")
if err == nil {
t.Error("Expected malformed session to be deleted, but got no error")
}
}
func TestScopesMatch(t *testing.T) {
// Test the local scopesMatch function to ensure it matches the oauth.ScopesMatch behavior
tests := []struct {
name string
stored []string
desired []string
expected bool
}{
{
name: "exact match",
stored: []string{"atproto", "blob:image/png"},
desired: []string{"atproto", "blob:image/png"},
expected: true,
},
{
name: "different order",
stored: []string{"blob:image/png", "atproto"},
desired: []string{"atproto", "blob:image/png"},
expected: true,
},
{
name: "missing scope",
stored: []string{"atproto"},
desired: []string{"atproto", "blob:image/png"},
expected: false,
},
{
name: "extra scope",
stored: []string{"atproto", "blob:image/png", "extra"},
desired: []string{"atproto", "blob:image/png"},
expected: false,
},
{
name: "both empty",
stored: []string{},
desired: []string{},
expected: true,
},
{
name: "nil vs empty",
stored: nil,
desired: []string{},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scopesMatch(tt.stored, tt.desired)
if result != tt.expected {
t.Errorf("scopesMatch(%v, %v) = %v, want %v",
tt.stored, tt.desired, result, tt.expected)
}
})
}
}
func TestOAuthStoreSessionLifecycle(t *testing.T) {
// Basic test to ensure SaveSession, GetSession, DeleteSession work correctly
db, err := InitDB(":memory:")
if err != nil {
t.Fatalf("Failed to init database: %v", err)
}
defer db.Close()
store := NewOAuthStore(db)
ctx := context.Background()
// Create test session
did, _ := syntax.ParseDID("did:plc:testuser")
sessionData := oauth.ClientSessionData{
AccountDID: did,
SessionID: "test_session_id",
HostURL: "https://bsky.social",
AuthServerURL: "https://bsky.social",
AuthServerTokenEndpoint: "https://bsky.social/oauth/token",
Scopes: []string{"atproto", "blob:image/png"},
AccessToken: "test_access_token",
RefreshToken: "test_refresh_token",
DPoPAuthServerNonce: "test_nonce",
DPoPHostNonce: "test_host_nonce",
DPoPPrivateKeyMultibase: "test_key",
}
// Test SaveSession
if err := store.SaveSession(ctx, sessionData); err != nil {
t.Fatalf("Failed to save session: %v", err)
}
// Test GetSession
retrieved, err := store.GetSession(ctx, did, "test_session_id")
if err != nil {
t.Fatalf("Failed to get session: %v", err)
}
if retrieved == nil {
t.Fatal("Retrieved session is nil")
}
if retrieved.SessionID != sessionData.SessionID {
t.Errorf("Expected session ID %s, got %s", sessionData.SessionID, retrieved.SessionID)
}
if len(retrieved.Scopes) != len(sessionData.Scopes) {
t.Errorf("Expected %d scopes, got %d", len(sessionData.Scopes), len(retrieved.Scopes))
}
// Test UpdateSession (upsert)
sessionData.AccessToken = "new_access_token"
if err := store.SaveSession(ctx, sessionData); err != nil {
t.Fatalf("Failed to update session: %v", err)
}
retrieved, err = store.GetSession(ctx, did, "test_session_id")
if err != nil {
t.Fatalf("Failed to get updated session: %v", err)
}
if retrieved.AccessToken != "new_access_token" {
t.Errorf("Expected updated access token, got %s", retrieved.AccessToken)
}
// Test DeleteSession
if err := store.DeleteSession(ctx, did, "test_session_id"); err != nil {
t.Fatalf("Failed to delete session: %v", err)
}
// Verify deletion
retrieved, err = store.GetSession(ctx, did, "test_session_id")
if err == nil {
t.Error("Expected error after deletion, got nil")
}
}
func TestCleanupOldSessions(t *testing.T) {
db, err := InitDB(":memory:")
if err != nil {
t.Fatalf("Failed to init database: %v", err)
}
defer db.Close()
store := NewOAuthStore(db)
ctx := context.Background()
// Insert old session (31 days ago)
did1, _ := syntax.ParseDID("did:plc:old")
oldSessionData := oauth.ClientSessionData{
AccountDID: did1,
SessionID: "old_session",
HostURL: "https://bsky.social",
AuthServerURL: "https://bsky.social",
AuthServerTokenEndpoint: "https://bsky.social/oauth/token",
Scopes: []string{"atproto"},
AccessToken: "old_token",
RefreshToken: "old_refresh",
DPoPAuthServerNonce: "old_nonce",
DPoPHostNonce: "old_host_nonce",
DPoPPrivateKeyMultibase: "old_key",
}
// Save and manually update timestamp to be old
if err := store.SaveSession(ctx, oldSessionData); err != nil {
t.Fatalf("Failed to save old session: %v", err)
}
// Update timestamp to 31 days ago
oldTime := time.Now().Add(-31 * 24 * time.Hour)
_, err = db.ExecContext(ctx, `
UPDATE oauth_sessions
SET updated_at = ?
WHERE session_key = ?
`, oldTime, makeSessionKey(did1.String(), "old_session"))
if err != nil {
t.Fatalf("Failed to update session timestamp: %v", err)
}
// Insert recent session (1 day ago)
did2, _ := syntax.ParseDID("did:plc:recent")
recentSessionData := oauth.ClientSessionData{
AccountDID: did2,
SessionID: "recent_session",
HostURL: "https://bsky.social",
AuthServerURL: "https://bsky.social",
AuthServerTokenEndpoint: "https://bsky.social/oauth/token",
Scopes: []string{"atproto"},
AccessToken: "recent_token",
RefreshToken: "recent_refresh",
DPoPAuthServerNonce: "recent_nonce",
DPoPHostNonce: "recent_host_nonce",
DPoPPrivateKeyMultibase: "recent_key",
}
if err := store.SaveSession(ctx, recentSessionData); err != nil {
t.Fatalf("Failed to save recent session: %v", err)
}
// Run cleanup (remove sessions older than 30 days)
if err := store.CleanupOldSessions(ctx, 30*24*time.Hour); err != nil {
t.Fatalf("Failed to cleanup old sessions: %v", err)
}
// Verify old session was deleted
_, err = store.GetSession(ctx, did1, "old_session")
if err == nil {
t.Error("Expected old session to be deleted")
}
// Verify recent session still exists
_, err = store.GetSession(ctx, did2, "recent_session")
if err != nil {
t.Errorf("Expected recent session to exist, got error: %v", err)
}
}
+1 -1
View File
@@ -92,7 +92,7 @@ func InitializeDatabase(uiEnabled bool, dbPath string) (*sql.DB, *sql.DB, *Sessi
// Start cleanup goroutines for all SQLite stores
go func() {
ticker := time.NewTicker(5 * time.Minute)
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
ctx := context.Background()
+7 -1
View File
@@ -196,7 +196,13 @@
{{ end }}
</div>
{{ if .IsManifestList }}
<span class="platform-count">{{ .PlatformCount }} platforms</span>
{{ if .Platforms }}
<div class="platforms-inline">
{{ range .Platforms }}
<span class="platform-badge">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
{{ end }}
</div>
{{ end }}
{{ end }}
</div>
</div>
+27
View File
@@ -139,3 +139,30 @@ func GetDefaultScopes(did string) []string {
fmt.Sprintf("repo:%s", atproto.SailorProfileCollection),
}
}
// ScopesMatch checks if two scope lists are equivalent (order-independent)
// Returns true if both lists contain the same scopes, regardless of order
func ScopesMatch(stored, desired []string) bool {
// Handle nil/empty cases
if len(stored) == 0 && len(desired) == 0 {
return true
}
if len(stored) != len(desired) {
return false
}
// Build map of desired scopes for O(1) lookup
desiredMap := make(map[string]bool, len(desired))
for _, scope := range desired {
desiredMap[scope] = true
}
// Check if all stored scopes exist in desired
for _, scope := range stored {
if !desiredMap[scope] {
return false
}
}
return true
}
+65
View File
@@ -0,0 +1,65 @@
package oauth
import "testing"
func TestScopesMatch(t *testing.T) {
tests := []struct {
name string
stored []string
desired []string
expected bool
}{
{
name: "exact match",
stored: []string{"atproto", "blob:image/png"},
desired: []string{"atproto", "blob:image/png"},
expected: true,
},
{
name: "different order",
stored: []string{"blob:image/png", "atproto"},
desired: []string{"atproto", "blob:image/png"},
expected: true,
},
{
name: "missing scope in stored",
stored: []string{"atproto"},
desired: []string{"atproto", "blob:image/png"},
expected: false,
},
{
name: "extra scope in stored",
stored: []string{"atproto", "blob:image/png", "extra"},
desired: []string{"atproto", "blob:image/png"},
expected: false,
},
{
name: "both empty",
stored: []string{},
desired: []string{},
expected: true,
},
{
name: "nil vs empty",
stored: nil,
desired: []string{},
expected: true,
},
{
name: "completely different",
stored: []string{"foo", "bar"},
desired: []string{"baz", "qux"},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ScopesMatch(tt.stored, tt.desired)
if result != tt.expected {
t.Errorf("ScopesMatch(%v, %v) = %v, want %v",
tt.stored, tt.desired, result, tt.expected)
}
})
}
}
+16 -1
View File
@@ -106,11 +106,26 @@ func (r *Refresher) resumeSession(ctx context.Context, did string) (*oauth.Clien
return nil, fmt.Errorf("store must implement GetLatestSessionForDID (SQLite store required)")
}
_, sessionID, err := getter.GetLatestSessionForDID(ctx, did)
sessionData, sessionID, err := getter.GetLatestSessionForDID(ctx, did)
if err != nil {
return nil, fmt.Errorf("no session found for DID: %s", did)
}
// Validate that session scopes match current desired scopes
desiredScopes := r.app.GetConfig().Scopes
if !ScopesMatch(sessionData.Scopes, desiredScopes) {
fmt.Printf("DEBUG [oauth/refresher]: Scope mismatch for DID %s - deleting session\n", did)
fmt.Printf(" Stored scopes: %v\n", sessionData.Scopes)
fmt.Printf(" Desired scopes: %v\n", desiredScopes)
// Delete the session from database since scopes have changed
if err := r.app.clientApp.Store.DeleteSession(ctx, accountDID, sessionID); err != nil {
fmt.Printf("WARNING [oauth/refresher]: Failed to delete session with mismatched scopes: %v\n", err)
}
return nil, fmt.Errorf("OAuth scopes changed, re-authentication required")
}
// Resume session
session, err := r.app.ResumeSession(ctx, accountDID, sessionID)
if err != nil {