Files
at-container-registry/pkg/auth/token/handler_test.go
T
Evan JarrettandClaude Opus 5 b25aee336b auth: serve the OAuth2 POST form at /auth/token
The route was registered GET-only, so containerd and Docker, which try the
OAuth2 POST endpoint first whenever they hold a secret, ate a 405 and retried
on the GET form. Every authenticated pull paid two auth round trips, and in the
production logs the POST share of token traffic grew from 0.5% to 38% over six
weeks as more clients pulled from k8s with basic-auth imagePullSecrets.

Serve both specs on the same path. After credentials and scope are extracted
the two paths are identical, so this is an extraction branch plus a form-shaped
error writer.

Only grant_type=password is supported and no refresh token is issued: the
registry JWT's lifetime is pinned to the AppView<->hold service-auth, so a
refresh token would be a fourth long-lived credential with its own storage and
revocation. Clients handle its absence by continuing to use the credential they
already hold.

The refresh grant is refused with 401 rather than the 400 that RFC 6749 5.2
prescribes. containerd sends that grant only when it has no username, which is
the same condition that disables its 405 fallback, so a 400 would hard-fail
those clients. 401 is on its retry list and routes them to the GET form, where
a device secret authenticates off the password alone. That shape previously had
no working path at all.

resolveService now takes the requested service as an argument, since it arrives
in the query string on GET and in the form body on POST.

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

1223 lines
38 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_NoAuth(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)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
// Check for WWW-Authenticate header
if w.Header().Get("WWW-Authenticate") == "" {
t.Error("Expected WWW-Authenticate header")
}
}
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())
}
}