Monorepo for Tangled tangled.org

appview/auth: implement background session refresh

After successful login, we start a goroutine to handle refreshing the
session token in the background, with session expiry defined in
auth.ExpiryDuration.

To kill the goroutine cleanly at Logout, s.sessionCancelFuncs maintains
a map of did->cancelFunc which we look up and call at logout.

Accounting for this map getting cleared at program end, we restore a
token refresher at the *first* authenticated request using RestoreSessionIfNeeded
in the AuthMiddleware.

anirudh.fi 7e904629 53feba3a

verified
Changed files
+167 -46
appview
+59 -1
appview/auth/auth.go
··· 13 13 "github.com/sotangled/tangled/appview" 14 14 ) 15 15 16 + const ExpiryDuration = 15 * time.Minute 17 + 16 18 type Auth struct { 17 19 Store *sessions.CookieStore 18 20 } ··· 61 63 GetStatus() *string 62 64 } 63 65 66 + type ClientSessionish struct { 67 + sessions.Session 68 + } 69 + 70 + func (c *ClientSessionish) GetAccessJwt() string { 71 + return c.Values[appview.SessionAccessJwt].(string) 72 + } 73 + 74 + func (c *ClientSessionish) GetActive() *bool { 75 + return c.Values[appview.SessionAuthenticated].(*bool) 76 + } 77 + 78 + func (c *ClientSessionish) GetDid() string { 79 + return c.Values[appview.SessionDid].(string) 80 + } 81 + 82 + func (c *ClientSessionish) GetDidDoc() *interface{} { 83 + return nil 84 + } 85 + 86 + func (c *ClientSessionish) GetHandle() string { 87 + return c.Values[appview.SessionHandle].(string) 88 + } 89 + 90 + func (c *ClientSessionish) GetRefreshJwt() string { 91 + return c.Values[appview.SessionRefreshJwt].(string) 92 + } 93 + 94 + func (c *ClientSessionish) GetStatus() *string { 95 + return nil 96 + } 97 + 64 98 // Create a wrapper type for ServerRefreshSession_Output 65 99 type RefreshSessionWrapper struct { 66 100 *comatproto.ServerRefreshSession_Output ··· 140 174 clientSession.Values[appview.SessionPds] = pdsEndpoint 141 175 clientSession.Values[appview.SessionAccessJwt] = atSessionish.GetAccessJwt() 142 176 clientSession.Values[appview.SessionRefreshJwt] = atSessionish.GetRefreshJwt() 143 - clientSession.Values[appview.SessionExpiry] = time.Now().Add(time.Minute * 15).Format(time.RFC3339) 177 + clientSession.Values[appview.SessionExpiry] = time.Now().Add(ExpiryDuration).Format(time.RFC3339) 144 178 clientSession.Values[appview.SessionAuthenticated] = true 145 179 return clientSession.Save(r, w) 180 + } 181 + 182 + func (a *Auth) RefreshSession(ctx context.Context, r *http.Request, w http.ResponseWriter, atSessionish Sessionish, pdsEndpoint string) error { 183 + client := xrpc.Client{ 184 + Host: pdsEndpoint, 185 + Auth: &xrpc.AuthInfo{ 186 + Did: atSessionish.GetDid(), 187 + AccessJwt: atSessionish.GetRefreshJwt(), 188 + RefreshJwt: atSessionish.GetRefreshJwt(), 189 + }, 190 + } 191 + 192 + atSession, err := comatproto.ServerRefreshSession(ctx, &client) 193 + if err != nil { 194 + return fmt.Errorf("failed to refresh session: %w", err) 195 + } 196 + 197 + newAtSessionish := &RefreshSessionWrapper{atSession} 198 + err = a.StoreSession(r, w, newAtSessionish, pdsEndpoint) 199 + if err != nil { 200 + return fmt.Errorf("failed to store refreshed session: %w", err) 201 + } 202 + 203 + return nil 146 204 } 147 205 148 206 func (a *Auth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) {
+9 -44
appview/state/middleware.go
··· 5 5 "log" 6 6 "net/http" 7 7 "strings" 8 - "time" 9 8 10 - comatproto "github.com/bluesky-social/indigo/api/atproto" 11 9 "github.com/bluesky-social/indigo/atproto/identity" 12 - "github.com/bluesky-social/indigo/xrpc" 13 10 "github.com/go-chi/chi/v5" 14 11 "github.com/sotangled/tangled/appview" 15 - "github.com/sotangled/tangled/appview/auth" 16 12 "github.com/sotangled/tangled/appview/db" 17 13 ) 18 14 ··· 21 17 func AuthMiddleware(s *State) Middleware { 22 18 return func(next http.Handler) http.Handler { 23 19 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 - session, _ := s.auth.Store.Get(r, appview.SessionName) 25 - authorized, ok := session.Values[appview.SessionAuthenticated].(bool) 26 - if !ok || !authorized { 27 - log.Printf("not logged in, redirecting") 20 + if s.auth == nil { 28 21 http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 29 22 return 30 23 } 31 - 32 - // refresh if nearing expiry 33 - // TODO: dedup with /login 34 - expiryStr := session.Values[appview.SessionExpiry].(string) 35 - expiry, err := time.Parse(time.RFC3339, expiryStr) 24 + err := s.RestoreSessionIfNeeded(r, w) 36 25 if err != nil { 37 - log.Println("invalid expiry time", err) 38 26 http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 39 27 return 40 28 } 41 - pdsUrl := session.Values[appview.SessionPds].(string) 42 - did := session.Values[appview.SessionDid].(string) 43 - refreshJwt := session.Values[appview.SessionRefreshJwt].(string) 44 29 45 - if time.Now().After(expiry) { 46 - log.Println("token expired, refreshing ...") 47 - 48 - client := xrpc.Client{ 49 - Host: pdsUrl, 50 - Auth: &xrpc.AuthInfo{ 51 - Did: did, 52 - AccessJwt: refreshJwt, 53 - RefreshJwt: refreshJwt, 54 - }, 55 - } 56 - atSession, err := comatproto.ServerRefreshSession(r.Context(), &client) 57 - if err != nil { 58 - log.Println("failed to refresh session", err) 59 - http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 60 - return 61 - } 62 - 63 - sessionish := auth.RefreshSessionWrapper{atSession} 64 - 65 - err = s.auth.StoreSession(r, w, &sessionish, pdsUrl) 66 - if err != nil { 67 - log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err) 68 - return 69 - } 70 - 71 - log.Println("successfully refreshed token") 30 + session, _ := s.auth.Store.Get(r, appview.SessionName) 31 + authorized, ok := session.Values[appview.SessionAuthenticated].(bool) 32 + if !ok || !authorized { 33 + log.Printf("not logged in, redirecting") 34 + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 35 + return 72 36 } 73 37 38 + // refresh if nearing expiry 74 39 next.ServeHTTP(w, r) 75 40 }) 76 41 }
+72
appview/state/session.go
··· 1 + package state 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "net/http" 8 + "time" 9 + 10 + "github.com/gorilla/sessions" 11 + "github.com/sotangled/tangled/appview" 12 + "github.com/sotangled/tangled/appview/auth" 13 + ) 14 + 15 + func (s *State) StartTokenRefresher( 16 + ctx context.Context, 17 + refreshInterval time.Duration, 18 + r *http.Request, 19 + w http.ResponseWriter, 20 + atSessionish auth.Sessionish, 21 + pdsEndpoint string, 22 + ) { 23 + go func() { 24 + ticker := time.NewTicker(refreshInterval) 25 + defer ticker.Stop() 26 + 27 + for { 28 + select { 29 + case <-ticker.C: 30 + err := s.auth.RefreshSession(ctx, r, w, atSessionish, pdsEndpoint) 31 + if err != nil { 32 + log.Printf("token refresh failed: %v", err) 33 + } else { 34 + log.Println("token refreshed successfully") 35 + } 36 + case <-ctx.Done(): 37 + log.Println("stopping token refresher") 38 + return 39 + } 40 + } 41 + }() 42 + } 43 + 44 + // RestoreSessionIfNeeded checks if a session exists in the request and starts a 45 + // token refresher if it doesn't have one running already. 46 + func (s *State) RestoreSessionIfNeeded(r *http.Request, w http.ResponseWriter) error { 47 + var session *sessions.Session 48 + var err error 49 + session, err = s.auth.GetSession(r) 50 + if err != nil { 51 + fmt.Errorf("error getting session: %w", err) 52 + } 53 + 54 + did, ok := session.Values[appview.SessionDid].(string) 55 + if !ok { 56 + return fmt.Errorf("session did not contain a did") 57 + } 58 + sessionish := auth.ClientSessionish{Session: *session} 59 + pdsEndpoint := session.Values[appview.SessionPds].(string) 60 + 61 + // If no refresher is running for this session, start one 62 + if _, exists := s.sessionCancelFuncs[did]; !exists { 63 + sessionCtx, cancel := context.WithCancel(context.Background()) 64 + s.sessionCancelFuncs[did] = cancel 65 + 66 + s.StartTokenRefresher(sessionCtx, auth.ExpiryDuration, r, w, &sessionish, pdsEndpoint) 67 + 68 + log.Printf("restored session refresher for %s", did) 69 + } 70 + 71 + return nil 72 + }
+27 -1
appview/state/state.go
··· 36 36 resolver *appview.Resolver 37 37 jc *jetstream.JetstreamClient 38 38 config *appview.Config 39 + 40 + sessionCancelFuncs map[string]context.CancelFunc 39 41 } 40 42 41 43 func Make(config *appview.Config) (*State, error) { ··· 70 72 return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 71 73 } 72 74 75 + sessionCancelFuncs := make(map[string]context.CancelFunc) 76 + 73 77 state := &State{ 74 78 d, 75 79 auth, ··· 79 83 resolver, 80 84 jc, 81 85 config, 86 + sessionCancelFuncs, 82 87 } 83 88 84 89 return state, nil ··· 123 128 } 124 129 125 130 log.Printf("successfully saved session for %s (%s)", atSession.Handle, atSession.Did) 131 + 132 + sessionCtx, cancel := context.WithCancel(context.Background()) 133 + s.sessionCancelFuncs[sessionish.GetDid()] = cancel 134 + expiry := auth.ExpiryDuration 135 + 136 + go s.StartTokenRefresher(sessionCtx, expiry, r, w, &sessionish, resolved.PDSEndpoint()) 137 + 126 138 s.pages.HxRedirect(w, "/") 127 139 return 128 140 } 129 141 } 130 142 131 143 func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 144 + session, err := s.auth.GetSession(r) 145 + did := session.Values[appview.SessionDid].(string) 146 + if err == nil { 147 + if cancel, exists := s.sessionCancelFuncs[did]; exists { 148 + cancel() 149 + delete(s.sessionCancelFuncs, did) 150 + } 151 + } 152 + 132 153 s.auth.ClearSession(r, w) 133 154 http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 134 155 } ··· 512 533 switch r.Method { 513 534 case http.MethodGet: 514 535 user := s.auth.GetUser(r) 536 + err := s.enforcer.AddMember("knot1.tangled.sh", user.Did) 537 + if err != nil { 538 + log.Println("failed to add user to knot1.tangled.sh: ", err) 539 + s.pages.Notice(w, "repo", "Failed to add user to knot1.tangled.sh. You should be able to use your own knot however.") 540 + } 541 + 515 542 knots, err := s.enforcer.GetDomainsForUser(user.Did) 516 - 517 543 if err != nil { 518 544 s.pages.Notice(w, "repo", "Invalid user account.") 519 545 return