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

Configure Feed

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

fix: architectural and security improvements (cookie domain, redirect validation, session isolation)

+316 -128
+10 -1
README.md
··· 65 65 # Default: "Authentication Portal" 66 66 name "My Services" 67 67 68 + # Optional domain for the session cookie. 69 + # Required if the Portal and Gate are on different subdomains. 70 + # e.g., if portal is auth.example.com and gate is app.example.com, set this to example.com 71 + # cookie_domain example.com 72 + 68 73 # Custom UI templates (optional) 69 74 ui { 70 75 # Path to a custom HTML template for the login page. ··· 88 93 app.example.com { 89 94 atproto_gate { 90 95 # List of allowed identities (DIDs or Handles). 91 - # REQUIRED. 96 + # OPTIONAL. If omitted, any authenticated user will be allowed. 92 97 allow @alice.bsky.social 93 98 allow did:plc:1234abcd... 94 99 ··· 97 102 # REQUIRED. 98 103 # If the Portal uses a path_prefix (e.g. /auth), append it here (e.g. https://auth.example.com/auth) 99 104 portal_url https://auth.example.com 105 + 106 + # Optional domain for the session cookie. 107 + # Must match the Portal's cookie_domain if it is set. 108 + # cookie_domain example.com 100 109 101 110 # Client ID for Transparent Refresh (Optional) 102 111 # If provided, enables background token refreshing using the shared DB.
+94 -37
gate.go
··· 1 1 package caddyatprotoauth 2 2 3 3 import ( 4 + "context" 4 5 "fmt" 5 6 "net/http" 6 7 "net/url" 8 + "strings" 7 9 "time" 8 10 9 11 "github.com/caddyserver/caddy/v2" ··· 25 27 // Gate acts as a middleware that guards endpoints 26 28 // and validates the session cookie. 27 29 type Gate struct { 28 - Allow []string `json:"allow,omitempty"` 29 - ClientID string `json:"client_id,omitempty"` // ClientID for session refreshing (e.g. https://example.com/client-metadata.json) 30 - PortalURL string `json:"portal_url,omitempty"` // URL of the auth portal (e.g. http://localhost:8080 or /) 31 - UI ui.Config `json:"ui,omitempty"` // Custom UI configuration 32 - 33 - // Paths configuration - Removed Login/Logout path from Gate as it no longer serves them. 34 - // But it might need to know them for redirection? 35 - // Currently Gate redirects to PortalURL/login. 36 - // If PortalURL is local, we just redirect there. 30 + Allow []string `json:"allow,omitempty"` 31 + ClientID string `json:"client_id,omitempty"` // ClientID for session refreshing (e.g. https://example.com/client-metadata.json) 32 + PortalURL string `json:"portal_url,omitempty"` // URL of the auth portal (e.g. http://localhost:8080 or /) 33 + CookieDomain string `json:"cookie_domain,omitempty"` // Optional domain for the session cookie (e.g. example.com) 34 + UI ui.Config `json:"ui,omitempty"` // Custom UI configuration 37 35 38 36 // Dependencies 39 - app *App 40 - resolver *resolver.Resolver 41 - sessions *session.Manager 42 - oauth *oauth.Manager 43 - renderer *ui.Renderer 44 - logger *zap.Logger 37 + app *App 38 + sessions *session.Manager 39 + oauth *oauth.Manager 40 + renderer *ui.Renderer 41 + logger *zap.Logger 42 + resolvedDIDs []string 45 43 } 46 44 47 45 // CaddyModule returns the Caddy module information. ··· 64 62 g.app = app.(*App) 65 63 66 64 // 2. Initialize Session Manager (using global secret) 67 - if g.app.CookieSecret == "" { 68 - return fmt.Errorf("global atproto cookie_secret is required") 69 - } 70 - g.sessions = session.NewManager(g.app.CookieSecret) 71 - 72 - // 3. Initialize Identity Resolver 73 - g.resolver = resolver.New() 65 + g.sessions = g.app.SessionManager 74 66 75 67 // 4. Initialize UI Renderer 76 68 renderer, err := ui.NewRenderer(g.UI) ··· 96 88 g.PortalURL = "/" 97 89 } 98 90 91 + // 6. Pre-resolve allowed handles to DIDs 92 + // We need a resolver for this 93 + resolverInstance := resolver.New() 94 + 95 + g.resolvedDIDs = make([]string, 0, len(g.Allow)) 96 + ctxResolver := context.Background() // Use background context for boot-time resolution 97 + for _, allow := range g.Allow { 98 + if allow == "*" { 99 + g.resolvedDIDs = append(g.resolvedDIDs, "*") 100 + continue 101 + } 102 + 103 + // If it's already a DID, append it directly 104 + if strings.HasPrefix(allow, "did:") { 105 + g.resolvedDIDs = append(g.resolvedDIDs, allow) 106 + continue 107 + } 108 + 109 + // Treat as handle and resolve 110 + did, err := resolverInstance.ResolveIdentifier(ctxResolver, allow) 111 + if err != nil { 112 + g.logger.Warn("failed to resolve handle during provision", zap.String("handle", allow), zap.Error(err)) 113 + } else { 114 + g.resolvedDIDs = append(g.resolvedDIDs, did) 115 + } 116 + } 117 + 99 118 return nil 100 119 } 101 120 102 121 // Validate checks that the configuration is valid. 103 122 func (g *Gate) Validate() error { 104 - if len(g.Allow) == 0 { 105 - return fmt.Errorf("atproto_gate requires at least one 'allow' entry") 106 - } 107 123 return nil 108 124 } 109 125 ··· 124 140 return d.ArgErr() 125 141 } 126 142 g.PortalURL = d.Val() 143 + case "cookie_domain": 144 + if !d.NextArg() { 145 + return d.ArgErr() 146 + } 147 + g.CookieDomain = d.Val() 127 148 case "ui": 128 149 for nesting := d.Nesting(); d.NextBlock(nesting); { 129 150 switch d.Val() { ··· 158 179 159 180 // ServeHTTP implements caddyhttp.MiddlewareHandler. 160 181 func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { 182 + if r.URL.Path == "/logout" && g.PortalURL != "" { 183 + scheme := "https" 184 + if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" { 185 + scheme = "http" 186 + } 187 + host := r.Host 188 + currentURL := fmt.Sprintf("%s://%s", scheme, host) 189 + 190 + // Ensure PortalURL doesn't end with / 191 + portalURL := g.PortalURL 192 + if portalURL == "/" { 193 + portalURL = "" 194 + } else if len(portalURL) > 0 && portalURL[len(portalURL)-1] == '/' { 195 + portalURL = portalURL[:len(portalURL)-1] 196 + } 197 + 198 + // Also perform local credential invalidation if possible (composite mode) 199 + sess, err := g.sessions.VerifyCookie(r) 200 + if err == nil || err == session.ErrExpired { 201 + if g.oauth != nil { 202 + if err := g.oauth.Logout(r.Context(), sess.DID, sess.SessionID); err != nil { 203 + g.logger.Error("failed to revoke session during local logout", zap.Error(err)) 204 + } 205 + } 206 + } 207 + 208 + // Clear local session cookie 209 + http.SetCookie(w, g.sessions.ClearCookie(g.CookieDomain)) 210 + 211 + portalLogout := fmt.Sprintf("%s/logout?redirect_to=%s", portalURL, url.QueryEscape(currentURL)) 212 + http.Redirect(w, r, portalLogout, http.StatusFound) 213 + return nil 214 + } 215 + 161 216 // 1. Verify stateless cookie here 162 217 sess, err := g.sessions.VerifyCookie(r) 163 218 if err == session.ErrExpired { ··· 166 221 // If ClientID is set, g.oauth is set. 167 222 168 223 if g.oauth != nil && sess != nil { 169 - clientSession, err := g.oauth.ResumeSession(r.Context(), sess.DID) 224 + clientSession, err := g.oauth.ResumeSession(r.Context(), sess.DID, sess.SessionID) 170 225 if err == nil { 171 226 // Refresh tokens 172 227 if _, err := clientSession.RefreshTokens(r.Context()); err == nil { ··· 174 229 // We need to extend expiration. 175 230 // Handle lookup might be needed if not in session? 176 231 // Sess has Handle. 232 + cookieDomain := g.CookieDomain 233 + if cookieDomain == "" { 234 + cookieDomain = r.Host 235 + } 236 + cookieDomain = strings.Split(cookieDomain, ":")[0] 237 + 177 238 cookie, err := g.sessions.CreateCookie( 178 239 clientSession.Data.AccountDID, 179 240 sess.Handle, // Keep handle from old cookie 241 + clientSession.Data.SessionID, 180 242 24*7*time.Hour, 181 - // Domain for cookie? 182 - // Previously we used g.Domain. Now we don't have it. 183 - // We can use request host or empty (current domain). 184 - // If we leave it empty, it defaults to host. 185 - // But CreateCookie expects a domain string? 186 - // Let's check session.CreateCookie signature. 187 - r.Host, 243 + cookieDomain, 188 244 ) 189 245 if err == nil { 190 246 http.SetCookie(w, cookie) 247 + r.AddCookie(cookie) 191 248 // Proceed as authorized 192 249 r.Header.Set("X-Atproto-Did", sess.DID) 193 250 r.Header.Set("X-Atproto-Handle", sess.Handle) ··· 201 258 // Session valid! 202 259 // Check authorization against allowlist 203 260 allowed := false 204 - for _, allow := range g.Allow { 205 - if allow == sess.DID || allow == sess.Handle { 261 + for _, allow := range g.resolvedDIDs { 262 + if allow == "*" || allow == sess.DID { 206 263 allowed = true 207 264 break 208 265 } ··· 230 287 231 288 // 2. If invalid/missing, initiate redirect to Portal 232 289 if g.PortalURL != "" { 233 - // Construct redirect URL: ${PortalURL}/login?redirect_uri=${CurrentURL} 290 + // Construct redirect URL: ${PortalURL}/login?redirect_to=${CurrentURL} 234 291 scheme := "https" 235 - if r.TLS == nil { 292 + if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" { 236 293 scheme = "http" 237 294 } 238 295 host := r.Host
+19 -3
global.go
··· 9 9 "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 10 10 11 11 "tangled.org/vvill.dev/caddy-atproto-auth/internal/db" 12 - "tangled.org/vvill.dev/caddy-atproto-auth/internal/oauth" 12 + "tangled.org/vvill.dev/caddy-atproto-auth/internal/session" 13 13 ) 14 14 15 15 func init() { ··· 21 21 type App struct { 22 22 StoragePath string `json:"storage_path,omitempty"` 23 23 CookieSecret string `json:"cookie_secret,omitempty"` 24 + CookieName string `json:"cookie_name,omitempty"` 25 + 26 + CookieDomain string `json:"cookie_domain,omitempty"` 24 27 25 28 // Internal state 26 - Store *db.Store `json:"-"` 27 - OAuthManager *oauth.Manager `json:"-"` 29 + Store *db.Store `json:"-"` 30 + SessionManager *session.Manager `json:"-"` 28 31 } 29 32 30 33 // CaddyModule returns the Caddy module information. ··· 59 62 a.CookieSecret = secret 60 63 } 61 64 65 + // Initialize Session Manager globally 66 + a.SessionManager = session.NewManager(a.CookieSecret, a.CookieName, a.CookieDomain) 67 + 62 68 return nil 63 69 } 64 70 ··· 103 109 return nil, d.ArgErr() 104 110 } 105 111 app.CookieSecret = d.Val() 112 + case "cookie_name": 113 + if !d.NextArg() { 114 + return nil, d.ArgErr() 115 + } 116 + app.CookieName = d.Val() 117 + case "cookie_domain": 118 + if !d.NextArg() { 119 + return nil, d.ArgErr() 120 + } 121 + app.CookieDomain = d.Val() 106 122 default: 107 123 return nil, d.Errf("unrecognized subdirective '%s'", d.Val()) 108 124 }
+27 -20
internal/db/db.go
··· 6 6 "database/sql" 7 7 "encoding/json" 8 8 "fmt" 9 + "sync/atomic" 9 10 10 11 "github.com/bluesky-social/indigo/atproto/atcrypto" 11 12 "github.com/bluesky-social/indigo/atproto/auth/oauth" ··· 18 19 19 20 // Store handles SQLite persistence for the plugin. 20 21 type Store struct { 21 - db *sql.DB 22 + db *sql.DB 23 + cleanupCounter uint32 22 24 } 23 25 24 26 // NewStore initializes a new SQLite-backed storage. ··· 127 129 return string(secret), nil 128 130 } 129 131 130 - // GetLatestSession returns the most recently updated session for a DID. 131 - func (s *Store) GetLatestSession(ctx context.Context, did syntax.DID) (*oauth.ClientSessionData, error) { 132 - var dataStr string 133 - err := s.db.QueryRowContext(ctx, "SELECT data FROM auth_sessions WHERE did = ? ORDER BY updated_at DESC LIMIT 1", did.String()).Scan(&dataStr) 134 - if err != nil { 135 - if err == sql.ErrNoRows { 136 - return nil, nil 137 - } 138 - return nil, fmt.Errorf("failed to query latest session: %w", err) 139 - } 140 - 141 - var sessionData oauth.ClientSessionData 142 - if err := json.Unmarshal([]byte(dataStr), &sessionData); err != nil { 143 - return nil, fmt.Errorf("failed to parse session data: %w", err) 144 - } 145 - 146 - return &sessionData, nil 147 - } 148 - 149 132 // GetSession retrieves session data from the database. 150 133 func (s *Store) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 151 134 var dataStr string ··· 196 179 return nil 197 180 } 198 181 182 + // Cleanup removes expired auth requests and stale sessions to prevent database bloat. 183 + func (s *Store) Cleanup(ctx context.Context) error { 184 + // Delete auth_requests older than 1 hour 185 + _, err := s.db.ExecContext(ctx, "DELETE FROM auth_requests WHERE created_at < datetime('now', '-1 hour')") 186 + if err != nil { 187 + return fmt.Errorf("cleanup auth_requests failed: %w", err) 188 + } 189 + 190 + // Delete auth_sessions older than 30 days 191 + _, err = s.db.ExecContext(ctx, "DELETE FROM auth_sessions WHERE updated_at < datetime('now', '-30 days')") 192 + if err != nil { 193 + return fmt.Errorf("cleanup auth_sessions failed: %w", err) 194 + } 195 + 196 + return nil 197 + } 198 + 199 199 // GetAuthRequestInfo retrieves the auth request data by state. 200 200 func (s *Store) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 201 201 var dataStr string ··· 230 230 `, info.State, string(dataBytes)) 231 231 if err != nil { 232 232 return fmt.Errorf("failed to save auth request: %w", err) 233 + } 234 + 235 + // Trigger occasional cleanup of stale auth requests and sessions 236 + if atomic.AddUint32(&s.cleanupCounter, 1)%100 == 0 { 237 + go func() { 238 + _ = s.Cleanup(context.Background()) 239 + }() 233 240 } 234 241 235 242 return nil
+5 -42
internal/oauth/manager.go
··· 88 88 return redirectURI, nil 89 89 } 90 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) { 91 + // ResumeSession attempts to load and refresh a session for the given DID and sessionID 92 + func (m *Manager) ResumeSession(ctx context.Context, didStr string, sessionID string) (*indigoOauth.ClientSession, error) { 93 93 did, err := syntax.ParseDID(didStr) 94 94 if err != nil { 95 95 return nil, fmt.Errorf("invalid DID: %w", err) 96 96 } 97 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) 98 + return m.App.ResumeSession(ctx, did, sessionID) 121 99 } 122 100 123 101 // Logout revokes the session for the given DID 124 - func (m *Manager) Logout(ctx context.Context, didStr string) error { 102 + func (m *Manager) Logout(ctx context.Context, didStr string, sessionID string) error { 125 103 did, err := syntax.ParseDID(didStr) 126 104 if err != nil { 127 105 return fmt.Errorf("invalid DID: %w", err) 128 106 } 129 107 130 - // We assume the most recent session is the one to revoke 131 - store, ok := m.App.Store.(*db.Store) 132 - if !ok { 133 - return fmt.Errorf("store is not of expected type") 134 - } 135 - 136 - latestSessionData, err := store.GetLatestSession(ctx, did) 137 - if err != nil { 138 - return fmt.Errorf("failed to get latest session: %w", err) 139 - } 140 - if latestSessionData == nil { 141 - // No session found, so already logged out effectively 142 - return nil 143 - } 144 - 145 - return m.App.Logout(ctx, did, latestSessionData.SessionID) 108 + return m.App.Logout(ctx, did, sessionID) 146 109 } 147 110 148 111 // ProcessCallback exchanges the authorization code for an access token
+26 -9
internal/session/session.go
··· 18 18 type Session struct { 19 19 DID string `json:"did"` 20 20 Handle string `json:"handle"` 21 + SessionID string `json:"sid"` 21 22 ExpiresAt int64 `json:"exp"` 22 23 } 23 24 24 25 // Manager handles cookie signing and verification. 25 26 type Manager struct { 26 - Secret []byte 27 - CookieName string 27 + Secret []byte 28 + CookieName string 29 + CookieDomain string 28 30 } 29 31 30 32 // NewManager creates a new session manager with the given secret. 31 - func NewManager(secret string) *Manager { 33 + func NewManager(secret string, cookieName string, cookieDomain string) *Manager { 32 34 if len(secret) < 32 { 33 35 // Warn or error if secret is too short? For now, we assume user configures it properly. 34 36 } 37 + if cookieName == "" { 38 + cookieName = "atproto_session" 39 + } 35 40 return &Manager{ 36 - Secret: []byte(secret), 37 - CookieName: "atproto_session", 41 + Secret: []byte(secret), 42 + CookieName: cookieName, 43 + CookieDomain: cookieDomain, 38 44 } 39 45 } 40 46 ··· 46 52 } 47 53 48 54 // CreateCookie generates a signed http.Cookie for the session. 49 - func (m *Manager) CreateCookie(did syntax.DID, handle string, duration time.Duration, domain string) (*http.Cookie, error) { 55 + func (m *Manager) CreateCookie(did syntax.DID, handle string, sessionID string, duration time.Duration, reqDomain string) (*http.Cookie, error) { 50 56 exp := time.Now().Add(duration).Unix() 51 57 sess := Session{ 52 58 DID: did.String(), 53 59 Handle: handle, 60 + SessionID: sessionID, 54 61 ExpiresAt: exp, 55 62 } 56 63 ··· 63 70 signature := m.sign([]byte(encoded)) 64 71 value := fmt.Sprintf("%s.%s", encoded, signature) 65 72 73 + cookieDomain := m.CookieDomain 74 + if cookieDomain == "" { 75 + cookieDomain = reqDomain 76 + } 77 + 66 78 cookie := &http.Cookie{ 67 79 Name: m.CookieName, 68 80 Value: value, 69 81 Path: "/", 70 - Domain: domain, 82 + Domain: cookieDomain, 71 83 Expires: time.Unix(exp, 0), 72 84 Secure: true, 73 85 HttpOnly: true, ··· 118 130 var ErrExpired = errors.New("session expired") 119 131 120 132 // ClearCookie returns a cookie that clears the session. 121 - func (m *Manager) ClearCookie(domain string) *http.Cookie { 133 + func (m *Manager) ClearCookie(reqDomain string) *http.Cookie { 134 + cookieDomain := m.CookieDomain 135 + if cookieDomain == "" { 136 + cookieDomain = reqDomain 137 + } 138 + 122 139 return &http.Cookie{ 123 140 Name: m.CookieName, 124 141 Value: "", 125 142 Path: "/", 126 - Domain: domain, 143 + Domain: cookieDomain, 127 144 Expires: time.Unix(0, 0), 128 145 MaxAge: -1, 129 146 Secure: true,
+3
internal/ui/templates/login.html
··· 153 153 {{ end }} 154 154 155 155 <form action="/login" method="POST"> 156 + {{ if .Redirect }} 157 + <input type="hidden" name="redirect_to" value="{{ .Redirect }}" /> 158 + {{ end }} 156 159 <div class="input-group"> 157 160 <label for="handle">Handle</label> 158 161 <input
+132 -16
portal.go
··· 4 4 "encoding/json" 5 5 "fmt" 6 6 "net/http" 7 + "net/url" 8 + "strings" 7 9 "time" 8 10 9 11 "github.com/caddyserver/caddy/v2" ··· 23 25 24 26 // Portal is the centralized authentication portal for Path B (Auth Hub). 25 27 type Portal struct { 26 - Name string `json:"name,omitempty"` 27 - Domain string `json:"domain,omitempty"` // Public domain of the portal (e.g. auth.example.com) 28 - UI ui.Config `json:"ui,omitempty"` // Custom UI configuration 28 + Name string `json:"name,omitempty"` 29 + Domain string `json:"domain,omitempty"` // Public domain of the portal (e.g. auth.example.com) 30 + CookieDomain string `json:"cookie_domain,omitempty"` // Optional domain for the session cookie (e.g. example.com) 31 + UI ui.Config `json:"ui,omitempty"` // Custom UI configuration 29 32 30 33 // Paths configuration 31 34 PathPrefix string `json:"path_prefix,omitempty"` ··· 57 60 } 58 61 p.app = app.(*App) 59 62 60 - // 2. Initialize Session Manager 61 - if p.app.CookieSecret == "" { 62 - return fmt.Errorf("global atproto cookie_secret is required") 63 - } 64 - p.sessions = session.NewManager(p.app.CookieSecret) 63 + // 2. Initialize Session Manager from global app 64 + p.sessions = p.app.SessionManager 65 65 66 66 // 4. Initialize UI Renderer 67 67 renderer, err := ui.NewRenderer(p.UI) ··· 117 117 return d.ArgErr() 118 118 } 119 119 p.Domain = d.Val() 120 + case "cookie_domain": 121 + if !d.NextArg() { 122 + return d.ArgErr() 123 + } 124 + p.CookieDomain = d.Val() 120 125 case "path_prefix": 121 126 if !d.NextArg() { 122 127 return d.ArgErr() ··· 177 182 sessionData, handle, err := p.oauth.ProcessCallback(ctx, query) 178 183 if err != nil { 179 184 p.logger.Error("oauth callback failed", zap.Error(err)) 180 - http.Error(w, fmt.Sprintf("Authentication failed: %v", err), http.StatusBadRequest) 185 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 186 + w.WriteHeader(http.StatusBadRequest) 187 + _ = p.renderer.RenderLogin(w, ui.LoginData{AppName: p.Name, Error: fmt.Sprintf("Authentication failed: %v", err)}) 181 188 return nil 182 189 } 183 190 191 + cookieDomain := p.CookieDomain 192 + if cookieDomain == "" { 193 + cookieDomain = p.Domain 194 + } 195 + cookieDomain = strings.Split(cookieDomain, ":")[0] 196 + 184 197 // Create Session Cookie 185 198 cookie, err := p.sessions.CreateCookie( 186 199 sessionData.AccountDID, 187 200 handle, 201 + sessionData.SessionID, 188 202 24*7*time.Hour, 189 - p.Domain, 203 + cookieDomain, 190 204 ) 191 205 if err != nil { 192 206 p.logger.Error("failed to create session cookie", zap.Error(err)) ··· 196 210 197 211 http.SetCookie(w, cookie) 198 212 213 + appNameForLog := p.Name 214 + if appNameForLog == "" { 215 + appNameForLog = cookieDomain 216 + } 217 + p.logger.Info(fmt.Sprintf("@%s (did: %s) has logged in for %s", handle, sessionData.AccountDID.String(), appNameForLog)) 218 + 219 + // Check for redirect_to cookie 220 + redirectTo := "/" 221 + state := r.URL.Query().Get("state") 222 + cookieName := fmt.Sprintf("atproto_redirect_to_%s", state) 223 + if redirectCookie, err := r.Cookie(cookieName); err == nil && redirectCookie.Value != "" { 224 + redirectTo = redirectCookie.Value 225 + // Clear cookie 226 + http.SetCookie(w, &http.Cookie{ 227 + Name: cookieName, 228 + Value: "", 229 + Path: "/", 230 + MaxAge: -1, 231 + HttpOnly: true, 232 + Secure: true, 233 + }) 234 + 235 + // Basic open redirect mitigation: ensure it's a relative path or matches CookieDomain/Domain 236 + if strings.HasPrefix(redirectTo, "http://") || strings.HasPrefix(redirectTo, "https://") { 237 + parsed, err := url.Parse(redirectTo) 238 + // Allow redirect if host is our exact domain, or if cookie domain is a parent of the host 239 + isAllowedDomain := false 240 + if err == nil { 241 + h := parsed.Host 242 + if h == p.Domain { 243 + isAllowedDomain = true 244 + } else if p.CookieDomain != "" { 245 + cleanCookieDomain := strings.TrimPrefix(p.CookieDomain, ".") 246 + if h == cleanCookieDomain || strings.HasSuffix(h, "."+cleanCookieDomain) { 247 + isAllowedDomain = true 248 + } 249 + } 250 + } 251 + 252 + if !isAllowedDomain { 253 + p.logger.Warn("blocked cross-domain redirect", zap.String("url", redirectTo)) 254 + redirectTo = "/" // Fallback to home if invalid or not matching domain 255 + } 256 + } 257 + } 258 + 199 259 // Redirect to home or saved location 200 - http.Redirect(w, r, "/", http.StatusFound) 260 + http.Redirect(w, r, redirectTo, http.StatusFound) 201 261 return nil 202 262 } 203 263 ··· 225 285 w.WriteHeader(http.StatusBadRequest) 226 286 if renderErr := p.renderer.RenderLogin(w, ui.LoginData{ 227 287 AppName: p.Name, 228 - Redirect: "/", 288 + Redirect: r.FormValue("redirect_to"), 229 289 Error: fmt.Sprintf("Authentication failed: %v", err), 230 290 }); renderErr != nil { 231 291 p.logger.Error("failed to render login error", zap.Error(renderErr)) ··· 233 293 return nil 234 294 } 235 295 296 + if redirectTo := r.FormValue("redirect_to"); redirectTo != "" { 297 + u, _ := url.Parse(redirectURI) 298 + state := u.Query().Get("state") 299 + http.SetCookie(w, &http.Cookie{ 300 + Name: fmt.Sprintf("atproto_redirect_to_%s", state), 301 + Value: redirectTo, 302 + Path: "/", 303 + MaxAge: 300, 304 + HttpOnly: true, 305 + Secure: true, 306 + SameSite: http.SameSiteLaxMode, 307 + }) 308 + } 309 + 236 310 http.Redirect(w, r, redirectURI, http.StatusFound) 237 311 return nil 238 312 } ··· 242 316 w.Header().Set("Content-Type", "text/html; charset=utf-8") 243 317 if err := p.renderer.RenderLogin(w, ui.LoginData{ 244 318 AppName: p.Name, 245 - Redirect: "/", 319 + Redirect: r.URL.Query().Get("redirect_to"), 246 320 }); err != nil { 247 321 p.logger.Error("failed to render login page", zap.Error(err)) 248 322 return caddyhttp.Error(http.StatusInternalServerError, err) ··· 254 328 if r.URL.Path == logoutPath { 255 329 // Invalidate credential if session exists 256 330 sess, err := p.sessions.VerifyCookie(r) 331 + 332 + cookieDomain := p.CookieDomain 333 + if cookieDomain == "" { 334 + cookieDomain = p.Domain 335 + } 336 + cookieDomain = strings.Split(cookieDomain, ":")[0] 337 + 257 338 if err == nil || err == session.ErrExpired { 258 - if err := p.oauth.Logout(r.Context(), sess.DID); err != nil { 339 + appNameForLog := p.Name 340 + if appNameForLog == "" { 341 + appNameForLog = cookieDomain 342 + } 343 + p.logger.Info(fmt.Sprintf("@%s (did: %s) has logged out for %s", sess.Handle, sess.DID, appNameForLog)) 344 + 345 + if err := p.oauth.Logout(r.Context(), sess.DID, sess.SessionID); err != nil { 259 346 p.logger.Error("failed to revoke session during logout", zap.Error(err)) 260 347 } 261 348 } 262 349 263 - http.SetCookie(w, p.sessions.ClearCookie(p.Domain)) 264 - http.Redirect(w, r, loginPath, http.StatusFound) 350 + http.SetCookie(w, p.sessions.ClearCookie(cookieDomain)) 351 + 352 + // Handle redirect_to for logout 353 + redirectTo := r.URL.Query().Get("redirect_to") 354 + if redirectTo == "" { 355 + redirectTo = loginPath 356 + } else { 357 + // Basic open redirect mitigation: ensure it's a relative path or matches CookieDomain/Domain 358 + if strings.HasPrefix(redirectTo, "http://") || strings.HasPrefix(redirectTo, "https://") { 359 + parsed, err := url.Parse(redirectTo) 360 + isAllowedDomain := false 361 + if err == nil { 362 + h := parsed.Host 363 + if h == p.Domain { 364 + isAllowedDomain = true 365 + } else if p.CookieDomain != "" { 366 + cleanCookieDomain := strings.TrimPrefix(p.CookieDomain, ".") 367 + if h == cleanCookieDomain || strings.HasSuffix(h, "."+cleanCookieDomain) { 368 + isAllowedDomain = true 369 + } 370 + } 371 + } 372 + 373 + if !isAllowedDomain { 374 + p.logger.Warn("blocked cross-domain redirect on logout", zap.String("url", redirectTo)) 375 + redirectTo = loginPath // Fallback to login page 376 + } 377 + } 378 + } 379 + 380 + http.Redirect(w, r, redirectTo, http.StatusFound) 265 381 return nil 266 382 } 267 383