diff --git a/appview/oauth/handler.go b/appview/oauth/handler.go index bc81476a..c06c06ff 100644 --- a/appview/oauth/handler.go +++ b/appview/oauth/handler.go @@ -6,20 +6,22 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "net/http" - "slices" "strings" "time" comatproto "github.com/bluesky-social/indigo/api/atproto" "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" lexutil "github.com/bluesky-social/indigo/lex/util" xrpc "github.com/bluesky-social/indigo/xrpc" "github.com/go-chi/chi/v5" "github.com/posthog/posthog-go" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" + "tangled.org/core/appview/knotcompat" "tangled.org/core/appview/models" "tangled.org/core/consts" "tangled.org/core/idresolver" @@ -27,6 +29,8 @@ import ( "tangled.org/core/tid" ) +const knotAdminTimeout = 30 * time.Second + func (o *OAuth) Router() http.Handler { r := chi.NewRouter() @@ -91,7 +95,7 @@ func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { o.Logger.Debug("session saved successfully") - go o.addToDefaultKnot(sessData.AccountDID.String()) + go o.addToDefaultKnot(sessData.AccountDID) go o.addToDefaultSpindle(sessData.AccountDID.String()) go o.ensureTangledProfile(sessData) go o.autoClaimTnglShDomain(sessData.AccountDID.String()) @@ -181,48 +185,123 @@ func (o *OAuth) addToDefaultSpindle(did string) { l.Debug("successfully added to default spindle", "did", did) } -func (o *OAuth) addToDefaultKnot(did string) { - l := o.Logger.With("subject", did) +type onboardAction int - // use the tangled.sh app password to get an accessJwt - // and create an sh.tangled.spindle.member record with that +const ( + onboardViaAdminAPI onboardAction = iota + onboardViaRecord + onboardBlockedMissingSecret + onboardBlockedSecretSet +) - allKnots, err := o.Enforcer.GetKnotsForUser(did) - if err != nil { - l.Error("failed to get knot members for did", "err", err) - return +type defaultKnotState struct { + native bool + adminSecretSet bool +} + +func onboardActionFor(s defaultKnotState) onboardAction { + switch { + case s.native && s.adminSecretSet: + return onboardViaAdminAPI + case s.native: + return onboardBlockedMissingSecret + case s.adminSecretSet: + return onboardBlockedSecretSet + default: + return onboardViaRecord } +} + +func (o *OAuth) addToDefaultKnot(did syntax.DID) { + l := o.Logger.With("subject", did) + + ctx := context.Background() - if slices.Contains(allKnots, consts.DefaultKnot) { + if o.Acl.IsKnotMember(ctx, o.Config.Knot.Default, did.String()) { l.Warn("already a member of the default knot") return } - l.Debug("adding to default knot") - session, err := o.getAppPasswordSession() + native := knotcompat.KnotHasCapability(ctx, o.Config.Knot.Default, o.Config.Core.Dev, consts.CapKnotACL) + + switch onboardActionFor(defaultKnotState{native: native, adminSecretSet: o.Config.Knot.AdminSecret != ""}) { + case onboardViaAdminAPI: + if err := o.addMemberViaKnotAdmin(ctx, o.Config.Knot.Default, did); err != nil { + l.Error("failed to add to default knot via admin api", "err", err) + return + } + o.Acl.InvalidateMembers(o.Config.Knot.Default) + l.Debug("successfully added to default knot via admin api") + + case onboardBlockedMissingSecret: + l.Error("cannot add to default knot: knot admin secret not configured") + + case onboardBlockedSecretSet: + l.Warn("default knot probe failed, skipping legacy fallback because an admin secret is configured") + + case onboardViaRecord: + l.Debug("adding to default knot") + session, err := o.getAppPasswordSession() + if err != nil { + l.Error("failed to create session", "err", err) + return + } + + record := tangled.KnotMember{ + LexiconTypeID: tangled.KnotMemberNSID, + Subject: did.String(), + Domain: o.Config.Knot.Default, + CreatedAt: time.Now().Format(time.RFC3339), + } + + if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { + l.Error("failed to add to default knot", "err", err) + return + } + + if err := o.Enforcer.AddKnotMember(o.Config.Knot.Default, did.String()); err != nil { + l.Error("failed to set up enforcer rules", "err", err) + return + } + + l.Debug("successfully added to default knot") + } +} + +func (o *OAuth) addMemberViaKnotAdmin(ctx context.Context, knotHost string, subject syntax.DID) error { + ctx, cancel := context.WithTimeout(ctx, knotAdminTimeout) + defer cancel() + + scheme := "https://" + if o.Config.Core.Dev { + scheme = "http://" + } + endpoint := fmt.Sprintf("%s%s/admin/addMember", scheme, knotHost) + + body, err := json.Marshal(tangled.KnotAddMember_Input{Subject: subject.String()}) if err != nil { - l.Error("failed to create session", "err", err) - return + return err } - record := tangled.KnotMember{ - LexiconTypeID: tangled.KnotMemberNSID, - Subject: did, - Domain: consts.DefaultKnot, - CreatedAt: time.Now().Format(time.RFC3339), + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err } + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth("admin", o.Config.Knot.AdminSecret) - if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { - l.Error("failed to add to default knot", "err", err) - return + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err } + defer resp.Body.Close() - if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { - l.Error("failed to set up enforcer rules", "err", err) - return + if resp.StatusCode != http.StatusOK { + msg, _ := io.ReadAll(resp.Body) + return fmt.Errorf("knot admin addMember returned status %d: %s", resp.StatusCode, bytes.TrimSpace(msg)) } - l.Debug("successfully added to default knot") + return nil } func (o *OAuth) ensureTangledProfile(sessData *oauth.ClientSessionData) { diff --git a/appview/oauth/handler_test.go b/appview/oauth/handler_test.go new file mode 100644 index 00000000..7970f6ed --- /dev/null +++ b/appview/oauth/handler_test.go @@ -0,0 +1,102 @@ +package oauth + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/appview/config" + "tangled.org/core/consts" +) + +type fakeAcl struct { + member bool + gotHost string + gotDid string +} + +func (f *fakeAcl) InvalidateMembers(host string) {} + +func (f *fakeAcl) IsKnotMember(ctx context.Context, host, userDid string) bool { + f.gotHost = host + f.gotDid = userDid + return f.member +} + +func TestAddToDefaultKnot_ShortCircuitsWhenAlreadyMember(t *testing.T) { + acl := &fakeAcl{member: true} + o := &OAuth{ + Acl: acl, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + Config: &config.Config{ + Core: config.CoreConfig{Dev: true}, + Knot: config.KnotConfig{Default: consts.DefaultKnot}, + }, + } + + o.addToDefaultKnot(syntax.DID("did:plc:akshay")) + + if acl.gotDid != "did:plc:akshay" { + t.Fatalf("IsKnotMember did = %q, want did:plc:akshay", acl.gotDid) + } + if acl.gotHost != consts.DefaultKnot { + t.Fatalf("IsKnotMember host = %q, want %q", acl.gotHost, consts.DefaultKnot) + } +} + +func TestAddMemberViaKnotAdmin_HonorsDeadline(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + })) + defer srv.Close() + defer close(release) + + o := &OAuth{Config: &config.Config{ + Core: config.CoreConfig{Dev: true}, + Knot: config.KnotConfig{AdminSecret: "hunter2"}, + }} + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- o.addMemberViaKnotAdmin(ctx, strings.TrimPrefix(srv.URL, "http://"), syntax.DID("did:plc:whelk")) + }() + + select { + case err := <-done: + if err == nil { + t.Fatal("a hung knot must surface an error, got nil") + } + case <-time.After(5 * time.Second): + t.Fatal("addMemberViaKnotAdmin blocked past its deadline; the request has no timeout") + } +} + +func TestOnboardActionFor(t *testing.T) { + cases := []struct { + name string + state defaultKnotState + want onboardAction + }{ + {"native default knot with admin secret uses the admin api", defaultKnotState{native: true, adminSecretSet: true}, onboardViaAdminAPI}, + {"native default knot without admin secret is blocked", defaultKnotState{native: true, adminSecretSet: false}, onboardBlockedMissingSecret}, + {"legacy default knot with admin secret skips the legacy record", defaultKnotState{native: false, adminSecretSet: true}, onboardBlockedSecretSet}, + {"legacy default knot without admin secret writes the legacy record", defaultKnotState{native: false, adminSecretSet: false}, onboardViaRecord}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := onboardActionFor(c.state); got != c.want { + t.Fatalf("onboardActionFor(%+v) = %d, want %d", c.state, got, c.want) + } + }) + } +}