···299299 identityStr := parts[0]
300300 imageName := parts[1]
301301302302+ // Support hyphen-encoded DIDs in image paths (e.g., did-plc-abc123/repo:tag)
303303+ // OCI reference grammar doesn't allow colons in path components, so DIDs must
304304+ // be encoded with hyphens instead: did:plc:abc123 → did-plc-abc123
305305+ if decoded, ok := token.DecodeDIDFromHyphens(identityStr); ok {
306306+ identityStr = decoded
307307+ }
308308+302309 // Resolve identity to DID, handle, and PDS endpoint
303310 did, handle, pdsEndpoint, err := atproto.ResolveIdentity(ctx, identityStr)
304311 if err != nil {
+20-14
pkg/auth/token/handler.go
···66 "fmt"
77 "log/slog"
88 "net/http"
99- "net/url"
109 "strings"
1110 "time"
1211···277276}
278277279278// parseBasicAuthDID fixes DID usernames that are mangled by HTTP Basic Auth.
280280-// Basic Auth splits on the first colon, so "did:plc:abc123" as a username
281281-// produces username="did" and the rest gets prepended to the password.
282279//
283280// This handles two cases:
284284-// 1. URL-encoded DIDs (did%3Aplc%3Aabc123) — decoded back to did:plc:abc123
285285-// 2. Raw DIDs — reconstructed from the mangled username + password
281281+// 1. Hyphen-encoded DIDs (did-plc-abc123) — converted to did:plc:abc123.
282282+// This is the recommended format for tools like helm that reject colons in usernames.
283283+// 2. Raw DIDs split by BasicAuth — "did:plc:abc123" gets split on the first colon
284284+// into username="did", password="plc:abc123:<real-password>". Reconstructed here.
286285func parseBasicAuthDID(username, password string) (string, string) {
287287- // Case 1: URL-encoded DID (e.g., did%3Aplc%3Aabc123)
288288- if decoded, err := url.QueryUnescape(username); err == nil && decoded != username {
289289- if strings.HasPrefix(decoded, "did:") {
290290- return decoded, password
291291- }
286286+ // Case 1: Hyphen-encoded DID (e.g., did-plc-abc123 or did-web-example.com)
287287+ if did, ok := DecodeDIDFromHyphens(username); ok {
288288+ return did, password
292289 }
293290294291 // Case 2: Raw DID was split by BasicAuth on the first colon
···298295 }
299296300297 if strings.HasPrefix(password, "plc:") {
301301- // did:plc:<base32-id> — the ID is a single segment (no colons)
302302- // password = "plc:<id>:<real-password>"
303298 rest := strings.TrimPrefix(password, "plc:")
304299 if idx := strings.Index(rest, ":"); idx > 0 {
305300 return "did:plc:" + rest[:idx], rest[idx+1:]
306301 }
307302 } else if strings.HasPrefix(password, "web:") {
308308- // did:web:<hostname> — hostname uses dots not colons
309309- // password = "web:<hostname>:<real-password>"
310303 rest := strings.TrimPrefix(password, "web:")
311304 if idx := strings.Index(rest, ":"); idx > 0 {
312305 return "did:web:" + rest[:idx], rest[idx+1:]
···315308316309 return username, password
317310}
311311+312312+// DecodeDIDFromHyphens converts a hyphen-encoded DID back to colon-separated form.
313313+// "did-plc-abc123" → "did:plc:abc123", "did-web-example.com" → "did:web:example.com"
314314+// Returns the decoded DID and true if the input matched, or ("", false) otherwise.
315315+func DecodeDIDFromHyphens(s string) (string, bool) {
316316+ if strings.HasPrefix(s, "did-plc-") {
317317+ return "did:plc:" + strings.TrimPrefix(s, "did-plc-"), true
318318+ }
319319+ if strings.HasPrefix(s, "did-web-") {
320320+ return "did:web:" + strings.TrimPrefix(s, "did-web-"), true
321321+ }
322322+ return "", false
323323+}