From c673a2ea5ae604f59fe7bc3cb03f5ece183c1983 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Sat, 11 Oct 2025 10:13:31 +0100 Subject: [PATCH] appview/oauth: add users to default knot/spindle upon login Signed-off-by: oppiliappan --- appview/oauth/handler.go | 196 +++++++++++++++++++++++++++++++++++++++ appview/oauth/oauth.go | 31 ++++--- appview/state/state.go | 2 +- 3 files changed, 217 insertions(+), 12 deletions(-) diff --git a/appview/oauth/handler.go b/appview/oauth/handler.go index 7fc0209d..6cdd2e6e 100644 --- a/appview/oauth/handler.go +++ b/appview/oauth/handler.go @@ -1,13 +1,22 @@ package oauth import ( + "bytes" + "context" "encoding/json" + "fmt" "log" "net/http" + "slices" + "time" "github.com/go-chi/chi/v5" "github.com/lestrrat-go/jwx/v2/jwk" "github.com/posthog/posthog-go" + "tangled.org/core/api/tangled" + "tangled.org/core/appview/db" + "tangled.org/core/consts" + "tangled.org/core/tid" ) func (o *OAuth) Router() http.Handler { @@ -62,6 +71,10 @@ func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { return } + log.Println("session saved successfully") + go o.addToDefaultKnot(sessData.AccountDID.String()) + go o.addToDefaultSpindle(sessData.AccountDID.String()) + if !o.Config.Core.Dev { err = o.Posthog.Enqueue(posthog.Capture{ DistinctId: sessData.AccountDID.String(), @@ -74,3 +87,186 @@ func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusFound) } + +func (o *OAuth) addToDefaultSpindle(did string) { + // use the tangled.sh app password to get an accessJwt + // and create an sh.tangled.spindle.member record with that + spindleMembers, err := db.GetSpindleMembers( + o.Db, + db.FilterEq("instance", "spindle.tangled.sh"), + db.FilterEq("subject", did), + ) + if err != nil { + log.Printf("failed to get spindle members for did %s: %v", did, err) + return + } + + if len(spindleMembers) != 0 { + log.Printf("did %s is already a member of the default spindle", did) + return + } + + log.Printf("adding %s to default spindle", did) + session, err := o.createAppPasswordSession(o.Config.Core.AppPassword, consts.TangledDid) + if err != nil { + log.Printf("failed to create session: %s", err) + return + } + + record := tangled.SpindleMember{ + LexiconTypeID: "sh.tangled.spindle.member", + Subject: did, + Instance: consts.DefaultSpindle, + CreatedAt: time.Now().Format(time.RFC3339), + } + + if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { + log.Printf("failed to add member to default spindle: %s", err) + return + } + + log.Printf("successfully added %s to default spindle", did) +} + +func (o *OAuth) addToDefaultKnot(did string) { + // use the tangled.sh app password to get an accessJwt + // and create an sh.tangled.spindle.member record with that + + allKnots, err := o.Enforcer.GetKnotsForUser(did) + if err != nil { + log.Printf("failed to get knot members for did %s: %v", did, err) + return + } + + if slices.Contains(allKnots, consts.DefaultKnot) { + log.Printf("did %s is already a member of the default knot", did) + return + } + + log.Printf("adding %s to default knot", did) + session, err := o.createAppPasswordSession(o.Config.Core.TmpAltAppPassword, consts.IcyDid) + if err != nil { + log.Printf("failed to create session: %s", err) + return + } + + record := tangled.KnotMember{ + LexiconTypeID: "sh.tangled.knot.member", + Subject: did, + Domain: consts.DefaultKnot, + CreatedAt: time.Now().Format(time.RFC3339), + } + + if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { + log.Printf("failed to add member to default knot: %s", err) + return + } + + if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { + log.Printf("failed to set up enforcer rules: %s", err) + return + } + + log.Printf("successfully added %s to default Knot", did) +} + +// create a session using apppasswords +type session struct { + AccessJwt string `json:"accessJwt"` + PdsEndpoint string + Did string +} + +func (o *OAuth) createAppPasswordSession(appPassword, did string) (*session, error) { + if appPassword == "" { + return nil, fmt.Errorf("no app password configured, skipping member addition") + } + + resolved, err := o.IdResolver.ResolveIdent(context.Background(), did) + if err != nil { + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) + } + + pdsEndpoint := resolved.PDSEndpoint() + if pdsEndpoint == "" { + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) + } + + sessionPayload := map[string]string{ + "identifier": did, + "password": appPassword, + } + sessionBytes, err := json.Marshal(sessionPayload) + if err != nil { + return nil, fmt.Errorf("failed to marshal session payload: %v", err) + } + + sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" + sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) + if err != nil { + return nil, fmt.Errorf("failed to create session request: %v", err) + } + sessionReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + sessionResp, err := client.Do(sessionReq) + if err != nil { + return nil, fmt.Errorf("failed to create session: %v", err) + } + defer sessionResp.Body.Close() + + if sessionResp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) + } + + var session session + if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { + return nil, fmt.Errorf("failed to decode session response: %v", err) + } + + session.PdsEndpoint = pdsEndpoint + session.Did = did + + return &session, nil +} + +func (s *session) putRecord(record any, collection string) error { + recordBytes, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("failed to marshal knot member record: %w", err) + } + + payload := map[string]any{ + "repo": s.Did, + "collection": collection, + "rkey": tid.TID(), + "record": json.RawMessage(recordBytes), + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal request payload: %w", err) + } + + url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+s.AccessJwt) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to add user to default service: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) + } + + return nil +} diff --git a/appview/oauth/oauth.go b/appview/oauth/oauth.go index 63aea77c..8efa8cee 100644 --- a/appview/oauth/oauth.go +++ b/appview/oauth/oauth.go @@ -15,9 +15,12 @@ import ( "github.com/lestrrat-go/jwx/v2/jwk" "github.com/posthog/posthog-go" "tangled.org/core/appview/config" + "tangled.org/core/appview/db" + "tangled.org/core/idresolver" + "tangled.org/core/rbac" ) -func New(config *config.Config, ph posthog.Client) (*OAuth, error) { +func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, res *idresolver.Resolver) (*OAuth, error) { var oauthConfig oauth.ClientConfig var clientUri string @@ -43,20 +46,26 @@ func New(config *config.Config, ph posthog.Client) (*OAuth, error) { sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret)) return &OAuth{ - ClientApp: oauth.NewClientApp(&oauthConfig, authStore), - Config: config, - SessStore: sessStore, - JwksUri: jwksUri, - Posthog: ph, + ClientApp: oauth.NewClientApp(&oauthConfig, authStore), + Config: config, + SessStore: sessStore, + JwksUri: jwksUri, + Posthog: ph, + Db: db, + Enforcer: enforcer, + IdResolver: res, }, nil } type OAuth struct { - ClientApp *oauth.ClientApp - SessStore *sessions.CookieStore - Config *config.Config - JwksUri string - Posthog posthog.Client + ClientApp *oauth.ClientApp + SessStore *sessions.CookieStore + Config *config.Config + JwksUri string + Posthog posthog.Client + Db *db.DB + Enforcer *rbac.Enforcer + IdResolver *idresolver.Resolver } func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error { diff --git a/appview/state/state.go b/appview/state/state.go index 63ec043d..e9c09ddb 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -85,7 +85,7 @@ func Make(ctx context.Context, config *config.Config) (*State, error) { pages := pages.NewPages(config, res) cache := cache.New(config.Redis.Addr) sess := session.New(cache) - oauth2, err := oauth.New(config, posthog) + oauth2, err := oauth.New(config, posthog, d, enforcer, res) if err != nil { return nil, fmt.Errorf("failed to start oauth handler: %w", err) } -- 2.51.2