Yōten: A social tracker for your language learning journey built on the atproto.
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}