Monorepo for Tangled
at push-ntmmpnmptnvp 344 lines 9.6 kB view raw
1package oauth 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "net/http" 10 "slices" 11 "time" 12 13 comatproto "github.com/bluesky-social/indigo/api/atproto" 14 "github.com/bluesky-social/indigo/atproto/auth/oauth" 15 lexutil "github.com/bluesky-social/indigo/lex/util" 16 "github.com/go-chi/chi/v5" 17 "github.com/posthog/posthog-go" 18 "tangled.org/core/api/tangled" 19 "tangled.org/core/appview/db" 20 "tangled.org/core/appview/models" 21 "tangled.org/core/consts" 22 "tangled.org/core/idresolver" 23 "tangled.org/core/orm" 24 "tangled.org/core/tid" 25) 26 27func (o *OAuth) Router() http.Handler { 28 r := chi.NewRouter() 29 30 r.Get("/oauth/client-metadata.json", o.clientMetadata) 31 r.Get("/oauth/jwks.json", o.jwks) 32 r.Get("/oauth/callback", o.callback) 33 return r 34} 35 36func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 37 doc := o.ClientApp.Config.ClientMetadata() 38 doc.JWKSURI = &o.JwksUri 39 doc.ClientName = &o.ClientName 40 doc.ClientURI = &o.ClientUri 41 42 w.Header().Set("Content-Type", "application/json") 43 if err := json.NewEncoder(w).Encode(doc); err != nil { 44 http.Error(w, err.Error(), http.StatusInternalServerError) 45 return 46 } 47} 48 49func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 50 w.Header().Set("Content-Type", "application/json") 51 body := o.ClientApp.Config.PublicJWKS() 52 if err := json.NewEncoder(w).Encode(body); err != nil { 53 http.Error(w, err.Error(), http.StatusInternalServerError) 54 return 55 } 56} 57 58func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 59 ctx := r.Context() 60 l := o.Logger.With("query", r.URL.Query()) 61 62 authReturn := o.GetAuthReturn(r) 63 _ = o.ClearAuthReturn(w, r) 64 65 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 66 if err != nil { 67 var callbackErr *oauth.AuthRequestCallbackError 68 if errors.As(err, &callbackErr) { 69 l.Debug("callback error", "err", callbackErr) 70 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound) 71 return 72 } 73 l.Error("failed to process callback", "err", err) 74 http.Redirect(w, r, "/login?error=oauth", http.StatusFound) 75 return 76 } 77 78 if err := o.SaveSession(w, r, sessData); err != nil { 79 l.Error("failed to save session", "data", sessData, "err", err) 80 errorCode := "session" 81 if errors.Is(err, ErrMaxAccountsReached) { 82 errorCode = "max_accounts" 83 } 84 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", errorCode), http.StatusFound) 85 return 86 } 87 88 o.Logger.Debug("session saved successfully") 89 90 go o.addToDefaultKnot(sessData.AccountDID.String()) 91 go o.addToDefaultSpindle(sessData.AccountDID.String()) 92 go o.ensureTangledProfile(sessData) 93 94 if !o.Config.Core.Dev { 95 err = o.Posthog.Enqueue(posthog.Capture{ 96 DistinctId: sessData.AccountDID.String(), 97 Event: "signin", 98 }) 99 if err != nil { 100 o.Logger.Error("failed to enqueue posthog event", "err", err) 101 } 102 } 103 104 redirectURL := "/" 105 if authReturn.ReturnURL != "" { 106 redirectURL = authReturn.ReturnURL 107 } 108 109 http.Redirect(w, r, redirectURL, http.StatusFound) 110} 111 112func (o *OAuth) addToDefaultSpindle(did string) { 113 l := o.Logger.With("subject", did) 114 115 // use the tangled.sh app password to get an accessJwt 116 // and create an sh.tangled.spindle.member record with that 117 spindleMembers, err := db.GetSpindleMembers( 118 o.Db, 119 orm.FilterEq("instance", "spindle.tangled.sh"), 120 orm.FilterEq("subject", did), 121 ) 122 if err != nil { 123 l.Error("failed to get spindle members", "err", err) 124 return 125 } 126 127 if len(spindleMembers) != 0 { 128 l.Warn("already a member of the default spindle") 129 return 130 } 131 132 l.Debug("adding to default spindle") 133 session, err := CreateAppPasswordSession(o.IdResolver, o.Config.Core.AppPassword, consts.TangledDid) 134 if err != nil { 135 l.Error("failed to create session", "err", err) 136 return 137 } 138 139 record := tangled.SpindleMember{ 140 LexiconTypeID: tangled.SpindleMemberNSID, 141 Subject: did, 142 Instance: consts.DefaultSpindle, 143 CreatedAt: time.Now().Format(time.RFC3339), 144 } 145 146 if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 147 l.Error("failed to add to default spindle", "err", err) 148 return 149 } 150 151 l.Debug("successfully added to default spindle", "did", did) 152} 153 154func (o *OAuth) addToDefaultKnot(did string) { 155 l := o.Logger.With("subject", did) 156 157 // use the tangled.sh app password to get an accessJwt 158 // and create an sh.tangled.spindle.member record with that 159 160 allKnots, err := o.Enforcer.GetKnotsForUser(did) 161 if err != nil { 162 l.Error("failed to get knot members for did", "err", err) 163 return 164 } 165 166 if slices.Contains(allKnots, consts.DefaultKnot) { 167 l.Warn("already a member of the default knot") 168 return 169 } 170 171 l.Debug("adding to default knot") 172 session, err := CreateAppPasswordSession(o.IdResolver, o.Config.Core.AppPassword, consts.TangledDid) 173 if err != nil { 174 l.Error("failed to create session", "err", err) 175 return 176 } 177 178 record := tangled.KnotMember{ 179 LexiconTypeID: tangled.KnotMemberNSID, 180 Subject: did, 181 Domain: consts.DefaultKnot, 182 CreatedAt: time.Now().Format(time.RFC3339), 183 } 184 185 if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 186 l.Error("failed to add to default knot", "err", err) 187 return 188 } 189 190 if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 191 l.Error("failed to set up enforcer rules", "err", err) 192 return 193 } 194 195 l.Debug("successfully addeds to default Knot") 196} 197 198func (o *OAuth) ensureTangledProfile(sessData *oauth.ClientSessionData) { 199 ctx := context.Background() 200 did := sessData.AccountDID.String() 201 l := o.Logger.With("did", did) 202 203 profile, _ := db.GetProfile(o.Db, did) 204 if profile != nil { 205 l.Debug("profile already exists in DB") 206 return 207 } 208 209 l.Debug("creating empty Tangled profile") 210 211 sess, err := o.ClientApp.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID) 212 if err != nil { 213 l.Error("failed to resume session for profile creation", "err", err) 214 return 215 } 216 client := sess.APIClient() 217 218 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 219 Collection: tangled.ActorProfileNSID, 220 Repo: did, 221 Rkey: "self", 222 Record: &lexutil.LexiconTypeDecoder{Val: &tangled.ActorProfile{}}, 223 }) 224 225 if err != nil { 226 l.Error("failed to create empty profile on PDS", "err", err) 227 return 228 } 229 230 tx, err := o.Db.BeginTx(ctx, nil) 231 if err != nil { 232 l.Error("failed to start transaction", "err", err) 233 return 234 } 235 236 emptyProfile := &models.Profile{Did: did} 237 if err := db.UpsertProfile(tx, emptyProfile); err != nil { 238 l.Error("failed to create empty profile in DB", "err", err) 239 return 240 } 241 242 l.Debug("successfully created empty Tangled profile on PDS and DB") 243} 244 245// create a AppPasswordSession using apppasswords 246type AppPasswordSession struct { 247 AccessJwt string `json:"accessJwt"` 248 PdsEndpoint string 249 Did string 250} 251 252func CreateAppPasswordSession(res *idresolver.Resolver, appPassword, did string) (*AppPasswordSession, error) { 253 if appPassword == "" { 254 return nil, fmt.Errorf("no app password configured") 255 } 256 257 resolved, err := res.ResolveIdent(context.Background(), did) 258 if err != nil { 259 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 260 } 261 262 pdsEndpoint := resolved.PDSEndpoint() 263 if pdsEndpoint == "" { 264 return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 265 } 266 267 sessionPayload := map[string]string{ 268 "identifier": did, 269 "password": appPassword, 270 } 271 sessionBytes, err := json.Marshal(sessionPayload) 272 if err != nil { 273 return nil, fmt.Errorf("failed to marshal session payload: %v", err) 274 } 275 276 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 277 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 278 if err != nil { 279 return nil, fmt.Errorf("failed to create session request: %v", err) 280 } 281 sessionReq.Header.Set("Content-Type", "application/json") 282 283 client := &http.Client{Timeout: 30 * time.Second} 284 sessionResp, err := client.Do(sessionReq) 285 if err != nil { 286 return nil, fmt.Errorf("failed to create session: %v", err) 287 } 288 defer sessionResp.Body.Close() 289 290 if sessionResp.StatusCode != http.StatusOK { 291 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 292 } 293 294 var session AppPasswordSession 295 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 296 return nil, fmt.Errorf("failed to decode session response: %v", err) 297 } 298 299 session.PdsEndpoint = pdsEndpoint 300 session.Did = did 301 302 return &session, nil 303} 304 305func (s *AppPasswordSession) putRecord(record any, collection string) error { 306 recordBytes, err := json.Marshal(record) 307 if err != nil { 308 return fmt.Errorf("failed to marshal knot member record: %w", err) 309 } 310 311 payload := map[string]any{ 312 "repo": s.Did, 313 "collection": collection, 314 "rkey": tid.TID(), 315 "record": json.RawMessage(recordBytes), 316 } 317 318 payloadBytes, err := json.Marshal(payload) 319 if err != nil { 320 return fmt.Errorf("failed to marshal request payload: %w", err) 321 } 322 323 url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 324 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 325 if err != nil { 326 return fmt.Errorf("failed to create HTTP request: %w", err) 327 } 328 329 req.Header.Set("Content-Type", "application/json") 330 req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 331 332 client := &http.Client{Timeout: 30 * time.Second} 333 resp, err := client.Do(req) 334 if err != nil { 335 return fmt.Errorf("failed to add user to default service: %w", err) 336 } 337 defer resp.Body.Close() 338 339 if resp.StatusCode != http.StatusOK { 340 return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 341 } 342 343 return nil 344}