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