Yōten: A social tracker for your language learning journey built on the atproto.
at master 176 lines 4.8 kB view raw
1package oauth 2 3import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "net/http" 9 "time" 10 11 comatproto "github.com/bluesky-social/indigo/api/atproto" 12 "github.com/bluesky-social/indigo/atproto/auth/oauth" 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 "github.com/go-chi/chi/v5" 15 "github.com/posthog/posthog-go" 16 17 "yoten.app/api/yoten" 18 ph "yoten.app/internal/clients/posthog" 19 "yoten.app/internal/db" 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 29 return r 30} 31 32func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) { 33 clientName := ClientName 34 clientUri := ClientURI 35 36 meta := o.ClientApp.Config.ClientMetadata() 37 meta.JWKSURI = &o.JwksUri 38 meta.ClientName = &clientName 39 meta.ClientURI = &clientUri 40 41 w.Header().Set("Content-Type", "application/json") 42 if err := json.NewEncoder(w).Encode(meta); err != nil { 43 http.Error(w, err.Error(), http.StatusInternalServerError) 44 return 45 } 46} 47 48func (o *OAuth) jwks(w http.ResponseWriter, r *http.Request) { 49 w.Header().Set("Content-Type", "application/json") 50 body := o.ClientApp.Config.PublicJWKS() 51 if err := json.NewEncoder(w).Encode(body); err != nil { 52 http.Error(w, err.Error(), http.StatusInternalServerError) 53 return 54 } 55} 56 57func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) { 58 ctx := r.Context() 59 l := o.Logger.With("handler", "callback").With("query", r.URL.Query()) 60 61 authReturn := o.GetAuthReturn(r) 62 _ = o.ClearAuthReturn(w, r) 63 64 sessData, err := o.ClientApp.ProcessCallback(ctx, r.URL.Query()) 65 if err != nil { 66 var callbackErr *oauth.AuthRequestCallbackError 67 if errors.As(err, &callbackErr) { 68 l.Debug("callback error", "err", callbackErr) 69 http.Redirect(w, r, fmt.Sprintf("/login?error=%s", callbackErr.ErrorCode), http.StatusFound) 70 return 71 } 72 l.Error("failed to process callback", "err", err) 73 http.Redirect(w, r, "/login?error=oauth", http.StatusFound) 74 return 75 } 76 77 if err := o.SaveSession(w, r, sessData); err != nil { 78 l.Error("failed to save session", "err", err) 79 http.Redirect(w, r, "/login?error=session", http.StatusFound) 80 return 81 } 82 83 did := sessData.AccountDID.String() 84 resolved, err := o.IdResolver.ResolveIdent(context.Background(), did) 85 if err != nil { 86 l.Error("failed to resolve handle", "handle", resolved.Handle.String(), "err", err) 87 http.Redirect(w, r, "/login?error=handle", http.StatusFound) 88 return 89 } 90 91 clientSession, err := o.ClientApp.ResumeSession(r.Context(), sessData.AccountDID, sessData.SessionID) 92 if err != nil { 93 l.Error("failed to get authorized client", "err", err) 94 http.Redirect(w, r, "/login?error=client", http.StatusFound) 95 return 96 } 97 98 client := clientSession.APIClient() 99 100 ex, _ := comatproto.RepoGetRecord(r.Context(), client, "", yoten.ActorProfileNSID, did, "self") 101 var cid *string 102 if ex != nil { 103 cid = ex.Cid 104 } 105 106 // This should only occur once per account 107 if ex == nil { 108 createdAt := time.Now().Format(time.RFC3339) 109 atresp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 110 Collection: yoten.ActorProfileNSID, 111 Repo: did, 112 Rkey: "self", 113 Record: &lexutil.LexiconTypeDecoder{ 114 Val: &yoten.ActorProfile{ 115 DisplayName: resolved.Handle.String(), 116 Description: db.ToPtr(""), 117 Languages: make([]string, 0), 118 Location: db.ToPtr(""), 119 CreatedAt: createdAt, 120 }}, 121 122 SwapRecord: cid, 123 }) 124 if err != nil { 125 l.Error("failed to create profile record", "err", err) 126 http.Redirect(w, r, "/login?error=profile-creation", http.StatusFound) 127 return 128 } 129 130 l.Debug("created profile record", "uri", atresp.Uri) 131 132 if !o.Config.Core.Dev { 133 properties := posthog.NewProperties(). 134 Set("display_name", resolved.Handle.String()). 135 Set("language_count", 0). 136 Set("$set_once", posthog.NewProperties(). 137 Set("initial_did", did). 138 Set("initial_handle", resolved.Handle.String()). 139 Set("created_at", createdAt), 140 ) 141 142 err = o.Posthog.Enqueue(posthog.Identify{ 143 DistinctId: did, 144 Properties: properties, 145 }) 146 if err != nil { 147 l.Error("failed to enqueue posthog identify event", "err", err) 148 } 149 150 err = o.Posthog.Enqueue(posthog.Capture{ 151 DistinctId: did, 152 Event: ph.ProfileRecordCreatedEvent, 153 }) 154 if err != nil { 155 l.Error("failed to enqueue posthog event", "err", err) 156 } 157 } 158 } 159 160 if !o.Config.Core.Dev { 161 err = o.Posthog.Enqueue(posthog.Capture{ 162 DistinctId: sessData.AccountDID.String(), 163 Event: ph.UserSignInSuccessEvent, 164 }) 165 if err != nil { 166 l.Error("failed to enqueue posthog event", "err", err) 167 } 168 } 169 170 redirectURL := "/" 171 if authReturn.ReturnURL != "" { 172 redirectURL = authReturn.ReturnURL 173 } 174 175 http.Redirect(w, r, redirectURL, http.StatusFound) 176}