Files
at-container-registry/pkg/auth/oauth/client_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

323 lines
12 KiB
Go

package oauth
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"testing"
"github.com/bluesky-social/indigo/atproto/atclient"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
)
func TestNewClientApp(t *testing.T) {
keyPath := t.TempDir() + "/oauth-key.bin"
store := oauth.NewMemStore()
baseURL := "http://localhost:5000"
scopes := GetDefaultScopes("*")
clientApp, err := NewClientApp(baseURL, store, scopes, keyPath, "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
if clientApp == nil {
t.Fatal("Expected non-nil clientApp")
}
if clientApp.Dir == nil {
t.Error("Expected directory to be set")
}
}
func TestNewClientAppWithCustomScopes(t *testing.T) {
keyPath := t.TempDir() + "/oauth-key.bin"
store := oauth.NewMemStore()
baseURL := "http://localhost:5000"
scopes := []string{"atproto", "custom:scope"}
clientApp, err := NewClientApp(baseURL, store, scopes, keyPath, "AT Container Registry")
if err != nil {
t.Fatalf("NewClientApp() error = %v", err)
}
if clientApp == nil {
t.Fatal("Expected non-nil clientApp")
}
// Verify clientApp was created successfully
// (Note: indigo's oauth.ClientApp doesn't expose scopes directly,
// but we can verify it was created without error)
if clientApp.Dir == nil {
t.Error("Expected directory to be set")
}
}
func TestMissingScopes(t *testing.T) {
fullApp := "repo?collection=io.atcr.manifest&collection=io.atcr.repo.page&collection=io.atcr.sailor.profile&collection=io.atcr.sailor.star&collection=io.atcr.tag"
tests := []struct {
name string
granted []string
desired []string
missing []string
}{
{
name: "exact match",
granted: []string{"atproto", "blob:image/png"},
desired: []string{"atproto", "blob:image/png"},
},
{
name: "different order",
granted: []string{"blob:image/png", "atproto"},
desired: []string{"atproto", "blob:image/png"},
},
{
name: "both empty",
granted: nil,
desired: []string{},
},
{
name: "extra grants are fine",
granted: []string{"atproto", "blob:image/png", "repo:app.bsky.feed.post", "extra"},
desired: []string{"atproto", "blob:image/png"},
},
{
name: "missing atproto",
granted: []string{"blob:image/png"},
desired: []string{"atproto", "blob:image/png"},
missing: []string{"atproto"},
},
{
name: "missing blob",
granted: []string{"atproto"},
desired: []string{"atproto", "blob:image/png"},
missing: []string{"blob:image/png"},
},
{
name: "blob wildcard covers a type",
granted: []string{"atproto", "blob:image/*"},
desired: []string{"atproto", "blob:image/png", "blob:image/*"},
},
{
name: "blob */* covers anything",
granted: []string{"atproto", "blob:*/*"},
desired: []string{"atproto", "blob:application/vnd.oci.image.manifest.v1+json"},
},
{
name: "blob wildcard of another type does not cover",
granted: []string{"atproto", "blob:image/*"},
desired: []string{"atproto", "blob:application/json"},
missing: []string{"blob:application/json"},
},
{
name: "include expanded by the PDS",
granted: []string{"atproto", fullApp},
desired: []string{"atproto", "include:io.atcr.authFullApp"},
},
{
name: "include echoed back unexpanded",
granted: []string{"atproto", "include:io.atcr.authFullApp"},
desired: []string{"atproto", "include:io.atcr.authFullApp"},
},
{
name: "include expanded into one scope per collection, any order",
granted: []string{"atproto",
"repo:io.atcr.tag", "repo:io.atcr.sailor.star", "repo:io.atcr.manifest",
"repo:io.atcr.sailor.profile", "repo:io.atcr.repo.page"},
desired: []string{"atproto", "include:io.atcr.authFullApp"},
},
{
// The tranquil.farm case: the consent screen dropped the manifest
// collection, so the push failed at putRecord.
name: "collection missing from the permission set",
granted: []string{"atproto",
"repo?collection=io.atcr.repo.page&collection=io.atcr.sailor.profile&collection=io.atcr.sailor.star&collection=io.atcr.tag"},
desired: []string{"atproto", "include:io.atcr.authFullApp"},
missing: []string{"repo:io.atcr.manifest?action=create&action=update&action=delete"},
},
{
name: "action subset granted",
granted: []string{"atproto", "repo:io.atcr.manifest?action=create"},
desired: []string{"atproto", "repo:io.atcr.manifest"},
missing: []string{"repo:io.atcr.manifest?action=update&action=delete"},
},
{
name: "wildcard collection covers",
granted: []string{"atproto", "repo:*"},
desired: []string{"atproto", "include:io.atcr.authFullApp"},
},
{
name: "transition:generic covers repo, blob and rpc",
granted: []string{"atproto", "transition:generic"},
desired: []string{"atproto", "include:io.atcr.authFullApp", "blob:image/*", "rpc:com.atproto.repo.getRecord?aud=*"},
},
{
name: "rpc match",
granted: []string{"atproto", "rpc:com.atproto.repo.getRecord?aud=*"},
desired: []string{"atproto", "rpc:com.atproto.repo.getRecord?aud=*"},
},
{
name: "rpc with narrower aud does not cover aud=*",
granted: []string{"atproto", "rpc:com.atproto.repo.getRecord?aud=did:web:hold.example.com%23atcr_hold"},
desired: []string{"atproto", "rpc:com.atproto.repo.getRecord?aud=*"},
missing: []string{"rpc:com.atproto.repo.getRecord?aud=*"},
},
{
name: "default scopes fully granted",
granted: append([]string{fullApp}, withoutInclude(GetDefaultScopes("*"))...),
desired: GetDefaultScopes("*"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := MissingScopes(tt.granted, tt.desired)
if !slices.Equal(got, tt.missing) {
t.Errorf("MissingScopes(%v, %v) = %v, want %v", tt.granted, tt.desired, got, tt.missing)
}
if ScopesCover(tt.granted, tt.desired) != (len(tt.missing) == 0) {
t.Errorf("ScopesCover disagrees with MissingScopes")
}
})
}
}
func withoutInclude(scopes []string) []string {
var out []string
for _, s := range scopes {
if !strings.HasPrefix(s, "include:") {
out = append(out, s)
}
}
return out
}
// ----------------------------------------------------------------------------
// Session Management (Refresher) Tests
// ----------------------------------------------------------------------------
func TestNewRefresher(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)
}
refresher := NewRefresher(clientApp)
if refresher == nil {
t.Fatal("Expected non-nil refresher")
}
if refresher.clientApp == nil {
t.Error("Expected clientApp to be set")
}
}
func TestRefresher_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)
}
refresher := NewRefresher(clientApp)
// Test that SetUISessionStore doesn't panic with nil
// Full mock implementation requires implementing the interface
refresher.SetUISessionStore(nil)
// Verify nil is accepted
if refresher.uiSessionStore != nil {
t.Error("Expected UI session store to be nil after setting nil")
}
}
// ----------------------------------------------------------------------------
// Refresh-cancellation regression tests
// ----------------------------------------------------------------------------
func TestIsSessionInvalidError(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"plain canceled", context.Canceled, false},
{"wrapped canceled", fmt.Errorf("token refresh failed: %w", context.Canceled), false},
{"wrapped deadline", fmt.Errorf("fetch: %w", context.DeadlineExceeded), false},
// Even if the message mentions an auth string, cancellation wins.
{"canceled with auth-ish text", fmt.Errorf("invalid_grant: %w", context.Canceled), false},
{"api error 401", &atclient.APIError{StatusCode: 401}, true},
{"api error InvalidGrant", &atclient.APIError{StatusCode: 400, Name: "InvalidGrant"}, true},
{"api error InvalidToken", &atclient.APIError{StatusCode: 400, Name: "InvalidToken"}, true},
{"api error 500", &atclient.APIError{StatusCode: 500, Name: "InternalServerError"}, false},
// ExpiredToken means "refresh me", not "revoked". Treating it as a dead
// session signs the user out of every UI session over an ordinary
// access-token expiry that a refresh would have fixed.
{"api error ExpiredToken is refreshable, not dead", &atclient.APIError{StatusCode: 400, Name: "ExpiredToken"}, false},
// Transient upstream failures must never evict: these are the shapes the
// service-token path now wraps as APIErrors.
{"api error 502", &atclient.APIError{StatusCode: 502, Name: ""}, false},
{"api error 429", &atclient.APIError{StatusCode: 429, Name: ""}, false},
{"api error 500 html body", &atclient.APIError{StatusCode: 500, Name: "", Message: "<html>bad gateway</html>"}, false},
// A revoked session reported as 401 with an atproto name — the case the
// service-token path was previously flattening into an unmatchable string.
{"api error 401 InvalidToken", &atclient.APIError{StatusCode: 401, Name: "InvalidToken"}, true},
// The refresh-replay failure arrives as a plain wrapped string from indigo.
{"plain invalid_grant string", errors.New("failed to refresh OAuth tokens: token refresh failed (HTTP 400): invalid_grant"), true},
{"plain invalid_token string", errors.New("auth server request failed (HTTP 401): invalid_token"), true},
{"connection refused", errors.New(`Post "https://pds.example.com/oauth/token": dial tcp: connection refused`), false},
{"generic 500", errors.New("token refresh failed (HTTP 500): server exploded"), false},
// A live session the PDS didn't grant this permission to. Deleting it
// signed the user out mid-push, and logging in again at the same PDS
// got the same grant, so they looped (tranquil.farm, 2026-09-21).
{"api error 403 InsufficientScope is alive", &atclient.APIError{StatusCode: 403, Name: "InsufficientScope", Message: "Insufficient scope to create records in io.atcr.manifest"}, false},
{"wrapped InsufficientScope string", errors.New("putRecord failed: API request failed (HTTP 403): InsufficientScope: insufficient_scope"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsSessionInvalidError(tt.err); got != tt.want {
t.Errorf("IsSessionInvalidError(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
// TestIsAuthError covers the gate DoWithSession uses to delete a session after
// a failed PDS call. It must agree with IsSessionInvalidError.
func TestIsAuthError(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"canceled", fmt.Errorf("putRecord failed: %w", context.Canceled), false},
{"api error 401", &atclient.APIError{StatusCode: 401}, true},
{"api error InvalidToken", &atclient.APIError{StatusCode: 400, Name: "InvalidToken"}, true},
{"api error 403 InsufficientScope", fmt.Errorf("putRecord failed: %w", &atclient.APIError{StatusCode: 403, Name: "InsufficientScope", Message: "Insufficient scope to create records in io.atcr.manifest"}), false},
{"insufficient_scope string", errors.New("request failed: insufficient_scope"), false},
{"plain invalid_token string", errors.New("auth server request failed (HTTP 401): invalid_token"), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isAuthError(tt.err); got != tt.want {
t.Errorf("isAuthError(%v) = %v, want %v", tt.err, got, tt.want)
}
if tt.err != nil && isAuthError(tt.err) != IsSessionInvalidError(tt.err) {
t.Errorf("isAuthError and IsSessionInvalidError disagree on %v", tt.err)
}
})
}
}