forked from
tangled.org/core
Monorepo for Tangled — https://tangled.org
1package oauth
2
3import (
4 "encoding/json"
5 "fmt"
6 "log"
7 "net/http"
8 "net/url"
9 "strings"
10
11 "github.com/go-chi/chi/v5"
12 "github.com/gorilla/sessions"
13 "github.com/lestrrat-go/jwx/v2/jwk"
14 "github.com/posthog/posthog-go"
15 "tangled.sh/icyphox.sh/atproto-oauth/helpers"
16 "tangled.sh/tangled.sh/core/appview/config"
17 "tangled.sh/tangled.sh/core/appview/db"
18 "tangled.sh/tangled.sh/core/appview/idresolver"
19 "tangled.sh/tangled.sh/core/appview/middleware"
20 "tangled.sh/tangled.sh/core/appview/oauth"
21 "tangled.sh/tangled.sh/core/appview/oauth/client"
22 "tangled.sh/tangled.sh/core/appview/pages"
23 "tangled.sh/tangled.sh/core/knotclient"
24 "tangled.sh/tangled.sh/core/rbac"
25)
26
27const (
28 oauthScope = "atproto transition:generic"
29)
30
31type OAuthHandler struct {
32 config *config.Config
33 pages *pages.Pages
34 idResolver *idresolver.Resolver
35 db *db.DB
36 store *sessions.CookieStore
37 oauth *oauth.OAuth
38 enforcer *rbac.Enforcer
39 posthog posthog.Client
40}
41
42func New(
43 config *config.Config,
44 pages *pages.Pages,
45 idResolver *idresolver.Resolver,
46 db *db.DB,
47 store *sessions.CookieStore,
48 oauth *oauth.OAuth,
49 enforcer *rbac.Enforcer,
50 posthog posthog.Client,
51) *OAuthHandler {
52 return &OAuthHandler{
53 config: config,
54 pages: pages,
55 idResolver: idResolver,
56 db: db,
57 store: store,
58 oauth: oauth,
59 enforcer: enforcer,
60 posthog: posthog,
61 }
62}
63
64func (o *OAuthHandler) Router() http.Handler {
65 r := chi.NewRouter()
66
67 r.Get("/login", o.login)
68 r.Post("/login", o.login)
69
70 r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout)
71
72 r.Get("/oauth/client-metadata.json", o.clientMetadata)
73 r.Get("/oauth/jwks.json", o.jwks)
74 r.Get("/oauth/callback", o.callback)
75 return r
76}
77
78func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) {
79 w.Header().Set("Content-Type", "application/json")
80 w.WriteHeader(http.StatusOK)
81 json.NewEncoder(w).Encode(o.oauth.ClientMetadata())
82}
83
84func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) {
85 jwks := o.config.OAuth.Jwks
86 pubKey, err := pubKeyFromJwk(jwks)
87 if err != nil {
88 log.Printf("error parsing public key: %v", err)
89 http.Error(w, err.Error(), http.StatusInternalServerError)
90 return
91 }
92
93 response := helpers.CreateJwksResponseObject(pubKey)
94
95 w.Header().Set("Content-Type", "application/json")
96 w.WriteHeader(http.StatusOK)
97 json.NewEncoder(w).Encode(response)
98}
99
100func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) {
101 switch r.Method {
102 case http.MethodGet:
103 o.pages.Login(w, pages.LoginParams{})
104 case http.MethodPost:
105 handle := strings.TrimPrefix(r.FormValue("handle"), "@")
106
107 resolved, err := o.idResolver.ResolveIdent(r.Context(), handle)
108 if err != nil {
109 log.Println("failed to resolve handle:", err)
110 o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle))
111 return
112 }
113 self := o.oauth.ClientMetadata()
114 oauthClient, err := client.NewClient(
115 self.ClientID,
116 o.config.OAuth.Jwks,
117 self.RedirectURIs[0],
118 )
119
120 if err != nil {
121 log.Println("failed to create oauth client:", err)
122 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
123 return
124 }
125
126 authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint())
127 if err != nil {
128 log.Println("failed to resolve auth server:", err)
129 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
130 return
131 }
132
133 authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer)
134 if err != nil {
135 log.Println("failed to fetch auth server metadata:", err)
136 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
137 return
138 }
139
140 dpopKey, err := helpers.GenerateKey(nil)
141 if err != nil {
142 log.Println("failed to generate dpop key:", err)
143 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
144 return
145 }
146
147 dpopKeyJson, err := json.Marshal(dpopKey)
148 if err != nil {
149 log.Println("failed to marshal dpop key:", err)
150 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
151 return
152 }
153
154 parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey)
155 if err != nil {
156 log.Println("failed to send par auth request:", err)
157 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
158 return
159 }
160
161 err = db.SaveOAuthRequest(o.db, db.OAuthRequest{
162 Did: resolved.DID.String(),
163 PdsUrl: resolved.PDSEndpoint(),
164 Handle: handle,
165 AuthserverIss: authMeta.Issuer,
166 PkceVerifier: parResp.PkceVerifier,
167 DpopAuthserverNonce: parResp.DpopAuthserverNonce,
168 DpopPrivateJwk: string(dpopKeyJson),
169 State: parResp.State,
170 })
171 if err != nil {
172 log.Println("failed to save oauth request:", err)
173 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
174 return
175 }
176
177 u, _ := url.Parse(authMeta.AuthorizationEndpoint)
178 query := url.Values{}
179 query.Add("client_id", self.ClientID)
180 query.Add("request_uri", parResp.RequestUri)
181 u.RawQuery = query.Encode()
182 o.pages.HxRedirect(w, u.String())
183 }
184}
185
186func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) {
187 state := r.FormValue("state")
188
189 oauthRequest, err := db.GetOAuthRequestByState(o.db, state)
190 if err != nil {
191 log.Println("failed to get oauth request:", err)
192 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
193 return
194 }
195
196 defer func() {
197 err := db.DeleteOAuthRequestByState(o.db, state)
198 if err != nil {
199 log.Println("failed to delete oauth request for state:", state, err)
200 }
201 }()
202
203 error := r.FormValue("error")
204 errorDescription := r.FormValue("error_description")
205 if error != "" || errorDescription != "" {
206 log.Printf("error: %s, %s", error, errorDescription)
207 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
208 return
209 }
210
211 code := r.FormValue("code")
212 if code == "" {
213 log.Println("missing code for state: ", state)
214 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
215 return
216 }
217
218 iss := r.FormValue("iss")
219 if iss == "" {
220 log.Println("missing iss for state: ", state)
221 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
222 return
223 }
224
225 self := o.oauth.ClientMetadata()
226
227 oauthClient, err := client.NewClient(
228 self.ClientID,
229 o.config.OAuth.Jwks,
230 self.RedirectURIs[0],
231 )
232
233 if err != nil {
234 log.Println("failed to create oauth client:", err)
235 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
236 return
237 }
238
239 jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk))
240 if err != nil {
241 log.Println("failed to parse jwk:", err)
242 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
243 return
244 }
245
246 tokenResp, err := oauthClient.InitialTokenRequest(
247 r.Context(),
248 code,
249 oauthRequest.AuthserverIss,
250 oauthRequest.PkceVerifier,
251 oauthRequest.DpopAuthserverNonce,
252 jwk,
253 )
254 if err != nil {
255 log.Println("failed to get token:", err)
256 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
257 return
258 }
259
260 if tokenResp.Scope != oauthScope {
261 log.Println("scope doesn't match:", tokenResp.Scope)
262 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
263 return
264 }
265
266 err = o.oauth.SaveSession(w, r, oauthRequest, tokenResp)
267 if err != nil {
268 log.Println("failed to save session:", err)
269 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.")
270 return
271 }
272
273 log.Println("session saved successfully")
274 go o.addToDefaultKnot(oauthRequest.Did)
275
276 if !o.config.Core.Dev {
277 err = o.posthog.Enqueue(posthog.Capture{
278 DistinctId: oauthRequest.Did,
279 Event: "signin",
280 })
281 if err != nil {
282 log.Println("failed to enqueue posthog event:", err)
283 }
284 }
285
286 http.Redirect(w, r, "/", http.StatusFound)
287}
288
289func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) {
290 err := o.oauth.ClearSession(r, w)
291 if err != nil {
292 log.Println("failed to clear session:", err)
293 http.Redirect(w, r, "/", http.StatusFound)
294 return
295 }
296
297 log.Println("session cleared successfully")
298 http.Redirect(w, r, "/", http.StatusFound)
299}
300
301func pubKeyFromJwk(jwks string) (jwk.Key, error) {
302 k, err := helpers.ParseJWKFromBytes([]byte(jwks))
303 if err != nil {
304 return nil, err
305 }
306 pubKey, err := k.PublicKey()
307 if err != nil {
308 return nil, err
309 }
310 return pubKey, nil
311}
312
313func (o *OAuthHandler) addToDefaultKnot(did string) {
314 defaultKnot := "knot1.tangled.sh"
315
316 log.Printf("adding %s to default knot", did)
317 err := o.enforcer.AddMember(defaultKnot, did)
318 if err != nil {
319 log.Println("failed to add user to knot1.tangled.sh: ", err)
320 return
321 }
322 err = o.enforcer.E.SavePolicy()
323 if err != nil {
324 log.Println("failed to add user to knot1.tangled.sh: ", err)
325 return
326 }
327
328 secret, err := db.GetRegistrationKey(o.db, defaultKnot)
329 if err != nil {
330 log.Println("failed to get registration key for knot1.tangled.sh")
331 return
332 }
333 signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.config.Core.Dev)
334 resp, err := signedClient.AddMember(did)
335 if err != nil {
336 log.Println("failed to add user to knot1.tangled.sh: ", err)
337 return
338 }
339
340 if resp.StatusCode != http.StatusNoContent {
341 log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode)
342 return
343 }
344}