···6565 # Default: "Authentication Portal"
6666 name "My Services"
67676868+ # Optional domain for the session cookie.
6969+ # Required if the Portal and Gate are on different subdomains.
7070+ # e.g., if portal is auth.example.com and gate is app.example.com, set this to example.com
7171+ # cookie_domain example.com
7272+6873 # Custom UI templates (optional)
6974 ui {
7075 # Path to a custom HTML template for the login page.
···8893app.example.com {
8994 atproto_gate {
9095 # List of allowed identities (DIDs or Handles).
9191- # REQUIRED.
9696+ # OPTIONAL. If omitted, any authenticated user will be allowed.
9297 allow @alice.bsky.social
9398 allow did:plc:1234abcd...
9499···97102 # REQUIRED.
98103 # If the Portal uses a path_prefix (e.g. /auth), append it here (e.g. https://auth.example.com/auth)
99104 portal_url https://auth.example.com
105105+106106+ # Optional domain for the session cookie.
107107+ # Must match the Portal's cookie_domain if it is set.
108108+ # cookie_domain example.com
100109101110 # Client ID for Transparent Refresh (Optional)
102111 # If provided, enables background token refreshing using the shared DB.
+94-37
gate.go
···11package caddyatprotoauth
2233import (
44+ "context"
45 "fmt"
56 "net/http"
67 "net/url"
88+ "strings"
79 "time"
810911 "github.com/caddyserver/caddy/v2"
···2527// Gate acts as a middleware that guards endpoints
2628// and validates the session cookie.
2729type Gate struct {
2828- Allow []string `json:"allow,omitempty"`
2929- ClientID string `json:"client_id,omitempty"` // ClientID for session refreshing (e.g. https://example.com/client-metadata.json)
3030- PortalURL string `json:"portal_url,omitempty"` // URL of the auth portal (e.g. http://localhost:8080 or /)
3131- UI ui.Config `json:"ui,omitempty"` // Custom UI configuration
3232-3333- // Paths configuration - Removed Login/Logout path from Gate as it no longer serves them.
3434- // But it might need to know them for redirection?
3535- // Currently Gate redirects to PortalURL/login.
3636- // If PortalURL is local, we just redirect there.
3030+ Allow []string `json:"allow,omitempty"`
3131+ ClientID string `json:"client_id,omitempty"` // ClientID for session refreshing (e.g. https://example.com/client-metadata.json)
3232+ PortalURL string `json:"portal_url,omitempty"` // URL of the auth portal (e.g. http://localhost:8080 or /)
3333+ CookieDomain string `json:"cookie_domain,omitempty"` // Optional domain for the session cookie (e.g. example.com)
3434+ UI ui.Config `json:"ui,omitempty"` // Custom UI configuration
37353836 // Dependencies
3939- app *App
4040- resolver *resolver.Resolver
4141- sessions *session.Manager
4242- oauth *oauth.Manager
4343- renderer *ui.Renderer
4444- logger *zap.Logger
3737+ app *App
3838+ sessions *session.Manager
3939+ oauth *oauth.Manager
4040+ renderer *ui.Renderer
4141+ logger *zap.Logger
4242+ resolvedDIDs []string
4543}
46444745// CaddyModule returns the Caddy module information.
···6462 g.app = app.(*App)
65636664 // 2. Initialize Session Manager (using global secret)
6767- if g.app.CookieSecret == "" {
6868- return fmt.Errorf("global atproto cookie_secret is required")
6969- }
7070- g.sessions = session.NewManager(g.app.CookieSecret)
7171-7272- // 3. Initialize Identity Resolver
7373- g.resolver = resolver.New()
6565+ g.sessions = g.app.SessionManager
74667567 // 4. Initialize UI Renderer
7668 renderer, err := ui.NewRenderer(g.UI)
···9688 g.PortalURL = "/"
9789 }
98909191+ // 6. Pre-resolve allowed handles to DIDs
9292+ // We need a resolver for this
9393+ resolverInstance := resolver.New()
9494+9595+ g.resolvedDIDs = make([]string, 0, len(g.Allow))
9696+ ctxResolver := context.Background() // Use background context for boot-time resolution
9797+ for _, allow := range g.Allow {
9898+ if allow == "*" {
9999+ g.resolvedDIDs = append(g.resolvedDIDs, "*")
100100+ continue
101101+ }
102102+103103+ // If it's already a DID, append it directly
104104+ if strings.HasPrefix(allow, "did:") {
105105+ g.resolvedDIDs = append(g.resolvedDIDs, allow)
106106+ continue
107107+ }
108108+109109+ // Treat as handle and resolve
110110+ did, err := resolverInstance.ResolveIdentifier(ctxResolver, allow)
111111+ if err != nil {
112112+ g.logger.Warn("failed to resolve handle during provision", zap.String("handle", allow), zap.Error(err))
113113+ } else {
114114+ g.resolvedDIDs = append(g.resolvedDIDs, did)
115115+ }
116116+ }
117117+99118 return nil
100119}
101120102121// Validate checks that the configuration is valid.
103122func (g *Gate) Validate() error {
104104- if len(g.Allow) == 0 {
105105- return fmt.Errorf("atproto_gate requires at least one 'allow' entry")
106106- }
107123 return nil
108124}
109125···124140 return d.ArgErr()
125141 }
126142 g.PortalURL = d.Val()
143143+ case "cookie_domain":
144144+ if !d.NextArg() {
145145+ return d.ArgErr()
146146+ }
147147+ g.CookieDomain = d.Val()
127148 case "ui":
128149 for nesting := d.Nesting(); d.NextBlock(nesting); {
129150 switch d.Val() {
···158179159180// ServeHTTP implements caddyhttp.MiddlewareHandler.
160181func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
182182+ if r.URL.Path == "/logout" && g.PortalURL != "" {
183183+ scheme := "https"
184184+ if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
185185+ scheme = "http"
186186+ }
187187+ host := r.Host
188188+ currentURL := fmt.Sprintf("%s://%s", scheme, host)
189189+190190+ // Ensure PortalURL doesn't end with /
191191+ portalURL := g.PortalURL
192192+ if portalURL == "/" {
193193+ portalURL = ""
194194+ } else if len(portalURL) > 0 && portalURL[len(portalURL)-1] == '/' {
195195+ portalURL = portalURL[:len(portalURL)-1]
196196+ }
197197+198198+ // Also perform local credential invalidation if possible (composite mode)
199199+ sess, err := g.sessions.VerifyCookie(r)
200200+ if err == nil || err == session.ErrExpired {
201201+ if g.oauth != nil {
202202+ if err := g.oauth.Logout(r.Context(), sess.DID, sess.SessionID); err != nil {
203203+ g.logger.Error("failed to revoke session during local logout", zap.Error(err))
204204+ }
205205+ }
206206+ }
207207+208208+ // Clear local session cookie
209209+ http.SetCookie(w, g.sessions.ClearCookie(g.CookieDomain))
210210+211211+ portalLogout := fmt.Sprintf("%s/logout?redirect_to=%s", portalURL, url.QueryEscape(currentURL))
212212+ http.Redirect(w, r, portalLogout, http.StatusFound)
213213+ return nil
214214+ }
215215+161216 // 1. Verify stateless cookie here
162217 sess, err := g.sessions.VerifyCookie(r)
163218 if err == session.ErrExpired {
···166221 // If ClientID is set, g.oauth is set.
167222168223 if g.oauth != nil && sess != nil {
169169- clientSession, err := g.oauth.ResumeSession(r.Context(), sess.DID)
224224+ clientSession, err := g.oauth.ResumeSession(r.Context(), sess.DID, sess.SessionID)
170225 if err == nil {
171226 // Refresh tokens
172227 if _, err := clientSession.RefreshTokens(r.Context()); err == nil {
···174229 // We need to extend expiration.
175230 // Handle lookup might be needed if not in session?
176231 // Sess has Handle.
232232+ cookieDomain := g.CookieDomain
233233+ if cookieDomain == "" {
234234+ cookieDomain = r.Host
235235+ }
236236+ cookieDomain = strings.Split(cookieDomain, ":")[0]
237237+177238 cookie, err := g.sessions.CreateCookie(
178239 clientSession.Data.AccountDID,
179240 sess.Handle, // Keep handle from old cookie
241241+ clientSession.Data.SessionID,
180242 24*7*time.Hour,
181181- // Domain for cookie?
182182- // Previously we used g.Domain. Now we don't have it.
183183- // We can use request host or empty (current domain).
184184- // If we leave it empty, it defaults to host.
185185- // But CreateCookie expects a domain string?
186186- // Let's check session.CreateCookie signature.
187187- r.Host,
243243+ cookieDomain,
188244 )
189245 if err == nil {
190246 http.SetCookie(w, cookie)
247247+ r.AddCookie(cookie)
191248 // Proceed as authorized
192249 r.Header.Set("X-Atproto-Did", sess.DID)
193250 r.Header.Set("X-Atproto-Handle", sess.Handle)
···201258 // Session valid!
202259 // Check authorization against allowlist
203260 allowed := false
204204- for _, allow := range g.Allow {
205205- if allow == sess.DID || allow == sess.Handle {
261261+ for _, allow := range g.resolvedDIDs {
262262+ if allow == "*" || allow == sess.DID {
206263 allowed = true
207264 break
208265 }
···230287231288 // 2. If invalid/missing, initiate redirect to Portal
232289 if g.PortalURL != "" {
233233- // Construct redirect URL: ${PortalURL}/login?redirect_uri=${CurrentURL}
290290+ // Construct redirect URL: ${PortalURL}/login?redirect_to=${CurrentURL}
234291 scheme := "https"
235235- if r.TLS == nil {
292292+ if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
236293 scheme = "http"
237294 }
238295 host := r.Host
···66 "database/sql"
77 "encoding/json"
88 "fmt"
99+ "sync/atomic"
9101011 "github.com/bluesky-social/indigo/atproto/atcrypto"
1112 "github.com/bluesky-social/indigo/atproto/auth/oauth"
···18191920// Store handles SQLite persistence for the plugin.
2021type Store struct {
2121- db *sql.DB
2222+ db *sql.DB
2323+ cleanupCounter uint32
2224}
23252426// NewStore initializes a new SQLite-backed storage.
···127129 return string(secret), nil
128130}
129131130130-// GetLatestSession returns the most recently updated session for a DID.
131131-func (s *Store) GetLatestSession(ctx context.Context, did syntax.DID) (*oauth.ClientSessionData, error) {
132132- var dataStr string
133133- err := s.db.QueryRowContext(ctx, "SELECT data FROM auth_sessions WHERE did = ? ORDER BY updated_at DESC LIMIT 1", did.String()).Scan(&dataStr)
134134- if err != nil {
135135- if err == sql.ErrNoRows {
136136- return nil, nil
137137- }
138138- return nil, fmt.Errorf("failed to query latest session: %w", err)
139139- }
140140-141141- var sessionData oauth.ClientSessionData
142142- if err := json.Unmarshal([]byte(dataStr), &sessionData); err != nil {
143143- return nil, fmt.Errorf("failed to parse session data: %w", err)
144144- }
145145-146146- return &sessionData, nil
147147-}
148148-149132// GetSession retrieves session data from the database.
150133func (s *Store) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
151134 var dataStr string
···196179 return nil
197180}
198181182182+// Cleanup removes expired auth requests and stale sessions to prevent database bloat.
183183+func (s *Store) Cleanup(ctx context.Context) error {
184184+ // Delete auth_requests older than 1 hour
185185+ _, err := s.db.ExecContext(ctx, "DELETE FROM auth_requests WHERE created_at < datetime('now', '-1 hour')")
186186+ if err != nil {
187187+ return fmt.Errorf("cleanup auth_requests failed: %w", err)
188188+ }
189189+190190+ // Delete auth_sessions older than 30 days
191191+ _, err = s.db.ExecContext(ctx, "DELETE FROM auth_sessions WHERE updated_at < datetime('now', '-30 days')")
192192+ if err != nil {
193193+ return fmt.Errorf("cleanup auth_sessions failed: %w", err)
194194+ }
195195+196196+ return nil
197197+}
198198+199199// GetAuthRequestInfo retrieves the auth request data by state.
200200func (s *Store) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
201201 var dataStr string
···230230 `, info.State, string(dataBytes))
231231 if err != nil {
232232 return fmt.Errorf("failed to save auth request: %w", err)
233233+ }
234234+235235+ // Trigger occasional cleanup of stale auth requests and sessions
236236+ if atomic.AddUint32(&s.cleanupCounter, 1)%100 == 0 {
237237+ go func() {
238238+ _ = s.Cleanup(context.Background())
239239+ }()
233240 }
234241235242 return nil
+5-42
internal/oauth/manager.go
···8888 return redirectURI, nil
8989}
90909191-// 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) {
9191+// ResumeSession attempts to load and refresh a session for the given DID and sessionID
9292+func (m *Manager) ResumeSession(ctx context.Context, didStr string, sessionID string) (*indigoOauth.ClientSession, error) {
9393 did, err := syntax.ParseDID(didStr)
9494 if err != nil {
9595 return nil, fmt.Errorf("invalid DID: %w", err)
9696 }
97979898- // 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)
9898+ return m.App.ResumeSession(ctx, did, sessionID)
12199}
122100123101// Logout revokes the session for the given DID
124124-func (m *Manager) Logout(ctx context.Context, didStr string) error {
102102+func (m *Manager) Logout(ctx context.Context, didStr string, sessionID string) error {
125103 did, err := syntax.ParseDID(didStr)
126104 if err != nil {
127105 return fmt.Errorf("invalid DID: %w", err)
128106 }
129107130130- // We assume the most recent session is the one to revoke
131131- store, ok := m.App.Store.(*db.Store)
132132- if !ok {
133133- return fmt.Errorf("store is not of expected type")
134134- }
135135-136136- latestSessionData, err := store.GetLatestSession(ctx, did)
137137- if err != nil {
138138- return fmt.Errorf("failed to get latest session: %w", err)
139139- }
140140- if latestSessionData == nil {
141141- // No session found, so already logged out effectively
142142- return nil
143143- }
144144-145145- return m.App.Logout(ctx, did, latestSessionData.SessionID)
108108+ return m.App.Logout(ctx, did, sessionID)
146109}
147110148111// ProcessCallback exchanges the authorization code for an access token