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