cloudflare-native port of the tangled knot server
1package oauth
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "net/url"
10 "sync"
11 "time"
12
13 comatproto "github.com/bluesky-social/indigo/api/atproto"
14 "github.com/bluesky-social/indigo/atproto/atclient"
15 "github.com/bluesky-social/indigo/atproto/atcrypto"
16 "github.com/bluesky-social/indigo/atproto/auth/oauth"
17 "github.com/bluesky-social/indigo/atproto/syntax"
18 xrpc "github.com/bluesky-social/indigo/xrpc"
19 "github.com/gorilla/sessions"
20 "github.com/hashicorp/golang-lru/v2/expirable"
21 "github.com/posthog/posthog-go"
22 "golang.org/x/sync/singleflight"
23 "tangled.org/core/appview/config"
24 "tangled.org/core/appview/db"
25 "tangled.org/core/hostutil"
26 "tangled.org/core/idresolver"
27 "tangled.org/core/rbac"
28 "tangled.org/core/xrpc/serviceauth"
29)
30
31const (
32 sessionCacheSize = 10000
33 sessionCacheTTL = time.Hour
34)
35
36type KnotMembership interface {
37 IsKnotMember(ctx context.Context, host, userDid string) bool
38 InvalidateMembers(host string)
39}
40
41type OAuth struct {
42 ClientApp *oauth.ClientApp
43 SessStore *sessions.CookieStore
44 Config *config.Config
45 JwksUri string
46 ClientName string
47 ClientUri string
48 Posthog posthog.Client
49 Db *db.DB
50 Enforcer *rbac.Enforcer
51 Acl KnotMembership
52 IdResolver *idresolver.Resolver
53 Logger *slog.Logger
54
55 appPasswordSession *AppPasswordSession
56 appPasswordSessionMu sync.Mutex
57
58 sessionCache *expirable.LRU[string, *oauth.ClientSession]
59 sessionSF singleflight.Group
60}
61
62func sessionCacheKey(did syntax.DID, sessionId string) string {
63 return string(did) + ":" + sessionId
64}
65
66func (o *OAuth) resumeSession(ctx context.Context, did syntax.DID, sessionId string) (*oauth.ClientSession, error) {
67 key := sessionCacheKey(did, sessionId)
68 if v, ok := o.sessionCache.Get(key); ok {
69 return v, nil
70 }
71 v, err, _ := o.sessionSF.Do(key, func() (any, error) {
72 if v, ok := o.sessionCache.Get(key); ok {
73 return v, nil
74 }
75 sess, err := o.ClientApp.ResumeSession(ctx, did, sessionId)
76 if err != nil {
77 return nil, err
78 }
79 o.sessionCache.Add(key, sess)
80 return sess, nil
81 })
82 if err != nil {
83 return nil, err
84 }
85 return v.(*oauth.ClientSession), nil
86}
87
88func (o *OAuth) EvictSession(did syntax.DID, sessionId string) {
89 o.sessionCache.Remove(sessionCacheKey(did, sessionId))
90}
91
92func (o *OAuth) HandlePermanentAuthErr(ctx context.Context, did syntax.DID, sessionId string, err error) bool {
93 if !IsPermanentAuthErr(err) {
94 return false
95 }
96 o.EvictSession(did, sessionId)
97 if logoutErr := o.ClientApp.Logout(ctx, did, sessionId); logoutErr != nil {
98 o.Logger.Warn("store logout after permanent auth error failed", "did", did, "err", logoutErr)
99 }
100 return true
101}
102
103func New(config *config.Config, ph posthog.Client, db *db.DB, enforcer *rbac.Enforcer, acl KnotMembership, res *idresolver.Resolver, logger *slog.Logger) (*OAuth, error) {
104 var oauthConfig oauth.ClientConfig
105 var clientUri string
106 if config.Core.Dev {
107 clientUri = "http://127.0.0.1:3000"
108 callbackUri := clientUri + "/oauth/callback"
109 oauthConfig = oauth.NewLocalhostConfig(callbackUri, TangledScopes)
110 } else {
111 clientUri = "https://" + config.Core.AppviewHost
112 clientId := fmt.Sprintf("%s/oauth/client-metadata.json", clientUri)
113 callbackUri := clientUri + "/oauth/callback"
114 oauthConfig = oauth.NewPublicConfig(clientId, callbackUri, TangledScopes)
115 }
116
117 // configure client secret
118 priv, err := atcrypto.ParsePrivateMultibase(config.OAuth.ClientSecret)
119 if err != nil {
120 return nil, err
121 }
122 if err := oauthConfig.SetClientSecret(priv, config.OAuth.ClientKid); err != nil {
123 return nil, err
124 }
125
126 jwksUri := clientUri + "/oauth/jwks.json"
127
128 authStore, err := NewRedisStore(&RedisStoreConfig{
129 RedisURL: config.Redis.ToURL(),
130 SessionExpiryDuration: time.Hour * 24 * 90,
131 SessionInactivityDuration: time.Hour * 24 * 14,
132 AuthRequestExpiryDuration: time.Minute * 30,
133 })
134 if err != nil {
135 return nil, err
136 }
137
138 sessStore := sessions.NewCookieStore([]byte(config.Core.CookieSecret))
139 sessStore.Options.SameSite = http.SameSiteLaxMode
140 sessStore.Options.HttpOnly = true
141 sessStore.Options.Secure = !config.Core.Dev
142
143 clientApp := oauth.NewClientApp(&oauthConfig, authStore)
144 clientApp.Dir = res.Directory()
145 // allow non-public transports in dev mode
146 if config.Core.Dev {
147 clientApp.Resolver.Client.Transport = http.DefaultTransport
148 }
149
150 clientName := config.Core.AppviewName
151
152 logger.Info("oauth setup successfully", "IsConfidential", clientApp.Config.IsConfidential())
153 return &OAuth{
154 ClientApp: clientApp,
155 Config: config,
156 SessStore: sessStore,
157 JwksUri: jwksUri,
158 ClientName: clientName,
159 ClientUri: clientUri,
160 Posthog: ph,
161 Db: db,
162 Enforcer: enforcer,
163 Acl: acl,
164 IdResolver: res,
165 Logger: logger,
166 sessionCache: expirable.NewLRU[string, *oauth.ClientSession](sessionCacheSize, nil, sessionCacheTTL),
167 }, nil
168}
169
170func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, sessData *oauth.ClientSessionData) error {
171 userSession, err := o.SessStore.Get(r, SessionName)
172 if err != nil {
173 o.Logger.Warn("failed to decode existing session cookie, will create new", "err", err)
174 }
175
176 userSession.Values[SessionDid] = sessData.AccountDID.String()
177 userSession.Values[SessionPds] = sessData.HostURL
178 userSession.Values[SessionId] = sessData.SessionID
179 userSession.Values[SessionAuthenticated] = true
180
181 if err := userSession.Save(r, w); err != nil {
182 return err
183 }
184
185 handle := ""
186 resolved, err := o.IdResolver.ResolveIdent(r.Context(), sessData.AccountDID.String())
187 if err == nil && resolved.Handle.String() != "" {
188 handle = resolved.Handle.String()
189 }
190
191 registry := o.GetAccounts(r)
192 if err := registry.AddAccount(sessData.AccountDID.String(), handle, sessData.SessionID); err != nil {
193 return err
194 }
195 return o.saveAccounts(w, r, registry)
196}
197
198func (o *OAuth) ResumeSession(r *http.Request) (*oauth.ClientSession, error) {
199 userSession, err := o.SessStore.Get(r, SessionName)
200 if err != nil {
201 return nil, fmt.Errorf("error getting user session: %w", err)
202 }
203 if userSession.IsNew {
204 return nil, fmt.Errorf("no session available for user")
205 }
206
207 d := userSession.Values[SessionDid].(string)
208 sessDid, err := syntax.ParseDID(d)
209 if err != nil {
210 return nil, fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
211 }
212
213 sessId := userSession.Values[SessionId].(string)
214
215 clientSess, err := o.resumeSession(r.Context(), sessDid, sessId)
216 if err != nil {
217 return nil, fmt.Errorf("failed to resume session: %w", err)
218 }
219
220 return clientSess, nil
221}
222
223func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error {
224 userSession, err := o.SessStore.Get(r, SessionName)
225 if err != nil {
226 return fmt.Errorf("error getting user session: %w", err)
227 }
228 if userSession.IsNew {
229 return fmt.Errorf("no session available for user")
230 }
231
232 d := userSession.Values[SessionDid].(string)
233 sessDid, err := syntax.ParseDID(d)
234 if err != nil {
235 return fmt.Errorf("malformed DID in session cookie '%s': %w", d, err)
236 }
237
238 sessId := userSession.Values[SessionId].(string)
239
240 o.EvictSession(sessDid, sessId)
241
242 // delete the session
243 err1 := o.ClientApp.Logout(r.Context(), sessDid, sessId)
244 if err1 != nil {
245 err1 = fmt.Errorf("failed to logout: %w", err1)
246 }
247 o.EvictSession(sessDid, sessId)
248
249 // remove the cookie
250 userSession.Options.MaxAge = -1
251 err2 := o.SessStore.Save(r, w, userSession)
252 if err2 != nil {
253 err2 = fmt.Errorf("failed to save into session store: %w", err2)
254 }
255
256 return errors.Join(err1, err2)
257}
258
259func (o *OAuth) SwitchAccount(w http.ResponseWriter, r *http.Request, targetDid string) error {
260 registry := o.GetAccounts(r)
261 account := registry.FindAccount(targetDid)
262 if account == nil {
263 return fmt.Errorf("account not found in registry: %s", targetDid)
264 }
265
266 did, err := syntax.ParseDID(targetDid)
267 if err != nil {
268 return fmt.Errorf("invalid DID: %w", err)
269 }
270
271 sess, err := o.resumeSession(r.Context(), did, account.SessionId)
272 if err != nil {
273 registry.RemoveAccount(targetDid)
274 _ = o.saveAccounts(w, r, registry)
275 return fmt.Errorf("session expired for account: %w", err)
276 }
277
278 userSession, err := o.SessStore.Get(r, SessionName)
279 if err != nil {
280 return err
281 }
282
283 userSession.Values[SessionDid] = sess.Data.AccountDID.String()
284 userSession.Values[SessionPds] = sess.Data.HostURL
285 userSession.Values[SessionId] = sess.Data.SessionID
286 userSession.Values[SessionAuthenticated] = true
287
288 return userSession.Save(r, w)
289}
290
291func (o *OAuth) RemoveAccount(w http.ResponseWriter, r *http.Request, targetDid string) error {
292 registry := o.GetAccounts(r)
293 account := registry.FindAccount(targetDid)
294 if account == nil {
295 return nil
296 }
297
298 did, err := syntax.ParseDID(targetDid)
299 if err == nil {
300 o.EvictSession(did, account.SessionId)
301 _ = o.ClientApp.Logout(r.Context(), did, account.SessionId)
302 o.EvictSession(did, account.SessionId)
303 }
304
305 registry.RemoveAccount(targetDid)
306 return o.saveAccounts(w, r, registry)
307}
308
309func (o *OAuth) GetDid(r *http.Request) string {
310 if u := o.GetMultiAccountUser(r); u != nil {
311 return u.Did
312 }
313
314 return ""
315}
316
317func (o *OAuth) GetDidFromCookie(r *http.Request) syntax.DID {
318 userSession, err := o.SessStore.Get(r, SessionName)
319 if err != nil || userSession.IsNew {
320 return ""
321 }
322 d, ok := userSession.Values[SessionDid].(string)
323 if !ok {
324 return ""
325 }
326 parsed, err := syntax.ParseDID(d)
327 if err != nil {
328 return ""
329 }
330 return parsed
331}
332
333func (o *OAuth) GetSessIdFromCookie(r *http.Request) string {
334 userSession, err := o.SessStore.Get(r, SessionName)
335 if err != nil || userSession.IsNew {
336 return ""
337 }
338 s, ok := userSession.Values[SessionId].(string)
339 if !ok {
340 return ""
341 }
342 return s
343}
344
345func (o *OAuth) AuthorizedClient(r *http.Request) (*atclient.APIClient, error) {
346 session, err := o.ResumeSession(r)
347 if err != nil {
348 return nil, fmt.Errorf("error getting session: %w", err)
349 }
350 return session.APIClient(), nil
351}
352
353// this is a higher level abstraction on ServerGetServiceAuth
354type ServiceClientOpts struct {
355 service string
356 exp int64
357 lxm string
358 dev bool
359 timeout time.Duration
360}
361
362type ServiceClientOpt func(*ServiceClientOpts)
363
364func DefaultServiceClientOpts() ServiceClientOpts {
365 return ServiceClientOpts{
366 timeout: time.Second * 5,
367 }
368}
369
370func WithService(service string) ServiceClientOpt {
371 return func(s *ServiceClientOpts) {
372 s.service = service
373 }
374}
375
376// Specify the Duration in seconds for the expiry of this token
377//
378// The time of expiry is calculated as time.Now().Unix() + exp
379func WithExp(exp int64) ServiceClientOpt {
380 return func(s *ServiceClientOpts) {
381 s.exp = time.Now().Unix() + exp
382 }
383}
384
385func WithLxm(lxm string) ServiceClientOpt {
386 return func(s *ServiceClientOpts) {
387 s.lxm = lxm
388 }
389}
390
391func WithDev(dev bool) ServiceClientOpt {
392 return func(s *ServiceClientOpts) {
393 s.dev = dev
394 }
395}
396
397func WithTimeout(timeout time.Duration) ServiceClientOpt {
398 return func(s *ServiceClientOpts) {
399 s.timeout = timeout
400 }
401}
402
403func (s *ServiceClientOpts) Audience() string {
404 return serviceauth.DidWeb(s.service).String()
405}
406
407func (s *ServiceClientOpts) Host() string {
408 scheme := "https://"
409 if s.dev {
410 scheme = "http://"
411 }
412
413 return scheme + s.service
414}
415
416func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) {
417 client, err := o.AuthorizedClient(r)
418 if err != nil {
419 return nil, err
420 }
421
422 opts := DefaultServiceClientOpts()
423 for _, o := range os {
424 o(&opts)
425 }
426
427 // force expiry to atleast 60 seconds in the future
428 sixty := time.Now().Unix() + 60
429 if opts.exp < sixty {
430 opts.exp = sixty
431 }
432
433 resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm)
434 if err != nil {
435 return nil, err
436 }
437
438 return &xrpc.Client{
439 Auth: &xrpc.AuthInfo{
440 AccessJwt: resp.Token,
441 },
442 Host: opts.Host(),
443 Client: &http.Client{
444 Timeout: opts.timeout,
445 },
446 }, nil
447}
448
449func (o *OAuth) SpindleServiceClient(r *http.Request, spindle, lxm string) (*xrpc.Client, error) {
450 hostname, noTLS, err := hostutil.ParseHostname(spindle)
451 if err != nil {
452 return nil, err
453 }
454 return o.ServiceClient(
455 r,
456 WithService(hostname),
457 WithLxm(lxm),
458 WithDev(noTLS),
459 WithTimeout(time.Second*30),
460 )
461}
462
463func (o *OAuth) StartElevatedAuthFlow(ctx context.Context, w http.ResponseWriter, r *http.Request, did string, extraScopes []string, returnURL string) (string, error) {
464 parsedDid, err := syntax.ParseDID(did)
465 if err != nil {
466 return "", fmt.Errorf("invalid DID: %w", err)
467 }
468
469 ident, err := o.ClientApp.Dir.Lookup(ctx, parsedDid.AtIdentifier())
470 if err != nil {
471 return "", fmt.Errorf("failed to resolve DID (%s): %w", did, err)
472 }
473
474 host := ident.PDSEndpoint()
475 if host == "" {
476 return "", fmt.Errorf("identity does not link to an atproto host (PDS)")
477 }
478
479 authserverURL, err := o.ClientApp.Resolver.ResolveAuthServerURL(ctx, host)
480 if err != nil {
481 return "", fmt.Errorf("resolving auth server: %w", err)
482 }
483
484 authserverMeta, err := o.ClientApp.Resolver.ResolveAuthServerMetadata(ctx, authserverURL)
485 if err != nil {
486 return "", fmt.Errorf("fetching auth server metadata: %w", err)
487 }
488
489 scopes := make([]string, 0, len(TangledScopes)+len(extraScopes))
490 scopes = append(scopes, TangledScopes...)
491 scopes = append(scopes, extraScopes...)
492
493 loginHint := did
494 if ident.Handle != "" && !ident.Handle.IsInvalidHandle() {
495 loginHint = ident.Handle.String()
496 }
497
498 info, err := o.ClientApp.SendAuthRequest(ctx, authserverMeta, scopes, loginHint)
499 if err != nil {
500 return "", fmt.Errorf("auth request failed: %w", err)
501 }
502
503 info.AccountDID = &parsedDid
504 o.ClientApp.Store.SaveAuthRequestInfo(ctx, *info)
505
506 if err := o.SetAuthReturn(w, r, returnURL); err != nil {
507 return "", fmt.Errorf("failed to set auth return: %w", err)
508 }
509
510 redirectURL := fmt.Sprintf("%s?client_id=%s&request_uri=%s",
511 authserverMeta.AuthorizationEndpoint,
512 url.QueryEscape(o.ClientApp.Config.ClientID),
513 url.QueryEscape(info.RequestURI),
514 )
515
516 return redirectURL, nil
517}