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
14 kB 501 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 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}