allow dids on docker login

This commit is contained in:
Evan Jarrett
2026-04-07 21:32:51 -05:00
parent 1865377b52
commit 21b6f6301a
2 changed files with 148 additions and 0 deletions
+44
View File
@@ -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:<id>:<real-password>" or "web:<host>:<real-password>"
if username != "did" {
return username, password
}
if strings.HasPrefix(password, "plc:") {
// did:plc:<base32-id> — the ID is a single segment (no colons)
// password = "plc:<id>:<real-password>"
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> — hostname uses dots not colons
// password = "web:<hostname>:<real-password>"
rest := strings.TrimPrefix(password, "web:")
if idx := strings.Index(rest, ":"); idx > 0 {
return "did:web:" + rest[:idx], rest[idx+1:]
}
}
return username, password
}
+104
View File
@@ -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:<secret>"
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())
}
}