A vibe coded tangled fork which supports pijul.
at 7cc6d7d5228e63b46787454eb4ebf177819a59b7 461 lines 14 kB view raw
1package oauth 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "log/slog" 10 "net/http" 11 "slices" 12 "strings" 13 "time" 14 15 comatproto "github.com/bluesky-social/indigo/api/atproto" 16 "github.com/bluesky-social/indigo/atproto/auth/oauth" 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 "github.com/go-chi/chi/v5" 19 "github.com/posthog/posthog-go" 20 "tangled.org/core/api/tangled" 21 "tangled.org/core/appview/db" 22 "tangled.org/core/appview/models" 23 "tangled.org/core/consts" 24 "tangled.org/core/idresolver" 25 "tangled.org/core/orm" 26 "tangled.org/core/tid" 27) 28 29func (o *OAuth) Router() http.Handler { 30 r := chi.NewRouter() 31 32 r.Get("/oauth/client-metadata.json", o.clientMetadata) 33 r.Get("/oauth/jwks.json", o.jwks) 34 r.Get("/oauth/callback", o.callback) 35 return r 36} 37 38func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 39 doc := o.ClientApp.Config.ClientMetadata() 40 doc.JWKSURI = &o.JwksUri 41 doc.ClientName = &o.ClientName 42 doc.ClientURI = &o.ClientUri 43 44 w.Header().Set("Content-Type", "application/json") 45 if err := json.NewEncoder(w).Encode(doc); err != nil { 46 http.Error(w, err.Error(), http.StatusInternalServerError) 47 return 48 } 49} 50 51func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 52 w.Header().Set("Content-Type", "application/json") 53 body := o.ClientApp.Config.PublicJWKS() 54 if err := json.NewEncoder(w).Encode(body); err != nil { 55 http.Error(w, err.Error(), http.StatusInternalServerError) 56 return 57 } 58} 59 60func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 61 ctx := r.Context() 62 l := o.Logger.With("query", r.URL.Query()) 63 64 authReturn := o.GetAuthReturn(r) 65 _ = o.ClearAuthReturn(w, r) 66 67 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 68 if err != nil { 69 var callbackErr *oauth.AuthRequestCallbackError 70 if errors.As(err, &callbackErr) { 71 l.Debug("callback error", "err", callbackErr) 72 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound) 73 return 74 } 75 l.Error("failed to process callback", "err", err) 76 http.Redirect(w, r, "/login?error=oauth", http.StatusFound) 77 return 78 } 79 80 if err := o.SaveSession(w, r, sessData); err != nil { 81 l.Error("failed to save session", "data", sessData, "err", err) 82 errorCode := "session" 83 if errors.Is(err, ErrMaxAccountsReached) { 84 errorCode = "max_accounts" 85 } 86 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", errorCode), http.StatusFound) 87 return 88 } 89 90 o.Logger.Debug("session saved successfully") 91 92 go o.addToDefaultKnot(sessData.AccountDID.String()) 93 go o.addToDefaultSpindle(sessData.AccountDID.String()) 94 go o.ensureTangledProfile(sessData) 95 go o.autoClaimTnglShDomain(sessData.AccountDID.String()) 96 97 if !o.Config.Core.Dev { 98 err = o.Posthog.Enqueue(posthog.Capture{ 99 DistinctId: sessData.AccountDID.String(), 100 Event: "signin", 101 }) 102 if err != nil { 103 o.Logger.Error("failed to enqueue posthog event", "err", err) 104 } 105 } 106 107 redirectURL := "/" 108 if authReturn.ReturnURL != "" { 109 redirectURL = authReturn.ReturnURL 110 } 111 112 http.Redirect(w, r, redirectURL, http.StatusFound) 113} 114 115func (o *OAuth) addToDefaultSpindle(did string) { 116 l := o.Logger.With("subject", did) 117 118 // use the tangled.sh app password to get an accessJwt 119 // and create an sh.tangled.spindle.member record with that 120 spindleMembers, err := db.GetSpindleMembers( 121 o.Db, 122 orm.FilterEq("instance", "spindle.tangled.sh"), 123 orm.FilterEq("subject", did), 124 ) 125 if err != nil { 126 l.Error("failed to get spindle members", "err", err) 127 return 128 } 129 130 if len(spindleMembers) != 0 { 131 l.Warn("already a member of the default spindle") 132 return 133 } 134 135 l.Debug("adding to default spindle") 136 session, err := o.getAppPasswordSession() 137 if err != nil { 138 l.Error("failed to create session", "err", err) 139 return 140 } 141 142 record := tangled.SpindleMember{ 143 LexiconTypeID: tangled.SpindleMemberNSID, 144 Subject: did, 145 Instance: consts.DefaultSpindle, 146 CreatedAt: time.Now().Format(time.RFC3339), 147 } 148 149 if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 150 l.Error("failed to add to default spindle", "err", err) 151 return 152 } 153 154 l.Debug("successfully added to default spindle", "did", did) 155} 156 157func (o *OAuth) addToDefaultKnot(did string) { 158 l := o.Logger.With("subject", did) 159 160 // use the tangled.sh app password to get an accessJwt 161 // and create an sh.tangled.spindle.member record with that 162 163 allKnots, err := o.Enforcer.GetKnotsForUser(did) 164 if err != nil { 165 l.Error("failed to get knot members for did", "err", err) 166 return 167 } 168 169 if slices.Contains(allKnots, consts.DefaultKnot) { 170 l.Warn("already a member of the default knot") 171 return 172 } 173 174 l.Debug("adding to default knot") 175 session, err := o.getAppPasswordSession() 176 if err != nil { 177 l.Error("failed to create session", "err", err) 178 return 179 } 180 181 record := tangled.KnotMember{ 182 LexiconTypeID: tangled.KnotMemberNSID, 183 Subject: did, 184 Domain: consts.DefaultKnot, 185 CreatedAt: time.Now().Format(time.RFC3339), 186 } 187 188 if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 189 l.Error("failed to add to default knot", "err", err) 190 return 191 } 192 193 if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 194 l.Error("failed to set up enforcer rules", "err", err) 195 return 196 } 197 198 l.Debug("successfully added to default knot") 199} 200 201func (o *OAuth) ensureTangledProfile(sessData *oauth.ClientSessionData) { 202 ctx := context.Background() 203 did := sessData.AccountDID.String() 204 l := o.Logger.With("did", did) 205 206 profile, _ := db.GetProfile(o.Db, did) 207 if profile != nil { 208 l.Debug("profile already exists in DB") 209 return 210 } 211 212 l.Debug("creating empty Tangled profile") 213 214 sess, err := o.ClientApp.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID) 215 if err != nil { 216 l.Error("failed to resume session for profile creation", "err", err) 217 return 218 } 219 client := sess.APIClient() 220 221 _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 222 Collection: tangled.ActorProfileNSID, 223 Repo: did, 224 Rkey: "self", 225 Record: &lexutil.LexiconTypeDecoder{Val: &tangled.ActorProfile{}}, 226 }) 227 228 if err != nil { 229 l.Error("failed to create empty profile on PDS", "err", err) 230 return 231 } 232 233 tx, err := o.Db.BeginTx(ctx, nil) 234 if err != nil { 235 l.Error("failed to start transaction", "err", err) 236 return 237 } 238 239 emptyProfile := &models.Profile{Did: did} 240 if err := db.UpsertProfile(tx, emptyProfile); err != nil { 241 l.Error("failed to create empty profile in DB", "err", err) 242 return 243 } 244 245 l.Debug("successfully created empty Tangled profile on PDS and DB") 246} 247 248// create a AppPasswordSession using apppasswords 249type AppPasswordSession struct { 250 AccessJwt string `json:"accessJwt"` 251 RefreshJwt string `json:"refreshJwt"` 252 PdsEndpoint string 253 Did string 254 Logger *slog.Logger 255 ExpiresAt time.Time 256} 257 258func CreateAppPasswordSession(res *idresolver.Resolver, appPassword, did string, logger *slog.Logger) (*AppPasswordSession, error) { 259 if appPassword == "" { 260 return nil, fmt.Errorf("no app password configured") 261 } 262 263 resolved, err := res.ResolveIdent(context.Background(), did) 264 if err != nil { 265 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 266 } 267 268 pdsEndpoint := resolved.PDSEndpoint() 269 if pdsEndpoint == "" { 270 return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 271 } 272 273 sessionPayload := map[string]string{ 274 "identifier": did, 275 "password": appPassword, 276 } 277 sessionBytes, err := json.Marshal(sessionPayload) 278 if err != nil { 279 return nil, fmt.Errorf("failed to marshal session payload: %v", err) 280 } 281 282 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 283 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 284 if err != nil { 285 return nil, fmt.Errorf("failed to create session request: %v", err) 286 } 287 sessionReq.Header.Set("Content-Type", "application/json") 288 289 logger.Debug("creating app password session", "url", sessionURL, "headers", sessionReq.Header) 290 291 client := &http.Client{Timeout: 30 * time.Second} 292 sessionResp, err := client.Do(sessionReq) 293 if err != nil { 294 return nil, fmt.Errorf("failed to create session: %v", err) 295 } 296 defer sessionResp.Body.Close() 297 298 if sessionResp.StatusCode != http.StatusOK { 299 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 300 } 301 302 var session AppPasswordSession 303 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 304 return nil, fmt.Errorf("failed to decode session response: %v", err) 305 } 306 307 session.PdsEndpoint = pdsEndpoint 308 session.Did = did 309 session.Logger = logger 310 session.ExpiresAt = time.Now().Add(115 * time.Minute) 311 312 return &session, nil 313} 314 315func (s *AppPasswordSession) refreshSession() error { 316 refreshURL := s.PdsEndpoint + "/xrpc/com.atproto.server.refreshSession" 317 req, err := http.NewRequestWithContext(context.Background(), "POST", refreshURL, nil) 318 if err != nil { 319 return fmt.Errorf("failed to create refresh request: %w", err) 320 } 321 322 req.Header.Set("Authorization", "Bearer "+s.RefreshJwt) 323 324 s.Logger.Debug("refreshing app password session", "url", refreshURL) 325 326 client := &http.Client{Timeout: 30 * time.Second} 327 resp, err := client.Do(req) 328 if err != nil { 329 return fmt.Errorf("failed to refresh session: %w", err) 330 } 331 defer resp.Body.Close() 332 333 if resp.StatusCode != http.StatusOK { 334 var errorResponse map[string]any 335 if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil { 336 return fmt.Errorf("failed to refresh session: HTTP %d (failed to decode error response: %w)", resp.StatusCode, err) 337 } 338 errorBytes, _ := json.Marshal(errorResponse) 339 return fmt.Errorf("failed to refresh session: HTTP %d, response: %s", resp.StatusCode, string(errorBytes)) 340 } 341 342 var refreshResponse struct { 343 AccessJwt string `json:"accessJwt"` 344 RefreshJwt string `json:"refreshJwt"` 345 } 346 if err := json.NewDecoder(resp.Body).Decode(&refreshResponse); err != nil { 347 return fmt.Errorf("failed to decode refresh response: %w", err) 348 } 349 350 s.AccessJwt = refreshResponse.AccessJwt 351 s.RefreshJwt = refreshResponse.RefreshJwt 352 // Set new expiry time with 5 minute buffer 353 s.ExpiresAt = time.Now().Add(115 * time.Minute) 354 355 s.Logger.Debug("successfully refreshed app password session") 356 return nil 357} 358 359func (s *AppPasswordSession) isValid() bool { 360 return time.Now().Before(s.ExpiresAt) 361} 362 363func (s *AppPasswordSession) putRecord(record any, collection string) error { 364 if !s.isValid() { 365 s.Logger.Debug("access token expired, refreshing session") 366 if err := s.refreshSession(); err != nil { 367 return fmt.Errorf("failed to refresh session: %w", err) 368 } 369 s.Logger.Debug("session refreshed") 370 } 371 372 recordBytes, err := json.Marshal(record) 373 if err != nil { 374 return fmt.Errorf("failed to marshal knot member record: %w", err) 375 } 376 377 payload := map[string]any{ 378 "repo": s.Did, 379 "collection": collection, 380 "rkey": tid.TID(), 381 "record": json.RawMessage(recordBytes), 382 } 383 384 payloadBytes, err := json.Marshal(payload) 385 if err != nil { 386 return fmt.Errorf("failed to marshal request payload: %w", err) 387 } 388 389 url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 390 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 391 if err != nil { 392 return fmt.Errorf("failed to create HTTP request: %w", err) 393 } 394 395 req.Header.Set("Content-Type", "application/json") 396 req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 397 398 s.Logger.Debug("putting record", "url", url, "collection", collection) 399 400 client := &http.Client{Timeout: 30 * time.Second} 401 resp, err := client.Do(req) 402 if err != nil { 403 return fmt.Errorf("failed to add user to default service: %w", err) 404 } 405 defer resp.Body.Close() 406 407 if resp.StatusCode != http.StatusOK { 408 var errorResponse map[string]any 409 if err := json.NewDecoder(resp.Body).Decode(&errorResponse); err != nil { 410 return fmt.Errorf("failed to add user to default service: HTTP %d (failed to decode error response: %w)", resp.StatusCode, err) 411 } 412 return fmt.Errorf("failed to add user to default service: HTTP %d, response: %v", resp.StatusCode, errorResponse) 413 } 414 415 return nil 416} 417 418// autoClaimTnglShDomain checks if the user has a .tngl.sh handle and, if so, 419// ensures their corresponding sites domain is claimed. This is idempotent — 420// ClaimDomain is a no-op if the claim already exists. 421func (o *OAuth) autoClaimTnglShDomain(did string) { 422 l := o.Logger.With("did", did) 423 424 pdsDomain := strings.TrimPrefix(o.Config.Pds.Host, "https://") 425 pdsDomain = strings.TrimPrefix(pdsDomain, "http://") 426 427 resolved, err := o.IdResolver.ResolveIdent(context.Background(), did) 428 if err != nil { 429 l.Error("autoClaimTnglShDomain: failed to resolve ident", "err", err) 430 return 431 } 432 433 handle := resolved.Handle.String() 434 if !strings.HasSuffix(handle, "."+pdsDomain) { 435 return 436 } 437 438 if err := db.ClaimDomain(o.Db, did, handle); err != nil { 439 l.Warn("autoClaimTnglShDomain: failed to claim domain", "domain", handle, "err", err) 440 } else { 441 l.Info("autoClaimTnglShDomain: claimed domain", "domain", handle) 442 } 443} 444 445// getAppPasswordSession returns a cached AppPasswordSession, creating one if needed. 446func (o *OAuth) getAppPasswordSession() (*AppPasswordSession, error) { 447 o.appPasswordSessionMu.Lock() 448 defer o.appPasswordSessionMu.Unlock() 449 450 if o.appPasswordSession != nil { 451 return o.appPasswordSession, nil 452 } 453 454 session, err := CreateAppPasswordSession(o.IdResolver, o.Config.Core.AppPassword, consts.TangledDid, o.Logger) 455 if err != nil { 456 return nil, err 457 } 458 459 o.appPasswordSession = session 460 return session, nil 461}