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