this repo has no description
1package oauth 2 3import ( 4 "bytes" 5 "context" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "net/http" 10 "slices" 11 "time" 12 13 "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 "github.com/go-chi/chi/v5" 15 "github.com/posthog/posthog-go" 16 "tangled.org/core/api/tangled" 17 "tangled.org/core/appview/db" 18 "tangled.org/core/consts" 19 "tangled.org/core/tid" 20) 21 22func (o *OAuth) Router() http.Handler { 23 r := chi.NewRouter() 24 25 r.Get("/oauth/client-metadata.json", o.clientMetadata) 26 r.Get("/oauth/jwks.json", o.jwks) 27 r.Get("/oauth/callback", o.callback) 28 return r 29} 30 31func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 32 doc := o.ClientApp.Config.ClientMetadata() 33 doc.JWKSURI = &o.JwksUri 34 35 w.Header().Set("Content-Type", "application/json") 36 if err := json.NewEncoder(w).Encode(doc); err != nil { 37 http.Error(w, err.Error(), http.StatusInternalServerError) 38 return 39 } 40} 41 42func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 43 w.Header().Set("Content-Type", "application/json") 44 body := o.ClientApp.Config.PublicJWKS() 45 if err := json.NewEncoder(w).Encode(body); err != nil { 46 http.Error(w, err.Error(), http.StatusInternalServerError) 47 return 48 } 49} 50 51func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 52 ctx := r.Context() 53 l := o.Logger.With("query", r.URL.Query()) 54 55 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 56 if err != nil { 57 var callbackErr *oauth.AuthRequestCallbackError 58 if errors.As(err, &callbackErr) { 59 l.Debug("callback error", "err", callbackErr) 60 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound) 61 return 62 } 63 l.Error("failed to process callback", "err", err) 64 http.Redirect(w, r, "/login?error=oauth", http.StatusFound) 65 return 66 } 67 68 if err := o.SaveSession(w, r, sessData); err != nil { 69 l.Error("failed to save session", "data", sessData, "err", err) 70 http.Redirect(w, r, "/login?error=session", http.StatusFound) 71 return 72 } 73 74 o.Logger.Debug("session saved successfully") 75 go o.addToDefaultKnot(sessData.AccountDID.String()) 76 go o.addToDefaultSpindle(sessData.AccountDID.String()) 77 78 if !o.Config.Core.Dev { 79 err = o.Posthog.Enqueue(posthog.Capture{ 80 DistinctId: sessData.AccountDID.String(), 81 Event: "signin", 82 }) 83 if err != nil { 84 o.Logger.Error("failed to enqueue posthog event", "err", err) 85 } 86 } 87 88 http.Redirect(w, r, "/", http.StatusFound) 89} 90 91func (o *OAuth) addToDefaultSpindle(did string) { 92 l := o.Logger.With("subject", did) 93 94 // use the tangled.sh app password to get an accessJwt 95 // and create an sh.tangled.spindle.member record with that 96 spindleMembers, err := db.GetSpindleMembers( 97 o.Db, 98 db.FilterEq("instance", "spindle.tangled.sh"), 99 db.FilterEq("subject", did), 100 ) 101 if err != nil { 102 l.Error("failed to get spindle members", "err", err) 103 return 104 } 105 106 if len(spindleMembers) != 0 { 107 l.Warn("already a member of the default spindle") 108 return 109 } 110 111 l.Debug("adding to default spindle") 112 session, err := o.createAppPasswordSession(o.Config.Core.AppPassword, consts.TangledDid) 113 if err != nil { 114 l.Error("failed to create session", "err", err) 115 return 116 } 117 118 record := tangled.SpindleMember{ 119 LexiconTypeID: "sh.tangled.spindle.member", 120 Subject: did, 121 Instance: consts.DefaultSpindle, 122 CreatedAt: time.Now().Format(time.RFC3339), 123 } 124 125 if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 126 l.Error("failed to add to default spindle", "err", err) 127 return 128 } 129 130 l.Debug("successfully added to default spindle", "did", did) 131} 132 133func (o *OAuth) addToDefaultKnot(did string) { 134 l := o.Logger.With("subject", did) 135 136 // use the tangled.sh app password to get an accessJwt 137 // and create an sh.tangled.spindle.member record with that 138 139 allKnots, err := o.Enforcer.GetKnotsForUser(did) 140 if err != nil { 141 l.Error("failed to get knot members for did", "err", err) 142 return 143 } 144 145 if slices.Contains(allKnots, consts.DefaultKnot) { 146 l.Warn("already a member of the default knot") 147 return 148 } 149 150 l.Debug("addings to default knot") 151 session, err := o.createAppPasswordSession(o.Config.Core.TmpAltAppPassword, consts.IcyDid) 152 if err != nil { 153 l.Error("failed to create session", "err", err) 154 return 155 } 156 157 record := tangled.KnotMember{ 158 LexiconTypeID: "sh.tangled.knot.member", 159 Subject: did, 160 Domain: consts.DefaultKnot, 161 CreatedAt: time.Now().Format(time.RFC3339), 162 } 163 164 if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 165 l.Error("failed to add to default knot", "err", err) 166 return 167 } 168 169 if err := o.Enforcer.AddKnotMember(consts.DefaultKnot, did); err != nil { 170 l.Error("failed to set up enforcer rules", "err", err) 171 return 172 } 173 174 l.Debug("successfully addeds to default Knot") 175} 176 177// create a session using apppasswords 178type session struct { 179 AccessJwt string `json:"accessJwt"` 180 PdsEndpoint string 181 Did string 182} 183 184func (o *OAuth) createAppPasswordSession(appPassword, did string) (*session, error) { 185 if appPassword == "" { 186 return nil, fmt.Errorf("no app password configured, skipping member addition") 187 } 188 189 resolved, err := o.IdResolver.ResolveIdent(context.Background(), did) 190 if err != nil { 191 return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 192 } 193 194 pdsEndpoint := resolved.PDSEndpoint() 195 if pdsEndpoint == "" { 196 return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 197 } 198 199 sessionPayload := map[string]string{ 200 "identifier": did, 201 "password": appPassword, 202 } 203 sessionBytes, err := json.Marshal(sessionPayload) 204 if err != nil { 205 return nil, fmt.Errorf("failed to marshal session payload: %v", err) 206 } 207 208 sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 209 sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 210 if err != nil { 211 return nil, fmt.Errorf("failed to create session request: %v", err) 212 } 213 sessionReq.Header.Set("Content-Type", "application/json") 214 215 client := &http.Client{Timeout: 30 * time.Second} 216 sessionResp, err := client.Do(sessionReq) 217 if err != nil { 218 return nil, fmt.Errorf("failed to create session: %v", err) 219 } 220 defer sessionResp.Body.Close() 221 222 if sessionResp.StatusCode != http.StatusOK { 223 return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 224 } 225 226 var session session 227 if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 228 return nil, fmt.Errorf("failed to decode session response: %v", err) 229 } 230 231 session.PdsEndpoint = pdsEndpoint 232 session.Did = did 233 234 return &session, nil 235} 236 237func (s *session) putRecord(record any, collection string) error { 238 recordBytes, err := json.Marshal(record) 239 if err != nil { 240 return fmt.Errorf("failed to marshal knot member record: %w", err) 241 } 242 243 payload := map[string]any{ 244 "repo": s.Did, 245 "collection": collection, 246 "rkey": tid.TID(), 247 "record": json.RawMessage(recordBytes), 248 } 249 250 payloadBytes, err := json.Marshal(payload) 251 if err != nil { 252 return fmt.Errorf("failed to marshal request payload: %w", err) 253 } 254 255 url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 256 req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 257 if err != nil { 258 return fmt.Errorf("failed to create HTTP request: %w", err) 259 } 260 261 req.Header.Set("Content-Type", "application/json") 262 req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 263 264 client := &http.Client{Timeout: 30 * time.Second} 265 resp, err := client.Do(req) 266 if err != nil { 267 return fmt.Errorf("failed to add user to default service: %w", err) 268 } 269 defer resp.Body.Close() 270 271 if resp.StatusCode != http.StatusOK { 272 return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 273 } 274 275 return nil 276}