Caddy module to require at-proto authentication and restrict routes to DIDs
3

Configure Feed

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

feat: Implement transparent session refresh for less logins

+94 -2
+1
README.md
··· 8 8 9 9 - **Zero-Dependency**: Plugs directly into Caddy, no external databases (uses embedded SQLite). 10 10 - **Stateless Verification**: Uses signed, domain-scoped cookies for lightning-fast request verification at the edge without database lookups. 11 + - **Transparent Session Refresh**: Automatically uses OAuth Refresh Tokens to extend sessions in the background, minimizing forced re-logins. 11 12 - **Two Deployment Modes**: 12 13 - *Standalone*: Add to any individual app's Caddyfile route directly. 13 14 - *Centralized Hub*: Act as an Identity Provider (`auth.example.com`) granting SSO access to many subdomains (`app.example.com`).
+37 -1
gate.go
··· 185 185 186 186 // 1. Verify stateless cookie here 187 187 sess, err := g.sessions.VerifyCookie(r) 188 - if err == nil { 188 + if err == session.ErrExpired { 189 + // Attempt transparent refresh if we are in a mode that supports it. 190 + // We need an OAuth manager to refresh. 191 + // If Standalone, g.oauth is set. 192 + // If Auth Hub, g.oauth is nil in Gate. Gate relies on Portal. 193 + // However, Gate and Portal SHARE the same DB (g.app.Store). 194 + // We can spin up a temporary OAuth manager or use a shared one if we had config. 195 + // But Gate in Hub mode doesn't know ClientID/CallbackURL. 196 + // Wait, the Refresh Token is bound to the ClientID. 197 + // If Gate is just a gate, it can't refresh on behalf of the Portal unless it acts AS the Portal client. 198 + 199 + // For Standalone mode, we can refresh. 200 + if g.oauth != nil && sess != nil { 201 + clientSession, err := g.oauth.ResumeSession(r.Context(), sess.DID) 202 + if err == nil { 203 + // Refresh tokens 204 + if _, err := clientSession.RefreshTokens(r.Context()); err == nil { 205 + // Success! Update cookie. 206 + // We need to extend expiration. 207 + cookie, err := g.sessions.CreateCookie( 208 + clientSession.Data.AccountDID, 209 + sess.Handle, // Keep handle from old cookie or lookup 210 + 24*7*time.Hour, 211 + g.Domain, 212 + ) 213 + if err == nil { 214 + http.SetCookie(w, cookie) 215 + // Proceed as authorized 216 + r.Header.Set("X-Atproto-Did", sess.DID) 217 + r.Header.Set("X-Atproto-Handle", sess.Handle) 218 + return next.ServeHTTP(w, r) 219 + } 220 + } 221 + } 222 + // If refresh failed, fall through to re-login logic 223 + } 224 + } else if err == nil { 189 225 // Session valid! 190 226 // Check authorization against allowlist 191 227 allowed := false
+19
internal/db/db.go
··· 103 103 return pk, "client_key", nil 104 104 } 105 105 106 + // GetLatestSession returns the most recently updated session for a DID. 107 + func (s *Store) GetLatestSession(ctx context.Context, did syntax.DID) (*oauth.ClientSessionData, error) { 108 + var dataStr string 109 + err := s.db.QueryRowContext(ctx, "SELECT data FROM auth_sessions WHERE did = ? ORDER BY updated_at DESC LIMIT 1", did.String()).Scan(&dataStr) 110 + if err != nil { 111 + if err == sql.ErrNoRows { 112 + return nil, nil 113 + } 114 + return nil, fmt.Errorf("failed to query latest session: %w", err) 115 + } 116 + 117 + var sessionData oauth.ClientSessionData 118 + if err := json.Unmarshal([]byte(dataStr), &sessionData); err != nil { 119 + return nil, fmt.Errorf("failed to parse session data: %w", err) 120 + } 121 + 122 + return &sessionData, nil 123 + } 124 + 106 125 // GetSession retrieves session data from the database. 107 126 func (s *Store) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 108 127 var dataStr string
+33
internal/oauth/manager.go
··· 8 8 9 9 "github.com/bluesky-social/indigo/atproto/atcrypto" 10 10 indigoOauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 11 12 12 13 "tangled.org/vvill.dev/caddy-atproto-auth/internal/db" 13 14 ) ··· 85 86 return "", fmt.Errorf("failed to start auth flow: %w", err) 86 87 } 87 88 return redirectURI, nil 89 + } 90 + 91 + // ResumeSession attempts to load and refresh a session for the given DID 92 + func (m *Manager) ResumeSession(ctx context.Context, didStr string) (*indigoOauth.ClientSession, error) { 93 + did, err := syntax.ParseDID(didStr) 94 + if err != nil { 95 + return nil, fmt.Errorf("invalid DID: %w", err) 96 + } 97 + 98 + // We assume the most recent session for this DID is the active one. 99 + // This relies on GetLatestSession which we added to our Store. 100 + // Since our Store implements ClientAuthStore, we can cast it to our db.Store type if needed, 101 + // but Manager holds db.Store as well? No, it holds indigoOauth.ClientAuthStore interface inside ClientApp. 102 + // But NewManager takes *db.Store. We should probably keep a reference or cast. 103 + // Since NewManager is passed *db.Store, we can add it to struct. 104 + 105 + // Wait, we need to cast m.App.Store back to *db.Store to use GetLatestSession 106 + store, ok := m.App.Store.(*db.Store) 107 + if !ok { 108 + return nil, fmt.Errorf("store is not of expected type") 109 + } 110 + 111 + latestSessionData, err := store.GetLatestSession(ctx, did) 112 + if err != nil { 113 + return nil, fmt.Errorf("failed to get latest session: %w", err) 114 + } 115 + if latestSessionData == nil { 116 + return nil, fmt.Errorf("no session found for DID") 117 + } 118 + 119 + // Now resume using the session ID found 120 + return m.App.ResumeSession(ctx, did, latestSessionData.SessionID) 88 121 } 89 122 90 123 // ProcessCallback exchanges the authorization code for an access token
+4 -1
internal/session/session.go
··· 108 108 } 109 109 110 110 if time.Now().Unix() > sess.ExpiresAt { 111 - return nil, errors.New("session expired") 111 + return &sess, ErrExpired 112 112 } 113 113 114 114 return &sess, nil 115 115 } 116 + 117 + // ErrExpired indicates the session is valid but expired 118 + var ErrExpired = errors.New("session expired") 116 119 117 120 // ClearCookie returns a cookie that clears the session. 118 121 func (m *Manager) ClearCookie(domain string) *http.Cookie {