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