mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-28 21:15:33 +00:00
deps: upgrade every module; gate loopback OAuth tests on testmode
go get -u across the root, scanner, and deploy modules, then tidy. The credential helpers pin atcr.io v0.1.4 for standalone go install and are left alone (go work sync tried to strip that pin; reverted). Direct upgrades in the root: indigo 20260901 to 20260903, aws-sdk-go-v2 core 1.45.1 to 1.47.0 with config, credentials, and s3 alongside, x/crypto 0.55 to 0.57, x/net, x/sync, x/sys, x/image, klauspost/compress 1.20, go-containerregistry 0.22.1, goldmark 1.8.6, regclient 0.11.6 (pinned only by the integration-tagged package, so the bulk upgrade skipped it). Scanner and deploy had no direct updates; their indirect sets moved. The indigo delta is a hardening series: identity.DefaultDirectory and oauth.NewClientApp now carry an SSRF-guarded transport that refuses loopback and private ranges, did:web and well-known bodies are size capped, all auth-server endpoints must be HTTPS URLs, and MST decoding validates PrefixLen on untrusted nodes. Production is unaffected. The testmode seam in pkg/atproto absorbs the rest: a probe confirmed an untagged build now refuses 127.0.0.1 with indigo's unsafe-address error and a tagged build dials through. Two OAuth tests drove the real client against httptest servers on loopback and failed untagged after the bump; three siblings in the same fixtures passed only because the refused dial happened to satisfy a "transient error" assertion. All five, with their fixtures and fake stores, move under //go:build testmode in sibling files. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
0080957a21
commit
dcee8f6a62
@@ -4,16 +4,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atclient"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
"github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
func TestNewClientApp(t *testing.T) {
|
||||
@@ -171,199 +165,6 @@ func TestRefresher_SetUISessionStore(t *testing.T) {
|
||||
// Refresh-cancellation regression tests
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// fakeAuthStore is a ClientAuthStore whose SaveSession honors context
|
||||
// cancellation, so a test fails if session persistence runs on a canceled
|
||||
// context. It also implements GetLatestSessionForDID (the sessionGetter
|
||||
// extension the Refresher requires).
|
||||
type fakeAuthStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]oauth.ClientSessionData // keyed by DID
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func newFakeAuthStore() *fakeAuthStore {
|
||||
return &fakeAuthStore{sessions: make(map[string]oauth.ClientSessionData)}
|
||||
}
|
||||
|
||||
func (s *fakeAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.sessions[did.String()]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("session not found")
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[sess.AccountDID.String()] = sess
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, did.String())
|
||||
s.deleted = append(s.deleted, did.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
func (s *fakeAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
|
||||
return nil
|
||||
}
|
||||
func (s *fakeAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthStore) GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.sessions[did]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("no session for DID")
|
||||
}
|
||||
return &sess, sess.SessionID, nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthStore) refreshToken(did string) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.sessions[did].RefreshToken
|
||||
}
|
||||
|
||||
func (s *fakeAuthStore) deletedDIDs() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.deleted...)
|
||||
}
|
||||
|
||||
// spyUISessionStore records DeleteByDID calls.
|
||||
type spyUISessionStore struct {
|
||||
mu sync.Mutex
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (s *spyUISessionStore) Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) {
|
||||
return "", fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (s *spyUISessionStore) DeleteByDID(did string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.deleted = append(s.deleted, did)
|
||||
}
|
||||
|
||||
// seedSession stores a session for did pointing at the given resource server
|
||||
// and token endpoint, with a freshly generated DPoP key.
|
||||
func seedSession(t *testing.T, store *fakeAuthStore, did, hostURL, tokenEndpoint string) {
|
||||
t.Helper()
|
||||
key, err := atcrypto.GeneratePrivateKeyP256()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsedDID, err := syntax.ParseDID(did)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.sessions[did] = oauth.ClientSessionData{
|
||||
AccountDID: parsedDID,
|
||||
SessionID: "test-session",
|
||||
HostURL: hostURL,
|
||||
AuthServerURL: hostURL,
|
||||
AuthServerTokenEndpoint: tokenEndpoint,
|
||||
Scopes: []string{"atproto"},
|
||||
AccessToken: "old-access",
|
||||
RefreshToken: "old-refresh",
|
||||
DPoPPrivateKeyMultibase: key.Multibase(),
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRefresher(t *testing.T, store oauth.ClientAuthStore) *Refresher {
|
||||
t.Helper()
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, []string{"atproto"}, "", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewRefresher(clientApp)
|
||||
}
|
||||
|
||||
// TestDoWithSession_RefreshSurvivesRequestCancellation reproduces the
|
||||
// production incident: the caller's context is canceled while the token
|
||||
// refresh is in flight (after the auth server has already rotated the
|
||||
// refresh token). The rotated token MUST be persisted and the session MUST
|
||||
// NOT be deleted, or the next refresh fails with invalid_grant "Refresh
|
||||
// token replayed" and the user is signed out.
|
||||
func TestDoWithSession_RefreshSurvivesRequestCancellation(t *testing.T) {
|
||||
const did = "did:web:refresh-cancel.example.com"
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
// Resource endpoint: reject the stale access token so DoWithAuth triggers
|
||||
// a refresh; accept the rotated one.
|
||||
mux.HandleFunc("/xrpc/test.resource", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "DPoP new-access" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token", error_description="expired"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
// Token endpoint: simulate the Docker client hanging up mid-refresh by
|
||||
// canceling the caller's context BEFORE responding, then return rotated
|
||||
// tokens (the auth server has already committed the rotation by then).
|
||||
mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
cancel()
|
||||
fmt.Fprintf(w, `{"sub":%q,"access_token":"new-access","refresh_token":"new-refresh"}`, did)
|
||||
})
|
||||
|
||||
store := newFakeAuthStore()
|
||||
seedSession(t, store, did, srv.URL, srv.URL+"/oauth/token")
|
||||
refresher := newTestRefresher(t, store)
|
||||
uiStore := &spyUISessionStore{}
|
||||
refresher.SetUISessionStore(uiStore)
|
||||
|
||||
err := refresher.DoWithSession(ctx, did, func(session *oauth.ClientSession) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/xrpc/test.resource", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := session.DoWithAuth(session.Client, req, syntax.NSID("com.atproto.server.getServiceAuth"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return nil
|
||||
})
|
||||
|
||||
// The overall operation may fail (the post-refresh retry of the resource
|
||||
// request runs on the canceled inbound context) — that is fine, Docker
|
||||
// retries. What must hold is that the rotated refresh token was saved and
|
||||
// the session survived.
|
||||
if got := store.refreshToken(did); got != "new-refresh" {
|
||||
t.Errorf("rotated refresh token not persisted: got %q, want %q (op err: %v)", got, "new-refresh", err)
|
||||
}
|
||||
if deleted := store.deletedDIDs(); len(deleted) != 0 {
|
||||
t.Errorf("session was deleted: %v", deleted)
|
||||
}
|
||||
if len(uiStore.deleted) != 0 {
|
||||
t.Errorf("UI session was invalidated: %v", uiStore.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSessionInvalidError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//go:build testmode
|
||||
|
||||
// This test drives the real OAuth client against a fake PDS on loopback, which only a testmode build can reach.
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
"github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// seedSession stores a session for did pointing at the given resource server
|
||||
// and token endpoint, with a freshly generated DPoP key.
|
||||
func seedSession(t *testing.T, store *fakeAuthStore, did, hostURL, tokenEndpoint string) {
|
||||
t.Helper()
|
||||
key, err := atcrypto.GeneratePrivateKeyP256()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsedDID, err := syntax.ParseDID(did)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.sessions[did] = oauth.ClientSessionData{
|
||||
AccountDID: parsedDID,
|
||||
SessionID: "test-session",
|
||||
HostURL: hostURL,
|
||||
AuthServerURL: hostURL,
|
||||
AuthServerTokenEndpoint: tokenEndpoint,
|
||||
Scopes: []string{"atproto"},
|
||||
AccessToken: "old-access",
|
||||
RefreshToken: "old-refresh",
|
||||
DPoPPrivateKeyMultibase: key.Multibase(),
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRefresher(t *testing.T, store oauth.ClientAuthStore) *Refresher {
|
||||
t.Helper()
|
||||
clientApp, err := NewClientApp("http://localhost:5000", store, []string{"atproto"}, "", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewRefresher(clientApp)
|
||||
}
|
||||
|
||||
// TestDoWithSession_RefreshSurvivesRequestCancellation reproduces the
|
||||
// production incident: the caller's context is canceled while the token
|
||||
// refresh is in flight (after the auth server has already rotated the
|
||||
// refresh token). The rotated token MUST be persisted and the session MUST
|
||||
// NOT be deleted, or the next refresh fails with invalid_grant "Refresh
|
||||
// token replayed" and the user is signed out.
|
||||
func TestDoWithSession_RefreshSurvivesRequestCancellation(t *testing.T) {
|
||||
const did = "did:web:refresh-cancel.example.com"
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
// Resource endpoint: reject the stale access token so DoWithAuth triggers
|
||||
// a refresh; accept the rotated one.
|
||||
mux.HandleFunc("/xrpc/test.resource", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") == "DPoP new-access" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token", error_description="expired"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
|
||||
// Token endpoint: simulate the Docker client hanging up mid-refresh by
|
||||
// canceling the caller's context BEFORE responding, then return rotated
|
||||
// tokens (the auth server has already committed the rotation by then).
|
||||
mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
cancel()
|
||||
fmt.Fprintf(w, `{"sub":%q,"access_token":"new-access","refresh_token":"new-refresh"}`, did)
|
||||
})
|
||||
|
||||
store := newFakeAuthStore()
|
||||
seedSession(t, store, did, srv.URL, srv.URL+"/oauth/token")
|
||||
refresher := newTestRefresher(t, store)
|
||||
uiStore := &spyUISessionStore{}
|
||||
refresher.SetUISessionStore(uiStore)
|
||||
|
||||
err := refresher.DoWithSession(ctx, did, func(session *oauth.ClientSession) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/xrpc/test.resource", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := session.DoWithAuth(session.Client, req, syntax.NSID("com.atproto.server.getServiceAuth"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return nil
|
||||
})
|
||||
|
||||
// The overall operation may fail (the post-refresh retry of the resource
|
||||
// request runs on the canceled inbound context) — that is fine, Docker
|
||||
// retries. What must hold is that the rotated refresh token was saved and
|
||||
// the session survived.
|
||||
if got := store.refreshToken(did); got != "new-refresh" {
|
||||
t.Errorf("rotated refresh token not persisted: got %q, want %q (op err: %v)", got, "new-refresh", err)
|
||||
}
|
||||
if deleted := store.deletedDIDs(); len(deleted) != 0 {
|
||||
t.Errorf("session was deleted: %v", deleted)
|
||||
}
|
||||
if len(uiStore.deleted) != 0 {
|
||||
t.Errorf("UI session was invalidated: %v", uiStore.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeAuthStore is a ClientAuthStore whose SaveSession honors context
|
||||
// cancellation, so a test fails if session persistence runs on a canceled
|
||||
// context. It also implements GetLatestSessionForDID (the sessionGetter
|
||||
// extension the Refresher requires).
|
||||
type fakeAuthStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]oauth.ClientSessionData // keyed by DID
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func newFakeAuthStore() *fakeAuthStore {
|
||||
return &fakeAuthStore{sessions: make(map[string]oauth.ClientSessionData)}
|
||||
}
|
||||
|
||||
func (s *fakeAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.sessions[did.String()]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("session not found")
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[sess.AccountDID.String()] = sess
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, did.String())
|
||||
s.deleted = append(s.deleted, did.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
func (s *fakeAuthStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
|
||||
return nil
|
||||
}
|
||||
func (s *fakeAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthStore) GetLatestSessionForDID(ctx context.Context, did string) (*oauth.ClientSessionData, string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.sessions[did]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("no session for DID")
|
||||
}
|
||||
return &sess, sess.SessionID, nil
|
||||
}
|
||||
|
||||
func (s *fakeAuthStore) refreshToken(did string) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.sessions[did].RefreshToken
|
||||
}
|
||||
|
||||
func (s *fakeAuthStore) deletedDIDs() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.deleted...)
|
||||
}
|
||||
|
||||
// spyUISessionStore records DeleteByDID calls.
|
||||
type spyUISessionStore struct {
|
||||
mu sync.Mutex
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (s *spyUISessionStore) Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) {
|
||||
return "", fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (s *spyUISessionStore) DeleteByDID(did string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.deleted = append(s.deleted, did)
|
||||
}
|
||||
@@ -2,19 +2,11 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
indigo_oauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
func TestGetOrFetchServiceToken_NilRefresher(t *testing.T) {
|
||||
@@ -42,183 +34,6 @@ func TestGetOrFetchServiceToken_NilRefresher(t *testing.T) {
|
||||
// Session-deletion gating tests (refresh-cancellation incident regression)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// stubAuthStore is a minimal indigo ClientAuthStore + GetLatestSessionForDID
|
||||
// implementation for driving a real Refresher against httptest servers.
|
||||
type stubAuthStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]indigo_oauth.ClientSessionData
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func newStubAuthStore() *stubAuthStore {
|
||||
return &stubAuthStore{sessions: make(map[string]indigo_oauth.ClientSessionData)}
|
||||
}
|
||||
|
||||
func (s *stubAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*indigo_oauth.ClientSessionData, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.sessions[did.String()]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("session not found")
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
func (s *stubAuthStore) SaveSession(ctx context.Context, sess indigo_oauth.ClientSessionData) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[sess.AccountDID.String()] = sess
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, did.String())
|
||||
s.deleted = append(s.deleted, did.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*indigo_oauth.AuthRequestData, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
func (s *stubAuthStore) SaveAuthRequestInfo(ctx context.Context, info indigo_oauth.AuthRequestData) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAuthStore) GetLatestSessionForDID(ctx context.Context, did string) (*indigo_oauth.ClientSessionData, string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.sessions[did]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("no session for DID")
|
||||
}
|
||||
return &sess, sess.SessionID, nil
|
||||
}
|
||||
|
||||
func (s *stubAuthStore) deletedDIDs() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.deleted...)
|
||||
}
|
||||
|
||||
type recordingUIStore struct {
|
||||
mu sync.Mutex
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (s *recordingUIStore) Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) {
|
||||
return "", fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (s *recordingUIStore) DeleteByDID(did string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.deleted = append(s.deleted, did)
|
||||
}
|
||||
|
||||
// setupServiceTokenScenario builds a fake PDS whose getServiceAuth endpoint
|
||||
// always 401s with invalid_token (forcing a token refresh) and whose token
|
||||
// endpoint behavior is supplied by the caller, plus a Refresher seeded with a
|
||||
// session for did.
|
||||
func setupServiceTokenScenario(t *testing.T, did string, tokenHandler http.HandlerFunc) (*oauth.Refresher, *stubAuthStore, *recordingUIStore, string) {
|
||||
t.Helper()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
mux.HandleFunc(atproto.ServerGetServiceAuth, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token", error_description="expired"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
mux.HandleFunc("/oauth/token", tokenHandler)
|
||||
|
||||
key, err := atcrypto.GeneratePrivateKeyP256()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsedDID, err := syntax.ParseDID(did)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := newStubAuthStore()
|
||||
store.sessions[did] = indigo_oauth.ClientSessionData{
|
||||
AccountDID: parsedDID,
|
||||
SessionID: "test-session",
|
||||
HostURL: srv.URL,
|
||||
AuthServerURL: srv.URL,
|
||||
AuthServerTokenEndpoint: srv.URL + "/oauth/token",
|
||||
Scopes: []string{"atproto"},
|
||||
AccessToken: "old-access",
|
||||
RefreshToken: "old-refresh",
|
||||
DPoPPrivateKeyMultibase: key.Multibase(),
|
||||
}
|
||||
|
||||
clientApp, err := oauth.NewClientApp("http://localhost:5000", store, []string{"atproto"}, "", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refresher := oauth.NewRefresher(clientApp)
|
||||
uiStore := &recordingUIStore{}
|
||||
refresher.SetUISessionStore(uiStore)
|
||||
|
||||
return refresher, store, uiStore, srv.URL
|
||||
}
|
||||
|
||||
// TestGetOrFetchServiceToken_TransientErrorKeepsSession asserts that a
|
||||
// transient refresh failure (5xx from the auth server) does NOT delete the
|
||||
// OAuth session or invalidate UI sessions.
|
||||
func TestGetOrFetchServiceToken_TransientErrorKeepsSession(t *testing.T) {
|
||||
const did = "did:web:transient.example.com"
|
||||
refresher, store, uiStore, pdsURL := setupServiceTokenScenario(t, did,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
_, err := GetOrFetchServiceToken(context.Background(), refresher, did, "did:web:hold1.example.com", pdsURL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from failed service token fetch")
|
||||
}
|
||||
if deleted := store.deletedDIDs(); len(deleted) != 0 {
|
||||
t.Errorf("session deleted on transient error: %v", deleted)
|
||||
}
|
||||
if len(uiStore.deleted) != 0 {
|
||||
t.Errorf("UI sessions invalidated on transient error: %v", uiStore.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetOrFetchServiceToken_InvalidGrantDeletesSession asserts that a
|
||||
// genuine invalid_grant (refresh token replayed/revoked) still deletes the
|
||||
// session and invalidates UI sessions.
|
||||
func TestGetOrFetchServiceToken_InvalidGrantDeletesSession(t *testing.T) {
|
||||
const did = "did:web:invalidgrant.example.com"
|
||||
refresher, store, uiStore, pdsURL := setupServiceTokenScenario(t, did,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"Refresh token replayed"}`))
|
||||
})
|
||||
|
||||
_, err := GetOrFetchServiceToken(context.Background(), refresher, did, "did:web:hold2.example.com", pdsURL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from invalid_grant")
|
||||
}
|
||||
if deleted := store.deletedDIDs(); len(deleted) != 1 || deleted[0] != did {
|
||||
t.Errorf("expected session deletion for %s, got: %v", did, deleted)
|
||||
}
|
||||
if len(uiStore.deleted) != 1 || uiStore.deleted[0] != did {
|
||||
t.Errorf("expected UI session invalidation for %s, got: %v", did, uiStore.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStaleBearerToken(t *testing.T) {
|
||||
stale := []string{"ExpiredToken", "InvalidToken", "ExpiredSession", "InvalidSession"}
|
||||
for _, name := range stale {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//go:build testmode
|
||||
|
||||
// These tests drive the real OAuth client against a fake PDS on loopback, which only a testmode build can reach.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
indigo_oauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
)
|
||||
|
||||
// setupServiceTokenScenario builds a fake PDS whose getServiceAuth endpoint
|
||||
// always 401s with invalid_token (forcing a token refresh) and whose token
|
||||
// endpoint behavior is supplied by the caller, plus a Refresher seeded with a
|
||||
// session for did.
|
||||
func setupServiceTokenScenario(t *testing.T, did string, tokenHandler http.HandlerFunc) (*oauth.Refresher, *stubAuthStore, *recordingUIStore, string) {
|
||||
t.Helper()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
mux.HandleFunc(atproto.ServerGetServiceAuth, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token", error_description="expired"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
})
|
||||
mux.HandleFunc("/oauth/token", tokenHandler)
|
||||
|
||||
key, err := atcrypto.GeneratePrivateKeyP256()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsedDID, err := syntax.ParseDID(did)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
store := newStubAuthStore()
|
||||
store.sessions[did] = indigo_oauth.ClientSessionData{
|
||||
AccountDID: parsedDID,
|
||||
SessionID: "test-session",
|
||||
HostURL: srv.URL,
|
||||
AuthServerURL: srv.URL,
|
||||
AuthServerTokenEndpoint: srv.URL + "/oauth/token",
|
||||
Scopes: []string{"atproto"},
|
||||
AccessToken: "old-access",
|
||||
RefreshToken: "old-refresh",
|
||||
DPoPPrivateKeyMultibase: key.Multibase(),
|
||||
}
|
||||
|
||||
clientApp, err := oauth.NewClientApp("http://localhost:5000", store, []string{"atproto"}, "", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refresher := oauth.NewRefresher(clientApp)
|
||||
uiStore := &recordingUIStore{}
|
||||
refresher.SetUISessionStore(uiStore)
|
||||
|
||||
return refresher, store, uiStore, srv.URL
|
||||
}
|
||||
|
||||
// TestGetOrFetchServiceToken_TransientErrorKeepsSession asserts that a
|
||||
// transient refresh failure (5xx from the auth server) does NOT delete the
|
||||
// OAuth session or invalidate UI sessions.
|
||||
func TestGetOrFetchServiceToken_TransientErrorKeepsSession(t *testing.T) {
|
||||
const did = "did:web:transient.example.com"
|
||||
refresher, store, uiStore, pdsURL := setupServiceTokenScenario(t, did,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
_, err := GetOrFetchServiceToken(context.Background(), refresher, did, "did:web:hold1.example.com", pdsURL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from failed service token fetch")
|
||||
}
|
||||
if deleted := store.deletedDIDs(); len(deleted) != 0 {
|
||||
t.Errorf("session deleted on transient error: %v", deleted)
|
||||
}
|
||||
if len(uiStore.deleted) != 0 {
|
||||
t.Errorf("UI sessions invalidated on transient error: %v", uiStore.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetOrFetchServiceToken_InvalidGrantDeletesSession asserts that a
|
||||
// genuine invalid_grant (refresh token replayed/revoked) still deletes the
|
||||
// session and invalidates UI sessions.
|
||||
func TestGetOrFetchServiceToken_InvalidGrantDeletesSession(t *testing.T) {
|
||||
const did = "did:web:invalidgrant.example.com"
|
||||
refresher, store, uiStore, pdsURL := setupServiceTokenScenario(t, did,
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"Refresh token replayed"}`))
|
||||
})
|
||||
|
||||
_, err := GetOrFetchServiceToken(context.Background(), refresher, did, "did:web:hold2.example.com", pdsURL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error from invalid_grant")
|
||||
}
|
||||
if deleted := store.deletedDIDs(); len(deleted) != 1 || deleted[0] != did {
|
||||
t.Errorf("expected session deletion for %s, got: %v", did, deleted)
|
||||
}
|
||||
if len(uiStore.deleted) != 1 || uiStore.deleted[0] != did {
|
||||
t.Errorf("expected UI session invalidation for %s, got: %v", did, uiStore.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// stubAuthStore is a minimal indigo ClientAuthStore + GetLatestSessionForDID
|
||||
// implementation for driving a real Refresher against httptest servers.
|
||||
type stubAuthStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]indigo_oauth.ClientSessionData
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func newStubAuthStore() *stubAuthStore {
|
||||
return &stubAuthStore{sessions: make(map[string]indigo_oauth.ClientSessionData)}
|
||||
}
|
||||
|
||||
func (s *stubAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*indigo_oauth.ClientSessionData, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.sessions[did.String()]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("session not found")
|
||||
}
|
||||
return &sess, nil
|
||||
}
|
||||
|
||||
func (s *stubAuthStore) SaveSession(ctx context.Context, sess indigo_oauth.ClientSessionData) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[sess.AccountDID.String()] = sess
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAuthStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, did.String())
|
||||
s.deleted = append(s.deleted, did.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAuthStore) GetAuthRequestInfo(ctx context.Context, state string) (*indigo_oauth.AuthRequestData, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
func (s *stubAuthStore) SaveAuthRequestInfo(ctx context.Context, info indigo_oauth.AuthRequestData) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubAuthStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAuthStore) GetLatestSessionForDID(ctx context.Context, did string) (*indigo_oauth.ClientSessionData, string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
sess, ok := s.sessions[did]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("no session for DID")
|
||||
}
|
||||
return &sess, sess.SessionID, nil
|
||||
}
|
||||
|
||||
func (s *stubAuthStore) deletedDIDs() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.deleted...)
|
||||
}
|
||||
|
||||
type recordingUIStore struct {
|
||||
mu sync.Mutex
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (s *recordingUIStore) Create(did, handle, pdsEndpoint string, duration time.Duration) (string, error) {
|
||||
return "", fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (s *recordingUIStore) DeleteByDID(did string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.deleted = append(s.deleted, did)
|
||||
}
|
||||
Reference in New Issue
Block a user