This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / oauth / oauth.go
13 kB 491 lines
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}