mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 04:06:58 +00:00
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>
1223 lines
38 KiB
Go
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())
|
|
}
|
|
}
|