Monorepo for Tangled tangled.org
1

Configure Feed

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

core / appview / oauth / oauth.go
14 kB 517 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 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}