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.

Further revisions, nits, added config options

+80 -28
+24
README.md
··· 43 43 route { 44 44 atproto_portal { 45 45 cookie_domain example.com 46 + allowed_redirect_domains app1.example.com 46 47 } 47 48 } 48 49 } ··· 77 78 # For use where a Gate and its Portal are on different machines. 78 79 # Default: 32 random bytes 79 80 cookie_secret "change-me-to-a-secure-random-string-at-least-32-chars" 81 + 82 + # OAuth Session duration - how long before user needs to log in again 83 + # Default 1 week (7d) 84 + session_duration 1d 85 + 86 + # Number of OAuth managers in cache (to avoid dos, increase with scale) 87 + # Default: 100 88 + oauth_manager_cache_size 1000 80 89 } 81 90 } 82 91 ``` ··· 113 122 # Prepends a prefix to /login and /logout paths. 114 123 # Useful to avoid conflicts with downstream apps. 115 124 path_prefix atproto_auth 125 + 126 + # Allowed domains for the redirect_to parameter after login/logout. 127 + # By default, only the portal's domain is allowed. 128 + allowed_redirect_domains app.example.com *.app.example.com 129 + 130 + # Where to redirect users after they log out. 131 + # Default: The login page 132 + logout_redirect_url https://example.com/goodbye 116 133 117 134 # Change cookie name, also to avoid conficts 118 135 # Default: atproto_session ··· 137 154 allow * 138 155 139 156 # If the Portal uses a path_prefix, include it here 157 + # Default: / 140 158 portal_url https://auth.example.com/atproto_auth 141 159 142 160 # Match Portal's values if set. Used for token refresh. 143 161 cookie_domain example.com 144 162 cookie_name caddy_atproto_session 163 + 164 + # The plugin resolves all handles to DIDs at startup. For security 165 + # against account hijacking, dynamic handle resolution is disabled by default. 166 + # Enable this if you want to follow specified handles to new DIDs. 167 + # Default: false 168 + resolve_handles_on_request false 145 169 } 146 170 } 147 171 ```
+21 -5
gate.go
··· 8 8 "strings" 9 9 "sync" 10 10 11 + "strconv" 12 + 11 13 "github.com/caddyserver/caddy/v2" 12 14 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 13 15 "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" ··· 55 57 func (g *Gate) Provision(ctx caddy.Context) error { 56 58 g.logger = ctx.Logger() 57 59 58 - // 1. Get Global App 60 + // Get Global App 59 61 app, err := ctx.App("atproto") 60 62 if err != nil { 61 63 return fmt.Errorf("getting atproto app: %w", err) 62 64 } 63 65 g.app = app.(*App) 64 66 65 - // 2. Initialize Session Manager (using global secret) 67 + // Initialize Session Manager (using global secret) 66 68 g.sessions = session.NewManager(g.app.CookieSecret, g.CookieName, g.CookieDomain) 67 69 68 70 g.oauthManagers = make(map[string]*oauth.Manager) ··· 74 76 g.PortalURL = g.PortalURL[:len(g.PortalURL)-1] 75 77 } 76 78 77 - // 5. Initialize OAuth Manager for transparent refresh 79 + // Initialize OAuth Manager for transparent refresh 78 80 // We derive the ClientID from the PortalURL if it's absolute, 79 81 // or from the Host header at request time if it's relative. 80 82 // For now, if it's absolute, we can init the OAuth manager immediately. ··· 90 92 } 91 93 } 92 94 93 - // 6. Pre-resolve allowed handles to DIDs 95 + // Pre-resolve allowed handles to DIDs 94 96 g.resolver = resolver.New() 95 97 96 98 g.resolvedDIDs = make([]string, 0, len(g.Allow)) ··· 148 150 } 149 151 g.PortalURL = d.Val() 150 152 case "resolve_handles_on_request": 151 - g.ResolveHandlesOnRequest = true 153 + if d.NextArg() { 154 + val, err := strconv.ParseBool(d.Val()) 155 + if err != nil { 156 + return d.Errf("invalid boolean value '%s'", d.Val()) 157 + } 158 + g.ResolveHandlesOnRequest = val 159 + } else { 160 + g.ResolveHandlesOnRequest = true 161 + } 152 162 default: 153 163 return d.Errf("unrecognized subdirective '%s'", d.Val()) 154 164 } ··· 300 310 currentURL := fmt.Sprintf("%s://%s%s", scheme, host, r.URL.RequestURI()) 301 311 302 312 portalURL := g.PortalURL 313 + if portalURL == "/" { 314 + portalURL = "" 315 + } 303 316 portalForbidden := fmt.Sprintf("%s/forbidden?redirect_to=%s", portalURL, url.QueryEscape(currentURL)) 304 317 http.Redirect(w, r, portalForbidden, http.StatusFound) 305 318 return nil ··· 319 332 currentURL := fmt.Sprintf("%s://%s%s", scheme, host, r.URL.RequestURI()) 320 333 321 334 portalURL := g.PortalURL 335 + if portalURL == "/" { 336 + portalURL = "" 337 + } 322 338 portalLogin := fmt.Sprintf("%s/login?redirect_to=%s", portalURL, url.QueryEscape(currentURL)) 323 339 http.Redirect(w, r, portalLogin, http.StatusFound) 324 340 return nil
+1 -1
global.go
··· 105 105 // storage_path /path/to/db 106 106 // cookie_secret <secret> 107 107 // } 108 - func parseGlobalAtproto(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) { 108 + func parseGlobalAtproto(d *caddyfile.Dispenser, _ any) (any, error) { 109 109 app := &App{} 110 110 for d.Next() { 111 111 for d.NextBlock(0) {
+4 -2
integration_test.go
··· 13 13 ) 14 14 15 15 func TestCaddyIntegration(t *testing.T) { 16 - // 1. Setup Caddyfile 16 + // Setup Caddyfile 17 + // Try: Change the allow list on the gate to include a nonexistent handle. 18 + // You should see a warning log that Caddy was unable to resolve it. 17 19 input := ` 18 20 { 19 21 admin off ··· 40 42 } 41 43 ` 42 44 43 - // 2. Parse and Load Config 45 + // Parse and Load Config 44 46 adapter := caddyfile.Adapter{ 45 47 ServerType: httpcaddyfile.ServerType{}, 46 48 }
+1 -4
internal/db/db.go
··· 137 137 err := s.db.QueryRowContext(ctx, "SELECT data FROM auth_sessions WHERE did = ? AND session_id = ?", did.String(), sessionID).Scan(&dataStr) 138 138 if err != nil { 139 139 if err == sql.ErrNoRows { 140 - // Some oauth methods in indigo might expect a specific error if not found, let's return it as nil/not found or check indigo docs. 141 - // Indigo's memstore returns (nil, nil) or custom error. We'll return nil for the session and potentially an error if we need to. 142 - // Currently indigo's oauth store interface doesn't strictly dictate `ErrNotFound`, but usually `nil, nil` or `nil, Err` is handled. Let's return nil, nil. 143 140 return nil, nil 144 141 } 145 142 return nil, fmt.Errorf("failed to query session: %w", err) ··· 225 222 return fmt.Errorf("failed to serialize auth request data: %w", err) 226 223 } 227 224 228 - // Creating is fine. It shouldn't exist, but we can do an INSERT OR REPLACE just in case. 225 + // It shouldn't exist, but we can do an INSERT OR REPLACE just in case. 229 226 _, err = s.db.ExecContext(ctx, ` 230 227 INSERT OR REPLACE INTO auth_requests (state, data, created_at) 231 228 VALUES (?, ?, CURRENT_TIMESTAMP)
-3
internal/session/session.go
··· 31 31 32 32 // NewManager creates a new session manager with the given secret. 33 33 func NewManager(secret string, cookieName string, cookieDomain string) *Manager { 34 - if len(secret) < 32 { 35 - // Warn or error if secret is too short? For now, we assume user configures it properly. 36 - } 37 34 if cookieName == "" { 38 35 cookieName = "atproto_session" 39 36 }
+5 -13
portal.go
··· 6 6 "net" 7 7 "net/http" 8 8 "net/url" 9 - "slices" 10 9 "strings" 11 10 "sync" 12 11 ··· 59 58 func (p *Portal) Provision(ctx caddy.Context) error { 60 59 p.logger = ctx.Logger() 61 60 62 - // 1. Get Global App 61 + // Get Global App 63 62 app, err := ctx.App("atproto") 64 63 if err != nil { 65 64 return fmt.Errorf("getting atproto app: %w", err) 66 65 } 67 66 p.app = app.(*App) 68 67 69 - // 2. Initialize Session Manager from global app secret 68 + // Initialize Session Manager from global app secret 70 69 p.sessions = session.NewManager(p.app.CookieSecret, p.CookieName, p.CookieDomain) 71 70 72 - // 4. Initialize UI Renderer 71 + // Initialize UI Renderer 73 72 renderer, err := ui.NewRenderer(ui.Config{ 74 73 LoginTemplatePath: p.LoginTemplatePath, 75 74 ForbiddenTemplatePath: p.ForbiddenTemplatePath, ··· 300 299 if hostNoPort == reqDomain { 301 300 isAllowedDomain = true 302 301 } else { 303 - for _, allowed := range p.AllowedRedirectDomains { 304 - if hostNoPort == allowed { 305 - isAllowedDomain = true 306 - break 307 - } 308 - } 302 + isAllowedDomain = checkAllowedDomain(hostNoPort, p.AllowedRedirectDomains) 309 303 } 310 304 } 311 305 ··· 443 437 if hostNoPort == reqDomain { 444 438 isAllowedDomain = true 445 439 } else { 446 - if slices.Contains(p.AllowedRedirectDomains, hostNoPort) { 447 - isAllowedDomain = true 448 - } 440 + isAllowedDomain = checkAllowedDomain(hostNoPort, p.AllowedRedirectDomains) 449 441 } 450 442 } 451 443
+24
util.go
··· 3 3 import ( 4 4 "net" 5 5 "net/http" 6 + "strings" 6 7 ) 7 8 8 9 // getRequestScheme infers the protocol scheme of the incoming request. ··· 22 23 } 23 24 return host 24 25 } 26 + 27 + // matchDomain checks if the host matches the allowed pattern. 28 + // Supports exact matches and wildcard prefix matches (e.g., *.example.com). 29 + func matchDomain(host, pattern string) bool { 30 + if host == pattern { 31 + return true 32 + } 33 + if strings.HasPrefix(pattern, "*.") { 34 + suffix := pattern[1:] // e.g., ".example.com" 35 + return strings.HasSuffix(host, suffix) 36 + } 37 + return false 38 + } 39 + 40 + // isAllowedDomain checks if the host is allowed by the configured domains. 41 + func checkAllowedDomain(host string, allowedDomains []string) bool { 42 + for _, allowed := range allowedDomains { 43 + if matchDomain(host, allowed) { 44 + return true 45 + } 46 + } 47 + return false 48 + }