Files
at-container-registry/pkg/auth/oauth/server_test.go
T
Evan JarrettandClaude Opus 5.5 c39606905e appview: a missing OAuth scope is a 403, not a dead session (#30)
A PDS that grants fewer scopes than requested produced a session that
worked until the first write it wasn't allowed, which the PDS answered
with a 403. We classified that 403 as a revoked session, deleted it, and
returned a 500 "unknown error"; Docker's retries then failed with "no
session found". Logging in again got the same partial grant, so the user
looped (#30, an older tranquil PDS that left a scope off its consent
screen).

- Login refuses a partial grant. The callback checks the granted scopes
  cover what was requested and, if not, deletes the new session and shows
  a page listing what's missing. It runs before the old-session cleanup,
  so a refused login leaves a working session alone. "Try again" goes
  back through the login page so return_to (e.g. the device page) holds.
- MissingScopes compares scopes by what they grant, not by spelling: an
  include: expanded or echoed back, collections split or reordered,
  wildcards, transition:generic. Extra grants are fine. It replaces the
  exact-match ScopesMatch at login, on resume, and in the boot sweep, which
  now evicts only sessions missing something.
- A 403 never deletes a session. InsufficientScope comes out of
  isAuthError and IsSessionInvalidError, and isOAuthError no longer treats
  every 403 as dead. PDSes spell this differently (tranquil:
  InsufficientScope, the reference PDS: ScopeMissingError), so nothing
  keys on the name.
- A PDS 403 on a manifest or tag write reaches Docker as DENIED with the
  PDS's own reason. The UI write handlers (star, tag and manifest delete,
  repo avatar and description) answer 403 with the reason too.
- The OAuth error, missing-permissions and success pages render in the
  site layout via an injected PageRenderer; pkg/auth/oauth keeps its
  inline templates as a fallback.

Verified live against a reference PDS with a forced partial grant: login
refused, an existing session kept, a push denied twice on the same
session with the PDS's message, the boot sweep evicting the partial
session, and a full login pushing normally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:26:23 -05:00

340 lines
9.5 KiB
Go

package oauth
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
)
func TestNewServer(t *testing.T) {
// Create a basic OAuth app for testing
store := oauth.NewMemStore()
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
server := NewServer(clientApp)
if server == nil {
t.Fatal("Expected non-nil server")
}
if server.clientApp == nil {
t.Error("Expected clientApp to be set")
}
}
func TestServer_SetRefresher(t *testing.T) {
store := oauth.NewMemStore()
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
server := NewServer(clientApp)
refresher := NewRefresher(clientApp)
server.SetRefresher(refresher)
if server.refresher == nil {
t.Error("Expected refresher to be set")
}
}
func TestServer_SetPostAuthCallback(t *testing.T) {
store := oauth.NewMemStore()
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
server := NewServer(clientApp)
// Set callback with correct signature
server.SetPostAuthCallback(func(ctx context.Context, did, handle, pds, sessionID string) error {
return nil
})
if server.postAuthCallback == nil {
t.Error("Expected post-auth callback to be set")
}
}
func TestServer_SetUISessionStore(t *testing.T) {
store := oauth.NewMemStore()
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
server := NewServer(clientApp)
mockStore := &mockUISessionStore{}
server.SetUISessionStore(mockStore)
if server.uiSessionStore == nil {
t.Error("Expected UI session store to be set")
}
}
// Mock implementations for testing
type mockUISessionStore struct {
createFunc func(did, handle, pdsEndpoint string, duration time.Duration) (string, error)
createWithOAuthFunc func(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error)
deleteByDIDFunc func(did string)
}
func (m *mockUISessionStore) Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) {
if m.createFunc != nil {
return m.createFunc(did, handle, pdsEndpoint, duration)
}
return "mock-session-id", nil
}
func (m *mockUISessionStore) CreateWithOAuth(did, handle, pdsEndpoint, oauthSessionID string, duration time.Duration) (string, error) {
if m.createWithOAuthFunc != nil {
return m.createWithOAuthFunc(did, handle, pdsEndpoint, oauthSessionID, duration)
}
return "mock-session-id-with-oauth", nil
}
func (m *mockUISessionStore) DeleteByDID(did string) {
if m.deleteByDIDFunc != nil {
m.deleteByDIDFunc(did)
}
}
// ServeAuthorize tests
func TestServer_ServeAuthorize_MissingHandle(t *testing.T) {
store := oauth.NewMemStore()
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
server := NewServer(clientApp)
req := httptest.NewRequest(http.MethodGet, "/auth/oauth/authorize", nil)
w := httptest.NewRecorder()
server.ServeAuthorize(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, resp.StatusCode)
}
}
func TestServer_ServeAuthorize_InvalidMethod(t *testing.T) {
store := oauth.NewMemStore()
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
server := NewServer(clientApp)
req := httptest.NewRequest(http.MethodPost, "/auth/oauth/authorize?handle=alice.bsky.social", nil)
w := httptest.NewRecorder()
server.ServeAuthorize(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("Expected status %d, got %d", http.StatusMethodNotAllowed, resp.StatusCode)
}
}
// ServeCallback tests
func TestServer_ServeCallback_InvalidMethod(t *testing.T) {
store := oauth.NewMemStore()
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
server := NewServer(clientApp)
req := httptest.NewRequest(http.MethodPost, "/auth/oauth/callback", nil)
w := httptest.NewRecorder()
server.ServeCallback(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("Expected status %d, got %d", http.StatusMethodNotAllowed, resp.StatusCode)
}
}
func TestServer_ServeCallback_OAuthError(t *testing.T) {
store := oauth.NewMemStore()
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
server := NewServer(clientApp)
req := httptest.NewRequest(http.MethodGet, "/auth/oauth/callback?error=access_denied&error_description=User+denied+access", nil)
w := httptest.NewRecorder()
server.ServeCallback(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, resp.StatusCode)
}
body := w.Body.String()
if !strings.Contains(body, "access_denied") {
t.Errorf("Expected error message to contain 'access_denied', got: %s", body)
}
}
func TestServer_ServeCallback_WithPostAuthCallback(t *testing.T) {
store := oauth.NewMemStore()
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
server := NewServer(clientApp)
callbackInvoked := false
server.SetPostAuthCallback(func(ctx context.Context, d, h, pds, sessionID string) error {
callbackInvoked = true
// Note: We can't verify the exact DID here since we're not running a full OAuth flow
// This test verifies that the callback mechanism works
return nil
})
// Verify callback is set
if server.postAuthCallback == nil {
t.Error("Expected post-auth callback to be set")
}
// For this test, we're verifying the callback is configured correctly
// A full integration test would require mocking the entire OAuth flow
if callbackInvoked {
t.Error("Callback should not be invoked without OAuth completion")
}
}
func TestServer_ServeCallback_UIFlow_SessionCreationLogic(t *testing.T) {
sessionCreated := false
uiStore := &mockUISessionStore{
createWithOAuthFunc: func(d, h, pds, oauthSessionID string, duration time.Duration) (string, error) {
sessionCreated = true
return "ui-session-123", nil
},
}
store := oauth.NewMemStore()
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
server := NewServer(clientApp)
server.SetUISessionStore(uiStore)
// Verify UI session store is set
if server.uiSessionStore == nil {
t.Error("Expected UI session store to be set")
}
// For this test, we're verifying the UI session store is configured correctly
// A full integration test would require mocking the entire OAuth flow with callback
if sessionCreated {
t.Error("Session should not be created without OAuth completion")
}
}
func TestServer_RenderError(t *testing.T) {
store := oauth.NewMemStore()
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
server := NewServer(clientApp)
w := httptest.NewRecorder()
server.renderError(w, httptest.NewRequest(http.MethodGet, "/auth/oauth/callback", nil), "Test error message")
resp := w.Result()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, resp.StatusCode)
}
body := w.Body.String()
if !strings.Contains(body, "Test error message") {
t.Errorf("Expected error message in body, got: %s", body)
}
if !strings.Contains(body, "Authorization Failed") {
t.Errorf("Expected 'Authorization Failed' title in body, got: %s", body)
}
}
func TestServer_RenderRedirectToSettings(t *testing.T) {
store := oauth.NewMemStore()
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp("http://localhost:5000", store, scopes, "", "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
server := NewServer(clientApp)
w := httptest.NewRecorder()
server.renderRedirectToSettings(w, httptest.NewRequest(http.MethodGet, "/auth/oauth/callback", nil), "alice.bsky.social")
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, resp.StatusCode)
}
body := w.Body.String()
if !strings.Contains(body, "alice.bsky.social") {
t.Errorf("Expected handle in body, got: %s", body)
}
if !strings.Contains(body, "Authorization Successful") {
t.Errorf("Expected 'Authorization Successful' title in body, got: %s", body)
}
if !strings.Contains(body, "/settings") {
t.Errorf("Expected redirect to /settings in body, got: %s", body)
}
}