···8899- **Zero-Dependency**: Plugs directly into Caddy, no external databases (uses embedded SQLite).
1010- **Stateless Verification**: Uses signed, domain-scoped cookies for lightning-fast request verification at the edge without database lookups.
1111+- **Transparent Session Refresh**: Automatically uses OAuth Refresh Tokens to extend sessions in the background, minimizing forced re-logins.
1112- **Two Deployment Modes**:
1213 - *Standalone*: Add to any individual app's Caddyfile route directly.
1314 - *Centralized Hub*: Act as an Identity Provider (`auth.example.com`) granting SSO access to many subdomains (`app.example.com`).
+37-1
gate.go
···185185186186 // 1. Verify stateless cookie here
187187 sess, err := g.sessions.VerifyCookie(r)
188188- if err == nil {
188188+ if err == session.ErrExpired {
189189+ // Attempt transparent refresh if we are in a mode that supports it.
190190+ // We need an OAuth manager to refresh.
191191+ // If Standalone, g.oauth is set.
192192+ // If Auth Hub, g.oauth is nil in Gate. Gate relies on Portal.
193193+ // However, Gate and Portal SHARE the same DB (g.app.Store).
194194+ // We can spin up a temporary OAuth manager or use a shared one if we had config.
195195+ // But Gate in Hub mode doesn't know ClientID/CallbackURL.
196196+ // Wait, the Refresh Token is bound to the ClientID.
197197+ // If Gate is just a gate, it can't refresh on behalf of the Portal unless it acts AS the Portal client.
198198+199199+ // For Standalone mode, we can refresh.
200200+ if g.oauth != nil && sess != nil {
201201+ clientSession, err := g.oauth.ResumeSession(r.Context(), sess.DID)
202202+ if err == nil {
203203+ // Refresh tokens
204204+ if _, err := clientSession.RefreshTokens(r.Context()); err == nil {
205205+ // Success! Update cookie.
206206+ // We need to extend expiration.
207207+ cookie, err := g.sessions.CreateCookie(
208208+ clientSession.Data.AccountDID,
209209+ sess.Handle, // Keep handle from old cookie or lookup
210210+ 24*7*time.Hour,
211211+ g.Domain,
212212+ )
213213+ if err == nil {
214214+ http.SetCookie(w, cookie)
215215+ // Proceed as authorized
216216+ r.Header.Set("X-Atproto-Did", sess.DID)
217217+ r.Header.Set("X-Atproto-Handle", sess.Handle)
218218+ return next.ServeHTTP(w, r)
219219+ }
220220+ }
221221+ }
222222+ // If refresh failed, fall through to re-login logic
223223+ }
224224+ } else if err == nil {
189225 // Session valid!
190226 // Check authorization against allowlist
191227 allowed := false
+19
internal/db/db.go
···103103 return pk, "client_key", nil
104104}
105105106106+// GetLatestSession returns the most recently updated session for a DID.
107107+func (s *Store) GetLatestSession(ctx context.Context, did syntax.DID) (*oauth.ClientSessionData, error) {
108108+ var dataStr string
109109+ err := s.db.QueryRowContext(ctx, "SELECT data FROM auth_sessions WHERE did = ? ORDER BY updated_at DESC LIMIT 1", did.String()).Scan(&dataStr)
110110+ if err != nil {
111111+ if err == sql.ErrNoRows {
112112+ return nil, nil
113113+ }
114114+ return nil, fmt.Errorf("failed to query latest session: %w", err)
115115+ }
116116+117117+ var sessionData oauth.ClientSessionData
118118+ if err := json.Unmarshal([]byte(dataStr), &sessionData); err != nil {
119119+ return nil, fmt.Errorf("failed to parse session data: %w", err)
120120+ }
121121+122122+ return &sessionData, nil
123123+}
124124+106125// GetSession retrieves session data from the database.
107126func (s *Store) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
108127 var dataStr string
+33
internal/oauth/manager.go
···8899 "github.com/bluesky-social/indigo/atproto/atcrypto"
1010 indigoOauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
1111+ "github.com/bluesky-social/indigo/atproto/syntax"
11121213 "tangled.org/vvill.dev/caddy-atproto-auth/internal/db"
1314)
···8586 return "", fmt.Errorf("failed to start auth flow: %w", err)
8687 }
8788 return redirectURI, nil
8989+}
9090+9191+// ResumeSession attempts to load and refresh a session for the given DID
9292+func (m *Manager) ResumeSession(ctx context.Context, didStr string) (*indigoOauth.ClientSession, error) {
9393+ did, err := syntax.ParseDID(didStr)
9494+ if err != nil {
9595+ return nil, fmt.Errorf("invalid DID: %w", err)
9696+ }
9797+9898+ // We assume the most recent session for this DID is the active one.
9999+ // This relies on GetLatestSession which we added to our Store.
100100+ // Since our Store implements ClientAuthStore, we can cast it to our db.Store type if needed,
101101+ // but Manager holds db.Store as well? No, it holds indigoOauth.ClientAuthStore interface inside ClientApp.
102102+ // But NewManager takes *db.Store. We should probably keep a reference or cast.
103103+ // Since NewManager is passed *db.Store, we can add it to struct.
104104+105105+ // Wait, we need to cast m.App.Store back to *db.Store to use GetLatestSession
106106+ store, ok := m.App.Store.(*db.Store)
107107+ if !ok {
108108+ return nil, fmt.Errorf("store is not of expected type")
109109+ }
110110+111111+ latestSessionData, err := store.GetLatestSession(ctx, did)
112112+ if err != nil {
113113+ return nil, fmt.Errorf("failed to get latest session: %w", err)
114114+ }
115115+ if latestSessionData == nil {
116116+ return nil, fmt.Errorf("no session found for DID")
117117+ }
118118+119119+ // Now resume using the session ID found
120120+ return m.App.ResumeSession(ctx, did, latestSessionData.SessionID)
88121}
8912290123// ProcessCallback exchanges the authorization code for an access token
+4-1
internal/session/session.go
···108108 }
109109110110 if time.Now().Unix() > sess.ExpiresAt {
111111- return nil, errors.New("session expired")
111111+ return &sess, ErrExpired
112112 }
113113114114 return &sess, nil
115115}
116116+117117+// ErrExpired indicates the session is valid but expired
118118+var ErrExpired = errors.New("session expired")
116119117120// ClearCookie returns a cookie that clears the session.
118121func (m *Manager) ClearCookie(domain string) *http.Cookie {