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
15 kB 557 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/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 353func (o *OAuth) ClientForDidSession(ctx context.Context, did syntax.DID, sessionId string) (*atclient.APIClient, error) { 354 sess, err := o.resumeSession(ctx, did, sessionId) 355 if err != nil { 356 return nil, fmt.Errorf("error getting session: %w", err) 357 } 358 return sess.APIClient(), nil 359} 360 361// this is a higher level abstraction on ServerGetServiceAuth 362type ServiceClientOpts struct { 363 service string 364 exp int64 365 lxm string 366 dev bool 367 timeout time.Duration 368} 369 370type ServiceClientOpt func(*ServiceClientOpts) 371 372func DefaultServiceClientOpts() ServiceClientOpts { 373 return ServiceClientOpts{ 374 timeout: time.Second * 5, 375 } 376} 377 378func WithService(service string) ServiceClientOpt { 379 return func(s *ServiceClientOpts) { 380 s.service = service 381 } 382} 383 384// Specify the Duration in seconds for the expiry of this token 385// 386// The time of expiry is calculated as time.Now().Unix() + exp 387func WithExp(exp int64) ServiceClientOpt { 388 return func(s *ServiceClientOpts) { 389 s.exp = time.Now().Unix() + exp 390 } 391} 392 393func WithLxm(lxm string) ServiceClientOpt { 394 return func(s *ServiceClientOpts) { 395 s.lxm = lxm 396 } 397} 398 399func WithDev(dev bool) ServiceClientOpt { 400 return func(s *ServiceClientOpts) { 401 s.dev = dev 402 } 403} 404 405func WithTimeout(timeout time.Duration) ServiceClientOpt { 406 return func(s *ServiceClientOpts) { 407 s.timeout = timeout 408 } 409} 410 411func (s *ServiceClientOpts) Audience() string { 412 return serviceauth.DidWeb(s.service).String() 413} 414 415func (s *ServiceClientOpts) Host() string { 416 scheme := "https://" 417 if s.dev { 418 scheme = "http://" 419 } 420 421 return scheme + s.service 422} 423 424func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*xrpc.Client, error) { 425 client, err := o.AuthorizedClient(r) 426 if err != nil { 427 return nil, err 428 } 429 430 opts := DefaultServiceClientOpts() 431 for _, o := range os { 432 o(&opts) 433 } 434 435 // force expiry to atleast 60 seconds in the future 436 sixty := time.Now().Unix() + 60 437 if opts.exp < sixty { 438 opts.exp = sixty 439 } 440 441 resp, err := comatproto.ServerGetServiceAuth(r.Context(), client, opts.Audience(), opts.exp, opts.lxm) 442 if err != nil { 443 return nil, err 444 } 445 446 return &xrpc.Client{ 447 Auth: &xrpc.AuthInfo{ 448 AccessJwt: resp.Token, 449 }, 450 Host: opts.Host(), 451 Client: &http.Client{ 452 Timeout: opts.timeout, 453 }, 454 }, nil 455} 456 457func (o *OAuth) SpindleServiceClient(r *http.Request, spindle, lxm string) (*xrpc.Client, error) { 458 hostname, noTLS, err := hostutil.ParseHostname(spindle) 459 if err != nil { 460 return nil, err 461 } 462 return o.ServiceClient( 463 r, 464 WithService(hostname), 465 WithLxm(lxm), 466 WithDev(noTLS), 467 WithTimeout(time.Second*30), 468 ) 469} 470 471func (o *OAuth) ServiceClientForDidSession(ctx context.Context, did syntax.DID, sessionId string, os ...ServiceClientOpt) (*xrpc.Client, error) { 472 opts := DefaultServiceClientOpts() 473 for _, o := range os { 474 o(&opts) 475 } 476 477 client, err := o.ClientForDidSession(ctx, did, sessionId) 478 if err != nil { 479 return nil, err 480 } 481 482 sixty := time.Now().Unix() + 60 483 if opts.exp < sixty { 484 opts.exp = sixty 485 } 486 487 resp, err := comatproto.ServerGetServiceAuth(ctx, client, opts.Audience(), opts.exp, opts.lxm) 488 if err != nil { 489 return nil, err 490 } 491 492 return &xrpc.Client{ 493 Auth: &xrpc.AuthInfo{ 494 AccessJwt: resp.Token, 495 }, 496 Host: opts.Host(), 497 Client: &http.Client{ 498 Timeout: opts.timeout, 499 }, 500 }, nil 501} 502 503func (o *OAuth) StartElevatedAuthFlow(ctx context.Context, w http.ResponseWriter, r *http.Request, did string, extraScopes []string, returnURL string) (string, error) { 504 parsedDid, err := syntax.ParseDID(did) 505 if err != nil { 506 return "", fmt.Errorf("invalid DID: %w", err) 507 } 508 509 ident, err := o.ClientApp.Dir.Lookup(ctx, parsedDid.AtIdentifier()) 510 if err != nil { 511 return "", fmt.Errorf("failed to resolve DID (%s): %w", did, err) 512 } 513 514 host := ident.PDSEndpoint() 515 if host == "" { 516 return "", fmt.Errorf("identity does not link to an atproto host (PDS)") 517 } 518 519 authserverURL, err := o.ClientApp.Resolver.ResolveAuthServerURL(ctx, host) 520 if err != nil { 521 return "", fmt.Errorf("resolving auth server: %w", err) 522 } 523 524 authserverMeta, err := o.ClientApp.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) 525 if err != nil { 526 return "", fmt.Errorf("fetching auth server metadata: %w", err) 527 } 528 529 scopes := make([]string, 0, len(TangledScopes)+len(extraScopes)) 530 scopes = append(scopes, TangledScopes...) 531 scopes = append(scopes, extraScopes...) 532 533 loginHint := did 534 if ident.Handle != "" && !ident.Handle.IsInvalidHandle() { 535 loginHint = ident.Handle.String() 536 } 537 538 info, err := o.ClientApp.SendAuthRequest(ctx, authserverMeta, scopes, loginHint) 539 if err != nil { 540 return "", fmt.Errorf("auth request failed: %w", err) 541 } 542 543 info.AccountDID = &parsedDid 544 o.ClientApp.Store.SaveAuthRequestInfo(ctx, *info) 545 546 if err := o.SetAuthReturn(w, r, returnURL); err != nil { 547 return "", fmt.Errorf("failed to set auth return: %w", err) 548 } 549 550 redirectURL := fmt.Sprintf("%s?client_id=%s&request_uri=%s", 551 authserverMeta.AuthorizationEndpoint, 552 url.QueryEscape(o.ClientApp.Config.ClientID), 553 url.QueryEscape(info.RequestURI), 554 ) 555 556 return redirectURL, nil 557}