From 21b6f6301ae8db05abe3be5909768607d3ac5130 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Tue, 7 Apr 2026 21:32:51 -0500 Subject: [PATCH] allow dids on docker login --- pkg/auth/token/handler.go | 44 ++++++++++++++ pkg/auth/token/handler_test.go | 104 +++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/pkg/auth/token/handler.go b/pkg/auth/token/handler.go index eb7aaf7..52c15b0 100644 --- a/pkg/auth/token/handler.go +++ b/pkg/auth/token/handler.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "net/url" "strings" "time" @@ -141,6 +142,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Reconstruct DID usernames that were mangled by BasicAuth's colon split + username, password = parseBasicAuthDID(username, password) + slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password)) // Parse query parameters @@ -271,3 +275,43 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, resp) } + +// parseBasicAuthDID fixes DID usernames that are mangled by HTTP Basic Auth. +// Basic Auth splits on the first colon, so "did:plc:abc123" as a username +// produces username="did" and the rest gets prepended to the password. +// +// This handles two cases: +// 1. URL-encoded DIDs (did%3Aplc%3Aabc123) — decoded back to did:plc:abc123 +// 2. Raw DIDs — reconstructed from the mangled username + password +func parseBasicAuthDID(username, password string) (string, string) { + // Case 1: URL-encoded DID (e.g., did%3Aplc%3Aabc123) + if decoded, err := url.QueryUnescape(username); err == nil && decoded != username { + if strings.HasPrefix(decoded, "did:") { + return decoded, password + } + } + + // Case 2: Raw DID was split by BasicAuth on the first colon + // username="did", password="plc::" or "web::" + if username != "did" { + return username, password + } + + if strings.HasPrefix(password, "plc:") { + // did:plc: — the ID is a single segment (no colons) + // password = "plc::" + rest := strings.TrimPrefix(password, "plc:") + if idx := strings.Index(rest, ":"); idx > 0 { + return "did:plc:" + rest[:idx], rest[idx+1:] + } + } else if strings.HasPrefix(password, "web:") { + // did:web: — hostname uses dots not colons + // password = "web::" + rest := strings.TrimPrefix(password, "web:") + if idx := strings.Index(rest, ":"); idx > 0 { + return "did:web:" + rest[:idx], rest[idx+1:] + } + } + + return username, password +} diff --git a/pkg/auth/token/handler_test.go b/pkg/auth/token/handler_test.go index f4f9452..643732f 100644 --- a/pkg/auth/token/handler_test.go +++ b/pkg/auth/token/handler_test.go @@ -642,3 +642,107 @@ func TestHandler_ServeHTTP_PullOnlyAccess(t *testing.T) { t.Errorf("Expected status %d for pull-only access, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String()) } } + +func TestParseBasicAuthDID(t *testing.T) { + tests := []struct { + name string + username string + password string + wantUsername string + wantPassword string + }{ + { + name: "normal handle unchanged", + username: "alice.bsky.social", + password: "mypassword", + wantUsername: "alice.bsky.social", + wantPassword: "mypassword", + }, + { + name: "URL-encoded did:plc", + username: "did%3Aplc%3Aabc123", + password: "mypassword", + wantUsername: "did:plc:abc123", + wantPassword: "mypassword", + }, + { + name: "URL-encoded did:web", + username: "did%3Aweb%3Aexample.com", + password: "mypassword", + wantUsername: "did:web:example.com", + wantPassword: "mypassword", + }, + { + name: "raw did:plc mangled by BasicAuth", + username: "did", + password: "plc:abc123:mypassword", + wantUsername: "did:plc:abc123", + wantPassword: "mypassword", + }, + { + name: "raw did:web mangled by BasicAuth", + username: "did", + password: "web:example.com:mypassword", + wantUsername: "did:web:example.com", + wantPassword: "mypassword", + }, + { + name: "raw did:plc with device secret", + username: "did", + password: "plc:e3kzdezk5gsirzh7eoqplc64:atcr_device_abc123", + wantUsername: "did:plc:e3kzdezk5gsirzh7eoqplc64", + wantPassword: "atcr_device_abc123", + }, + { + name: "username did but not a DID method", + username: "did", + password: "something:else", + wantUsername: "did", + wantPassword: "something:else", + }, + { + name: "username did with no colon in rest", + username: "did", + password: "plc:abc123", + wantUsername: "did", + wantPassword: "plc:abc123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotUsername, gotPassword := parseBasicAuthDID(tt.username, tt.password) + if gotUsername != tt.wantUsername { + t.Errorf("username = %q, want %q", gotUsername, tt.wantUsername) + } + if gotPassword != tt.wantPassword { + t.Errorf("password = %q, want %q", gotPassword, tt.wantPassword) + } + }) + } +} + +func TestTokenHandler_DIDBasicAuth(t *testing.T) { + // Test that a DID passed as BasicAuth username works through the full handler + deviceStore, database := setupTestDeviceStore(t) + keyPath := getSharedTestKey(t) + issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute) + if err != nil { + t.Fatalf("NewIssuer() error = %v", err) + } + deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:abc123", "alice.bsky.social") + + handler := NewHandler(issuer, deviceStore) + + // Simulate what BasicAuth() does when username is "did:plc:abc123" + // It splits on first colon: username="did", password="plc:abc123:" + req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull", nil) + req.SetBasicAuth("did:plc:abc123", deviceSecret) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d for DID BasicAuth, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String()) + } +}