mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-05-28 11:00:22 +00:00
fix oauth scope mismatch
This commit is contained in:
25
.air.hold.toml
Normal file
25
.air.hold.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -buildvcs=false -o ./tmp/atcr-hold ./cmd/hold"
|
||||
entrypoint = ["./tmp/atcr-hold"]
|
||||
include_ext = ["go"]
|
||||
exclude_dir = ["bin", "tmp", "vendor", "deploy", "docs", ".git", "dist", "pkg/appview"]
|
||||
exclude_regex = ["_test\\.go$"]
|
||||
delay = 1000
|
||||
stop_on_error = true
|
||||
send_interrupt = true
|
||||
kill_delay = 500
|
||||
|
||||
[log]
|
||||
time = false
|
||||
|
||||
[color]
|
||||
main = "blue"
|
||||
watcher = "magenta"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,6 +18,7 @@ pkg/appview/static/js/htmx.min.js
|
||||
pkg/appview/static/js/lucide.min.js
|
||||
|
||||
# IDE
|
||||
.zed/
|
||||
.claude/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# Development image with Air hot reload
|
||||
# Build: docker build -f Dockerfile.dev -t atcr-appview-dev .
|
||||
# Run: docker run -v $(pwd):/app -p 5000:5000 atcr-appview-dev
|
||||
# Build: docker build -f Dockerfile.dev -t atcr-dev .
|
||||
# Run: docker run -v $(pwd):/app -p 5000:5000 atcr-dev
|
||||
FROM docker.io/golang:1.25.4-trixie
|
||||
|
||||
ARG AIR_CONFIG=.air.toml
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV AIR_CONFIG=${AIR_CONFIG}
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends sqlite3 libsqlite3-dev curl && \
|
||||
@@ -17,5 +20,4 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# For development: source mounted as volume, Air handles builds
|
||||
EXPOSE 5000
|
||||
CMD ["air", "-c", ".air.toml"]
|
||||
CMD ["sh", "-c", "air -c ${AIR_CONFIG}"]
|
||||
|
||||
@@ -114,7 +114,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
|
||||
slog.Debug("Base URL for OAuth", "base_url", baseURL)
|
||||
if testMode {
|
||||
slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution and transition:generic scope")
|
||||
slog.Info("TEST_MODE enabled - will use HTTP for local DID resolution")
|
||||
}
|
||||
|
||||
// Create OAuth client app (automatically configures confidential client for production)
|
||||
@@ -123,11 +123,6 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create OAuth client app: %w", err)
|
||||
}
|
||||
if testMode {
|
||||
slog.Info("Using OAuth scopes with transition:generic (test mode)")
|
||||
} else {
|
||||
slog.Info("Using OAuth scopes with RPC scope (production mode)")
|
||||
}
|
||||
|
||||
// Invalidate sessions with mismatched scopes on startup
|
||||
// This ensures all users have the latest required scopes after deployment
|
||||
@@ -383,7 +378,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
logoURI := cfg.Server.BaseURL + "/web-app-manifest-192x192.png"
|
||||
policyURI := cfg.Server.BaseURL + "/privacy"
|
||||
tosURI := cfg.Server.BaseURL + "/terms"
|
||||
|
||||
|
||||
metadata := config.ClientMetadata()
|
||||
metadata.ClientName = &cfg.Server.ClientName
|
||||
metadata.ClientURI = &cfg.Server.BaseURL
|
||||
|
||||
@@ -110,7 +110,7 @@ func main() {
|
||||
fmt.Println("=== Calculating from hold layer records ===")
|
||||
fmt.Println("NOTE: May undercount app-password users due to layer record bug")
|
||||
fmt.Println(" Use --from-manifests for more accurate results")
|
||||
|
||||
|
||||
userUsage, err = calculateFromLayerRecords(baseURL, holdDID)
|
||||
}
|
||||
|
||||
|
||||
@@ -57,12 +57,18 @@ services:
|
||||
# Storage config comes from env_file (STORAGE_DRIVER, AWS_*, S3_*)
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.hold
|
||||
image: atcr-hold:latest
|
||||
dockerfile: Dockerfile.dev
|
||||
args:
|
||||
AIR_CONFIG: .air.hold.toml
|
||||
image: atcr-hold-dev:latest
|
||||
container_name: atcr-hold
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
# Mount source code for Air hot reload
|
||||
- .:/app
|
||||
# Cache go modules between rebuilds
|
||||
- go-mod-cache:/go/pkg/mod
|
||||
# PDS data (carstore SQLite + signing keys)
|
||||
- atcr-hold:/var/lib/atcr-hold
|
||||
restart: unless-stopped
|
||||
|
||||
1399
docs/ADMIN_PANEL.md
Normal file
1399
docs/ADMIN_PANEL.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user