fix oauth scope mismatch

This commit is contained in:
Evan Jarrett
2026-01-05 20:26:41 -06:00
parent a448e8257b
commit f35bf2bcde
10 changed files with 1510 additions and 50 deletions
+10 -28
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"time"
atoauth "atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/syntax"
)
@@ -283,10 +284,15 @@ func (s *OAuthStore) InvalidateSessionsWithMismatchedScopes(ctx context.Context,
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) {
// Check if scopes match (expands include: scopes before comparing)
if !atoauth.ScopesMatch(sessionData.Scopes, desiredScopes) {
slog.Debug("Session has mismatched scopes",
"component", "oauth/store",
"session_key", sessionKey,
"account_did", accountDID,
"session_scopes", sessionData.Scopes,
"desired_scopes", desiredScopes,
)
sessionsToDelete = append(sessionsToDelete, sessionKey)
}
}
@@ -311,30 +317,6 @@ func (s *OAuthStore) InvalidateSessionsWithMismatchedScopes(ctx context.Context,
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
}
// GetSessionStats returns statistics about stored OAuth sessions
// Useful for monitoring and debugging session health
func (s *OAuthStore) GetSessionStats(ctx context.Context) (map[string]any, error) {
+16 -3
View File
@@ -5,6 +5,7 @@ import (
"testing"
"time"
atcroauth "atcr.io/pkg/auth/oauth"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/syntax"
)
@@ -161,7 +162,7 @@ func TestInvalidateSessionsWithMismatchedScopes(t *testing.T) {
}
func TestScopesMatch(t *testing.T) {
// Test the local scopesMatch function to ensure it matches the oauth.ScopesMatch behavior
// Test oauth.ScopesMatch function including include: scope expansion
tests := []struct {
name string
stored []string
@@ -204,13 +205,25 @@ func TestScopesMatch(t *testing.T) {
desired: []string{},
expected: true,
},
{
name: "include scope expansion",
stored: []string{
"atproto",
"repo?collection=io.atcr.manifest&collection=io.atcr.repo.page&collection=io.atcr.sailor.profile&collection=io.atcr.sailor.star&collection=io.atcr.tag",
},
desired: []string{
"atproto",
"include:io.atcr.authFullApp",
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := scopesMatch(tt.stored, tt.desired)
result := atcroauth.ScopesMatch(tt.stored, tt.desired)
if result != tt.expected {
t.Errorf("scopesMatch(%v, %v) = %v, want %v",
t.Errorf("ScopesMatch(%v, %v) = %v, want %v",
tt.stored, tt.desired, result, tt.expected)
}
})
+42 -5
View File
@@ -17,6 +17,38 @@ import (
"github.com/bluesky-social/indigo/atproto/syntax"
)
// permissionSetExpansions maps lexicon IDs to their expanded scope format.
// These must match the collections defined in lexicons/io/atcr/authFullApp.json
// Collections are sorted alphabetically for consistent comparison with PDS-expanded scopes.
var permissionSetExpansions = map[string]string{
"io.atcr.authFullApp": "repo?" +
"collection=io.atcr.manifest&" +
"collection=io.atcr.repo.page&" +
"collection=io.atcr.sailor.profile&" +
"collection=io.atcr.sailor.star&" +
"collection=io.atcr.tag",
}
// ExpandIncludeScopes expands any "include:" prefixed scopes to their full form
// by looking up the corresponding permission-set in the embedded lexicon files.
// For example, "include:io.atcr.authFullApp" expands to "repo?collection=io.atcr.manifest&..."
func ExpandIncludeScopes(scopes []string) []string {
var expanded []string
for _, scope := range scopes {
if strings.HasPrefix(scope, "include:") {
lexiconID := strings.TrimPrefix(scope, "include:")
if exp, ok := permissionSetExpansions[lexiconID]; ok {
expanded = append(expanded, exp)
} else {
expanded = append(expanded, scope) // Keep original if unknown
}
} else {
expanded = append(expanded, scope)
}
}
return expanded
}
// NewClientApp creates an indigo OAuth ClientApp with ATCR-specific configuration
// Automatically configures confidential client for production deployments
// keyPath specifies where to store/load the OAuth client P-256 key (ignored for localhost)
@@ -97,19 +129,24 @@ func GetDefaultScopes(did string) []string {
}
// ScopesMatch checks if two scope lists are equivalent (order-independent)
// Returns true if both lists contain the same scopes, regardless of order
// Returns true if both lists contain the same scopes, regardless of order.
// Expands any "include:" prefixed scopes in the desired list before comparing,
// since the PDS returns expanded scopes in the stored session.
func ScopesMatch(stored, desired []string) bool {
// Expand any include: scopes in desired before comparing
expandedDesired := ExpandIncludeScopes(desired)
// Handle nil/empty cases
if len(stored) == 0 && len(desired) == 0 {
if len(stored) == 0 && len(expandedDesired) == 0 {
return true
}
if len(stored) != len(desired) {
if len(stored) != len(expandedDesired) {
return false
}
// Build map of desired scopes for O(1) lookup
desiredMap := make(map[string]bool, len(desired))
for _, scope := range desired {
desiredMap := make(map[string]bool, len(expandedDesired))
for _, scope := range expandedDesired {
desiredMap[scope] = true
}