mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-27 20:54:20 +00:00
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>
323 lines
12 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|