A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
81
fork

Configure Feed

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

allow dids on docker login

+148
+44
pkg/auth/token/handler.go
··· 6 6 "fmt" 7 7 "log/slog" 8 8 "net/http" 9 + "net/url" 9 10 "strings" 10 11 "time" 11 12 ··· 141 142 return 142 143 } 143 144 145 + // Reconstruct DID usernames that were mangled by BasicAuth's colon split 146 + username, password = parseBasicAuthDID(username, password) 147 + 144 148 slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password)) 145 149 146 150 // Parse query parameters ··· 271 275 272 276 render.JSON(w, r, resp) 273 277 } 278 + 279 + // parseBasicAuthDID fixes DID usernames that are mangled by HTTP Basic Auth. 280 + // Basic Auth splits on the first colon, so "did:plc:abc123" as a username 281 + // produces username="did" and the rest gets prepended to the password. 282 + // 283 + // This handles two cases: 284 + // 1. URL-encoded DIDs (did%3Aplc%3Aabc123) — decoded back to did:plc:abc123 285 + // 2. Raw DIDs — reconstructed from the mangled username + password 286 + func parseBasicAuthDID(username, password string) (string, string) { 287 + // Case 1: URL-encoded DID (e.g., did%3Aplc%3Aabc123) 288 + if decoded, err := url.QueryUnescape(username); err == nil && decoded != username { 289 + if strings.HasPrefix(decoded, "did:") { 290 + return decoded, password 291 + } 292 + } 293 + 294 + // Case 2: Raw DID was split by BasicAuth on the first colon 295 + // username="did", password="plc:<id>:<real-password>" or "web:<host>:<real-password>" 296 + if username != "did" { 297 + return username, password 298 + } 299 + 300 + if strings.HasPrefix(password, "plc:") { 301 + // did:plc:<base32-id> — the ID is a single segment (no colons) 302 + // password = "plc:<id>:<real-password>" 303 + rest := strings.TrimPrefix(password, "plc:") 304 + if idx := strings.Index(rest, ":"); idx > 0 { 305 + return "did:plc:" + rest[:idx], rest[idx+1:] 306 + } 307 + } else if strings.HasPrefix(password, "web:") { 308 + // did:web:<hostname> — hostname uses dots not colons 309 + // password = "web:<hostname>:<real-password>" 310 + rest := strings.TrimPrefix(password, "web:") 311 + if idx := strings.Index(rest, ":"); idx > 0 { 312 + return "did:web:" + rest[:idx], rest[idx+1:] 313 + } 314 + } 315 + 316 + return username, password 317 + }
+104
pkg/auth/token/handler_test.go
··· 642 642 t.Errorf("Expected status %d for pull-only access, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String()) 643 643 } 644 644 } 645 + 646 + func TestParseBasicAuthDID(t *testing.T) { 647 + tests := []struct { 648 + name string 649 + username string 650 + password string 651 + wantUsername string 652 + wantPassword string 653 + }{ 654 + { 655 + name: "normal handle unchanged", 656 + username: "alice.bsky.social", 657 + password: "mypassword", 658 + wantUsername: "alice.bsky.social", 659 + wantPassword: "mypassword", 660 + }, 661 + { 662 + name: "URL-encoded did:plc", 663 + username: "did%3Aplc%3Aabc123", 664 + password: "mypassword", 665 + wantUsername: "did:plc:abc123", 666 + wantPassword: "mypassword", 667 + }, 668 + { 669 + name: "URL-encoded did:web", 670 + username: "did%3Aweb%3Aexample.com", 671 + password: "mypassword", 672 + wantUsername: "did:web:example.com", 673 + wantPassword: "mypassword", 674 + }, 675 + { 676 + name: "raw did:plc mangled by BasicAuth", 677 + username: "did", 678 + password: "plc:abc123:mypassword", 679 + wantUsername: "did:plc:abc123", 680 + wantPassword: "mypassword", 681 + }, 682 + { 683 + name: "raw did:web mangled by BasicAuth", 684 + username: "did", 685 + password: "web:example.com:mypassword", 686 + wantUsername: "did:web:example.com", 687 + wantPassword: "mypassword", 688 + }, 689 + { 690 + name: "raw did:plc with device secret", 691 + username: "did", 692 + password: "plc:e3kzdezk5gsirzh7eoqplc64:atcr_device_abc123", 693 + wantUsername: "did:plc:e3kzdezk5gsirzh7eoqplc64", 694 + wantPassword: "atcr_device_abc123", 695 + }, 696 + { 697 + name: "username did but not a DID method", 698 + username: "did", 699 + password: "something:else", 700 + wantUsername: "did", 701 + wantPassword: "something:else", 702 + }, 703 + { 704 + name: "username did with no colon in rest", 705 + username: "did", 706 + password: "plc:abc123", 707 + wantUsername: "did", 708 + wantPassword: "plc:abc123", 709 + }, 710 + } 711 + 712 + for _, tt := range tests { 713 + t.Run(tt.name, func(t *testing.T) { 714 + gotUsername, gotPassword := parseBasicAuthDID(tt.username, tt.password) 715 + if gotUsername != tt.wantUsername { 716 + t.Errorf("username = %q, want %q", gotUsername, tt.wantUsername) 717 + } 718 + if gotPassword != tt.wantPassword { 719 + t.Errorf("password = %q, want %q", gotPassword, tt.wantPassword) 720 + } 721 + }) 722 + } 723 + } 724 + 725 + func TestTokenHandler_DIDBasicAuth(t *testing.T) { 726 + // Test that a DID passed as BasicAuth username works through the full handler 727 + deviceStore, database := setupTestDeviceStore(t) 728 + keyPath := getSharedTestKey(t) 729 + issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) 730 + if err != nil { 731 + t.Fatalf("NewIssuer() error = %v", err) 732 + } 733 + deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:abc123", "alice.bsky.social") 734 + 735 + handler := NewHandler(issuer, deviceStore) 736 + 737 + // Simulate what BasicAuth() does when username is "did:plc:abc123" 738 + // It splits on first colon: username="did", password="plc:abc123:<secret>" 739 + req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull", nil) 740 + req.SetBasicAuth("did:plc:abc123", deviceSecret) 741 + w := httptest.NewRecorder() 742 + 743 + handler.ServeHTTP(w, req) 744 + 745 + if w.Code != http.StatusOK { 746 + t.Errorf("Expected status %d for DID BasicAuth, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String()) 747 + } 748 + }