Files
at-container-registry/pkg/auth/token/handler_test.go
T
Evan JarrettandClaude Opus 5 5aa13abdc2 auth: make anonymous pull work, and let the hold decide it
a7569a7 added credential-less pulls of public images. Three things about it
were wrong, all of them in how the appview handled the decision that belongs
to the hold.

**Scope handling was all-or-nothing.** IsPullOnlyScope required every
requested action to already be "pull", but clients routinely ask for more
than the operation needs — pull,push is common for a plain read, and some
ask for pull,push,delete up front. Those were rejected and challenged,
leaving a credential-less client no way to pull even a public image, which
is the entire feature. NarrowToPullOnly drops the write actions and issues a
token carrying "pull" and nothing else. Granting a subset is what the
distribution token spec expects. The allowlist property is preserved: "pull"
is the only action that survives, and "*" is deliberately not expanded into
it, since a wildcard request is not evidence the caller wants a read.

**The appview-side read gate was inert.** checkReadAccess passed p.ctx.DID,
the DID of the repository *owner*, not the requester. Any non-empty DID
satisfies a private hold's check, and the owner's is never empty, so it asked
"may the owner read their own hold", answered yes, and admitted everyone.
Worse, CheckReadAccessWithCaptain admitted any authenticated DID to a private
hold at all, on an explicitly-MVP assumption that holding a DID was close
enough to being a sailor. Every doc says otherwise (docs/hold.md:109 "Crew
with blob:read", CLAUDE.md:140, docs/BYOS.md:280) and so does the hold
(ValidateBlobReadAccess: owner, or crew carrying blob:read/blob:write). It
now takes isCrew and requires owner-or-crew, and callers only pay for the
crew lookup when it can change the answer — a public hold or an anonymous
caller is decided by the captain record alone. Nothing here loosens access;
it brings the local gate into agreement with the authority.

**Denials could not reach the client.** distribution's blobHandler.GetBlob
maps everything except ErrBlobUnknown to ErrorCodeUnknown, so a 401 raised
in the blob store left as a 500 — misreporting an auth failure as a server
fault, and giving BearerChallenge no 401 to attach WWW-Authenticate to, so
Docker was told "server error" instead of being prompted for credentials.
Clients that retry 5xx looped: 4.1s per case in the matrix, now 0.01s. The
check moves to Repository(), where an errcode.Error is passed through
verbatim by the registry app — the same mechanism a7569a7 used for
NAME_UNKNOWN. It fails open on a lookup error, since the hold is the
authority and a transient failure should not break public pulls.

Removes auth.allow_anonymous_pull. It could only ever withhold — captain.Public
is what grants — so it was a second flag for a decision the hold already owns,
and gating it appview-side was never the intent. Layer bytes 307 straight to
S3, so the appview is not even in the path whose cost might have justified an
operator-side lever.

Tests: TestAuthMatrix only ever ran against a public hold, and its pull cases
never fetched a layer — crane.Pull is lazy and img.Digest() needs only the
manifest, which ATCR serves from the user's PDS where it is world-readable, so
no pull row in the matrix touched blob authorization at all. Pulls now
materialize layer bytes, and testharness.WithPrivateHold plus
TestAuthMatrixPrivateHold cover public:false + allow_all_crew:true — the
production shape, where anyone with an account pulls and pushes and anonymous
gets nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:43:32 -05:00

1347 lines
42 KiB
Go

package token
import (
"context"
"crypto/tls"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/auth"
)
// Shared test key to avoid generating a new RSA key for each test
// Generating a 2048-bit RSA key takes ~0.15s, so reusing one key saves ~4.5s for 32 tests
var (
sharedTestKeyPath string
sharedTestKeyOnce sync.Once
sharedTestKeyDir string
)
// getSharedTestKey returns a shared RSA key and its file path for all tests
// The key is generated once and reused across all tests in this package
func getSharedTestKey(t *testing.T) string {
sharedTestKeyOnce.Do(func() {
// Create a persistent temp directory for the shared key
var err error
sharedTestKeyDir, err = os.MkdirTemp("", "atcr-test-keys-*")
if err != nil {
t.Fatalf("Failed to create test key directory: %v", err)
}
sharedTestKeyPath = filepath.Join(sharedTestKeyDir, "test-key.pem")
// Generate the key once (this is the expensive operation we want to avoid repeating)
// This will also generate the certificate via NewIssuer
_, err = NewIssuer(sharedTestKeyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("Failed to generate shared test key: %v", err)
}
})
return sharedTestKeyPath
}
// setupTestDeviceStore creates an in-memory SQLite database for testing
func setupTestDeviceStore(t *testing.T) (*db.DeviceStore, *sql.DB) {
testDB, err := db.InitDB(":memory:", db.LibsqlConfig{})
if err != nil {
t.Fatalf("Failed to initialize test database: %v", err)
}
return db.NewDeviceStore(testDB), testDB
}
// createTestDevice creates a device in the test database and returns its secret
// Requires both DeviceStore and sql.DB to insert user record first
func createTestDevice(t *testing.T, store *db.DeviceStore, testDB *sql.DB, did, handle string) string {
// First create a user record (required by foreign key constraint)
user := &db.User{
DID: did,
Handle: handle,
PDSEndpoint: "https://pds.example.com",
}
err := db.UpsertUser(testDB, user)
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
// Create pending authorization
pending, err := store.CreatePendingAuth("Test Device", "127.0.0.1", "test-agent")
if err != nil {
t.Fatalf("Failed to create pending auth: %v", err)
}
// Approve the pending authorization
secret, err := store.ApprovePending(pending.UserCode, did, handle)
if err != nil {
t.Fatalf("Failed to approve pending auth: %v", err)
}
return secret
}
func TestNewHandler(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
if handler == nil {
t.Fatal("Expected non-nil handler")
}
if handler.issuer == nil {
t.Error("Expected issuer to be set")
}
if handler.validator == nil {
t.Error("Expected validator to be initialized")
}
}
func TestHandler_SetPostAuthCallback(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
handler.SetPostAuthCallback(func(ctx context.Context, did, handle, pds, token string) error {
return nil
})
if handler.postAuthCallback == nil {
t.Error("Expected post-auth callback to be set")
}
}
func TestHandler_ServeHTTP_AnonymousPull(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil) // allowAnonymousPull defaults true
// No credentials, pull-only scope for someone else's repo.
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:bob.bsky.social/myapp:pull", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status %d for anonymous pull, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String())
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if resp.Token == "" {
t.Fatal("Expected non-empty anonymous token")
}
// The anonymous token carries no subject and the anonymous auth method.
if sub := ExtractSubject(resp.Token); sub != "" {
t.Errorf("Expected empty subject for anonymous token, got %q", sub)
}
if am := ExtractAuthMethod(resp.Token); am != AuthMethodAnonymous {
t.Errorf("Expected auth method %q, got %q", AuthMethodAnonymous, am)
}
access := ExtractAccess(resp.Token)
if len(access) != 1 || access[0].Name != "bob.bsky.social/myapp" {
t.Errorf("Expected pull access for bob.bsky.social/myapp, got %+v", access)
}
}
func TestHandler_ServeHTTP_AnonymousPing(t *testing.T) {
// The /v2/ ping requests a token with no scope (empty access). Anonymous
// issuance must grant it so the ping succeeds without credentials.
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status %d for anonymous ping, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String())
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if resp.Token == "" {
t.Error("Expected non-empty token for anonymous ping")
}
if len(ExtractAccess(resp.Token)) != 0 {
t.Errorf("Expected empty access for ping token, got %+v", ExtractAccess(resp.Token))
}
}
func TestHandler_ServeHTTP_AnonymousPushChallenged(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
// A scope with no pull component is nothing an anonymous caller can be
// granted, so it still draws the standard challenge. "*" is included
// deliberately: it is not treated as a request to read.
for _, action := range []string{"push", "delete", "push,delete", "*"} {
t.Run(action, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:bob.bsky.social/myapp:"+action, nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status %d for anonymous %s, got %d. Body: %s", http.StatusUnauthorized, action, w.Code, w.Body.String())
}
if w.Header().Get("WWW-Authenticate") == "" {
t.Error("Expected WWW-Authenticate header on anonymous write challenge")
}
})
}
}
// TestHandler_ServeHTTP_AnonymousMixedScopeNarrowedToPull pins the behavior
// clients actually depend on: many request pull,push (or pull,push,delete) for
// an operation that only reads. Demanding the request already be pull-only made
// anonymous pull unreachable for those clients, so the write actions are dropped
// and a pull-only token is issued instead of a challenge.
func TestHandler_ServeHTTP_AnonymousMixedScopeNarrowedToPull(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
for _, action := range []string{"pull,push", "pull,push,delete", "push,pull"} {
t.Run(action, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:bob.bsky.social/myapp:"+action, nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status %d for anonymous %s, got %d. Body: %s", http.StatusOK, action, w.Code, w.Body.String())
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
granted := ExtractAccess(resp.Token)
if len(granted) != 1 {
t.Fatalf("Expected exactly one access entry, got %+v", granted)
}
if diff := len(granted[0].Actions); diff != 1 || granted[0].Actions[0] != "pull" {
t.Errorf("Expected granted actions [pull], got %v", granted[0].Actions)
}
if granted[0].Name != "bob.bsky.social/myapp" {
t.Errorf("Expected repository name preserved, got %q", granted[0].Name)
}
})
}
}
func TestHandler_ServeHTTP_WrongMethod(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
// GET and POST are both real token specs; anything else is not.
req := httptest.NewRequest(http.MethodPut, "/auth/token", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("Expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
}
// newOAuthTokenRequest builds a POST request in the OAuth2 token spec's
// form-encoded shape.
func newOAuthTokenRequest(form url.Values) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/auth/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req
}
func TestHandler_ServeHTTP_OAuthPost_UnsupportedGrant(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
// We issue no refresh tokens, so the refresh grant is rejected. The status
// must stay 401 and not the RFC's 400: clients that send this grant have no
// username, so 401 is the only answer that still routes them to the GET form
// instead of failing them outright.
req := newOAuthTokenRequest(url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {"whatever"},
"service": {"registry"},
})
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
var body oauthError
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("response body is not an OAuth error object: %v", err)
}
if body.Error != "unsupported_grant_type" {
t.Errorf("error = %q, want unsupported_grant_type", body.Error)
}
}
func TestHandler_ServeHTTP_OAuthPost_MissingCredentials(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
// 401 is one of the statuses clients retry on the GET form, so an empty
// body must not come back as anything else.
req := newOAuthTokenRequest(url.Values{"grant_type": {"password"}})
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
}
func TestHandler_ServeHTTP_OAuthPost_DeviceAuth_Valid(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:user123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Same credentials and scope as the GET device-auth test, delivered in the
// form body instead. The two paths must issue equivalent tokens.
req := newOAuthTokenRequest(url.Values{
"grant_type": {"password"},
"username": {"alice.bsky.social"},
"password": {deviceSecret},
"service": {"registry"},
"scope": {"repository:alice.bsky.social/myapp:pull,push"},
})
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status %d, got %d (body: %s)", http.StatusOK, w.Code, w.Body.String())
}
var resp TokenResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if resp.Token == "" {
t.Error("Expected non-empty token")
}
// Docker's OAuth2 client reads access_token, not the legacy token field.
if resp.AccessToken != resp.Token {
t.Error("access_token must mirror token for OAuth2 clients")
}
}
func TestHandler_ServeHTTP_DeviceAuth_Valid(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
// Create real device store with in-memory database
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:user123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Create request with device secret
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull,push", nil)
req.SetBasicAuth("alice.bsky.social", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
t.Logf("Response body: %s", w.Body.String())
}
// Parse response
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if resp.Token == "" {
t.Error("Expected non-empty token")
}
if resp.AccessToken == "" {
t.Error("Expected non-empty access_token")
}
if resp.ExpiresIn == 0 {
t.Error("Expected non-zero expires_in")
}
// Verify token and access_token are the same
if resp.Token != resp.AccessToken {
t.Error("Expected token and access_token to be the same")
}
}
func TestHandler_ServeHTTP_DeviceAuth_Invalid(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
// Create device store but don't add any devices
deviceStore, _ := setupTestDeviceStore(t)
handler := NewHandler(issuer, deviceStore)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry", nil)
req.SetBasicAuth("alice", "atcr_device_invalid")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
}
func TestHandler_ServeHTTP_InvalidScope(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:user123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Invalid scope format (missing colons)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=invalid", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "invalid scope") {
t.Errorf("Expected error message to contain 'invalid scope', got: %s", body)
}
}
func TestHandler_ServeHTTP_AccessDenied(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Try to push to someone else's repository
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:bob.bsky.social/myapp:push", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("Expected status %d, got %d", http.StatusForbidden, w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "access denied") {
t.Errorf("Expected error message to contain 'access denied', got: %s", body)
}
}
func TestHandler_ServeHTTP_WithCallback(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:user123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Set callback to track if it's called
callbackCalled := false
handler.SetPostAuthCallback(func(ctx context.Context, did, handle, pds, token string) error {
callbackCalled = true
// Note: We don't check the values because callback shouldn't be called for device auth
return nil
})
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// Note: Callback is only called for app password auth, not device auth
// So callbackCalled should be false for this test
if callbackCalled {
t.Error("Expected callback NOT to be called for device auth")
}
}
func TestHandler_ServeHTTP_MultipleScopes(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Multiple scopes separated by space (URL encoded)
scopes := "repository%3Aalice.bsky.social%2Fapp1%3Apull+repository%3Aalice.bsky.social%2Fapp2%3Apush"
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope="+scopes, nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String())
}
}
func TestHandler_ServeHTTP_WildcardScope(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Wildcard scope should be allowed
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:*:pull,push", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String())
}
}
func TestHandler_ServeHTTP_NoScope(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// No scope parameter - should still work (empty access)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if resp.Token == "" {
t.Error("Expected non-empty token even with no scope")
}
}
func TestGetBaseURL(t *testing.T) {
tests := []struct {
name string
host string
headers map[string]string
expectedURL string
}{
{
name: "simple host",
host: "registry.example.com",
headers: map[string]string{},
expectedURL: "http://registry.example.com",
},
{
name: "with TLS",
host: "registry.example.com",
headers: map[string]string{},
expectedURL: "https://registry.example.com", // Would need TLS in request
},
{
name: "with X-Forwarded-Host",
host: "internal-host",
headers: map[string]string{
"X-Forwarded-Host": "registry.example.com",
},
expectedURL: "http://registry.example.com",
},
{
name: "with X-Forwarded-Proto",
host: "registry.example.com",
headers: map[string]string{
"X-Forwarded-Proto": "https",
},
expectedURL: "https://registry.example.com",
},
{
name: "with both forwarded headers",
host: "internal",
headers: map[string]string{
"X-Forwarded-Host": "registry.example.com",
"X-Forwarded-Proto": "https",
},
expectedURL: "https://registry.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = tt.host
for key, value := range tt.headers {
req.Header.Set(key, value)
}
// For TLS test
if tt.expectedURL == "https://registry.example.com" && len(tt.headers) == 0 {
req.TLS = &tls.ConnectionState{} // Non-nil TLS indicates HTTPS
}
baseURL := getBaseURL(req)
if baseURL != tt.expectedURL {
t.Errorf("Expected URL %q, got %q", tt.expectedURL, baseURL)
}
})
}
}
func TestTokenResponse_JSONFormat(t *testing.T) {
resp := TokenResponse{
Token: "jwt_token_here",
AccessToken: "jwt_token_here",
ExpiresIn: 900,
IssuedAt: "2025-01-01T00:00:00Z",
}
data, err := json.Marshal(resp)
if err != nil {
t.Fatalf("Failed to marshal response: %v", err)
}
// Verify JSON structure
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Failed to unmarshal JSON: %v", err)
}
if decoded["token"] != "jwt_token_here" {
t.Error("Expected token field in JSON")
}
if decoded["access_token"] != "jwt_token_here" {
t.Error("Expected access_token field in JSON")
}
if decoded["expires_in"] != float64(900) {
t.Error("Expected expires_in field in JSON")
}
if decoded["issued_at"] != "2025-01-01T00:00:00Z" {
t.Error("Expected issued_at field in JSON")
}
}
func TestHandler_ServeHTTP_AuthHeader(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
// Test with manually constructed auth header
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry", nil)
auth := base64.StdEncoding.EncodeToString([]byte("username:password"))
req.Header.Set("Authorization", "Basic "+auth)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// Should fail because we don't have valid credentials, but we're testing the header parsing
if w.Code != http.StatusUnauthorized {
t.Logf("Got status %d (this is fine, we're just testing header parsing)", w.Code)
}
}
func TestHandler_ServeHTTP_ContentType(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code)
}
contentType := w.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got %q", contentType)
}
}
func TestHandler_ServeHTTP_ExpiresIn(t *testing.T) {
keyPath := getSharedTestKey(t)
// Create issuer with specific expiration
expiration := 10 * time.Minute
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", expiration)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
expectedExpiresIn := int(expiration.Seconds())
if resp.ExpiresIn != expectedExpiresIn {
t.Errorf("Expected expires_in %d, got %d", expectedExpiresIn, resp.ExpiresIn)
}
}
func TestHandler_ServeHTTP_PullOnlyAccess(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Pull from someone else's repo should be allowed
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:bob.bsky.social/myapp:pull", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
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: "hyphen-encoded did:plc",
username: "did-plc-abc123",
password: "mypassword",
wantUsername: "did:plc:abc123",
wantPassword: "mypassword",
},
{
name: "hyphen-encoded did:web",
username: "did-web-example.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)
}
})
}
}
// stubAuthorizer is a configurable Authorizer for testing the gate.
type stubAuthorizer struct {
called bool
err error
delay time.Duration // optional sleep before returning, for parallelism tests
// Concurrency barrier — when set, Authorize closes `started` then waits on
// `partnerStarted`. Two stubs cross-wired this way both block until both
// goroutines are live, deterministically proving parallel execution.
// If the partner never starts, the wait fails after barrierTimeout.
started chan struct{}
partnerStarted chan struct{}
barrierTimeout time.Duration
}
func (s *stubAuthorizer) Authorize(_ context.Context, _, _ string, _ []auth.AccessEntry) error {
s.called = true
if s.started != nil {
close(s.started)
select {
case <-s.partnerStarted:
case <-time.After(s.barrierTimeout):
return errors.New("partner goroutine did not start — sequential execution")
}
}
if s.delay > 0 {
time.Sleep(s.delay)
}
return s.err
}
func TestHandler_Authorizer_DeniesPushScope(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
stub := &stubAuthorizer{err: errors.New("quota exceeded: 6000000000 / 5368709120 bytes used")}
handler.SetAuthorizer(stub)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull,push", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if !stub.called {
t.Fatal("expected Authorizer to be called for push-scoped token request")
}
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d. Body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "quota exceeded") {
t.Errorf("expected denial reason in body, got: %s", w.Body.String())
}
// Distribution error JSON shape: {"errors":[{"code":"DENIED","message":"..."}]}
var errBody struct {
Errors []struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(w.Body.Bytes(), &errBody); err != nil {
t.Fatalf("decode distribution error JSON: %v. Body: %s", err, w.Body.String())
}
if len(errBody.Errors) == 0 || errBody.Errors[0].Code != "DENIED" {
t.Errorf("expected errors[0].code = DENIED, got %+v", errBody.Errors)
}
}
func TestHandler_Authorizer_RunsForPullOnly(t *testing.T) {
// Pull-only scopes still go through the gate so first-time CLI users on
// a private hold can have their crew membership reconciled before the
// hold-side read check runs.
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
stub := &stubAuthorizer{} // succeeds
handler.SetAuthorizer(stub)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if !stub.called {
t.Error("Authorizer should run for pull-only scopes (drives crew reconciliation)")
}
if w.Code != http.StatusOK {
t.Errorf("expected 200 for pull-only, got %d. Body: %s", w.Code, w.Body.String())
}
}
func TestHandler_Authorizer_NilIsBackwardsCompatible(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// No authorizer set.
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull,push", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200 with no authorizer configured, got %d. Body: %s", w.Code, w.Body.String())
}
}
// stubServiceAuthFetcher is a configurable ServiceAuthFetcher for testing
// the JWT-to-service-auth lifetime binding.
type stubServiceAuthFetcher struct {
called bool
expiresAt time.Time
err error
delay time.Duration // optional sleep before returning, for parallelism tests
// Concurrency barrier — see stubAuthorizer for semantics.
started chan struct{}
partnerStarted chan struct{}
barrierTimeout time.Duration
}
func (s *stubServiceAuthFetcher) Fetch(_ context.Context, _, _ string) (time.Time, error) {
s.called = true
if s.started != nil {
close(s.started)
select {
case <-s.partnerStarted:
case <-time.After(s.barrierTimeout):
return time.Time{}, errors.New("partner goroutine did not start — sequential execution")
}
}
if s.delay > 0 {
time.Sleep(s.delay)
}
return s.expiresAt, s.err
}
func TestHandler_ServiceAuthFetcher_BindsJWTExp(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Service-auth expires in 4 minutes; JWT should be capped to that.
stub := &stubServiceAuthFetcher{expiresAt: time.Now().Add(4 * time.Minute)}
handler.SetServiceAuthFetcher(stub)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull,push", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if !stub.called {
t.Fatal("expected ServiceAuthFetcher.Fetch to be called")
}
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
// Allow a few seconds of slack for time elapsed during the request.
if resp.ExpiresIn > 240 || resp.ExpiresIn < 230 {
t.Errorf("expected expires_in ≈ 240 (capped to service-auth), got %d", resp.ExpiresIn)
}
}
func TestHandler_ServiceAuthFetcher_NoHoldUsesDefault(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 5*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Zero time + nil error = "no hold configured" — fall back to default.
stub := &stubServiceAuthFetcher{expiresAt: time.Time{}}
handler.SetServiceAuthFetcher(stub)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull,push", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 with no-hold degradation, got %d. Body: %s", w.Code, w.Body.String())
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.ExpiresIn != 300 {
t.Errorf("expected expires_in = 300 (issuer default), got %d", resp.ExpiresIn)
}
}
func TestHandler_ServiceAuthFetcher_FailureReturns503(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 5*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
stub := &stubServiceAuthFetcher{err: errors.New("PDS unreachable")}
handler.SetServiceAuthFetcher(stub)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull,push", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503 on fetch failure, got %d. Body: %s", w.Code, w.Body.String())
}
}
func TestHandler_ServiceAuthFetcher_NilUsesIssuerDefault(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 5*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// No fetcher set — backward-compat path.
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull,push", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 with nil fetcher, got %d. Body: %s", w.Code, w.Body.String())
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.ExpiresIn != 300 {
t.Errorf("expected expires_in = 300 (issuer default), got %d", resp.ExpiresIn)
}
}
func TestHandler_GateDenialPreemptsFetchError(t *testing.T) {
// When both branches error, the gate's denial wins: it's drained
// first and returns 403/DENIED before the fetcher's 503/UNAVAILABLE
// can surface. Regression guard for the drain-gate-first contract.
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 5*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
handler.SetAuthorizer(&stubAuthorizer{err: errors.New("crew membership required")})
handler.SetServiceAuthFetcher(&stubServiceAuthFetcher{err: errors.New("PDS unreachable")})
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull,push", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("expected 403 (gate denial wins), got %d. Body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "crew membership required") {
t.Errorf("expected gate's error message in body, got: %s", w.Body.String())
}
}
func TestHandler_ExpiresInCapZeroFloor(t *testing.T) {
// Regression guard — see follow-up if a non-negative floor is desired.
// When the fetcher reports a service-auth that's already expired, the
// JWT exp-cap arithmetic (`if until < issueExp { issueExp = until }`)
// lets issueExp go negative. We lock in the current observable
// behavior so a future change here is intentional, not silent.
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 5*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
handler.SetServiceAuthFetcher(&stubServiceAuthFetcher{
expiresAt: time.Now().Add(-1 * time.Minute), // already expired
})
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull,push", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 (token still issued, just with short exp), got %d. Body: %s", w.Code, w.Body.String())
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
// Current behavior: ExpiresIn is negative (≈ -60). The handler does
// not floor it. If someone introduces a floor, this assertion needs
// updating along with the docstring above.
if resp.ExpiresIn >= 0 {
t.Errorf("expected negative expires_in for already-expired service-auth, got %d", resp.ExpiresIn)
}
}
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())
}
}