forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

appview/oauth: add users to default knot/spindle upon login

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li c673a2ea 88a45c13

verified
Changed files
+217 -12
appview
+196
appview/oauth/handler.go
···
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 {
···
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(),
···
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
+
}
+20 -11
appview/oauth/oauth.go
···
"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
···
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 {
+1 -1
appview/state/state.go
···
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)
}