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.

fix: sanitize handle input and render login errors properly

+41 -6
+6
README.md
··· 25 25 26 26 ## Configuration 27 27 28 + ### Important Note on Local Development 29 + 30 + The AT Protocol OAuth flow requires the Authentication Server (PDS) to fetch client metadata from your application. If you are running Caddy on `localhost`, production PDS instances (like `bsky.social`) **cannot reach your local server**, resulting in an `invalid_client` error. 31 + 32 + To test locally with a real PDS, you must expose your local server to the internet using a tunnel (e.g., `ngrok`, `cloudflared`, or `tailscale funnel`) and configure the `domain` directive with the public tunnel URL. 33 + 28 34 ### Global Options 29 35 30 36 The `atproto` global block configures the shared storage and security settings.
+3 -3
e2e/Caddyfile
··· 1 1 { 2 2 admin off 3 3 atproto { 4 - storage_path ./e2e/e2e.db 4 + storage_path ./e2e.db 5 5 cookie_secret "testing-secret-must-be-at-least-32-bytes-long" 6 6 } 7 7 } ··· 13 13 atproto_gate { 14 14 # Standalone mode enabled by setting 'domain' 15 15 domain localhost:8081 16 - allow @test.bsky.social 16 + allow @vvill.dev 17 17 } 18 18 19 19 # Protected content ··· 40 40 atproto_gate { 41 41 # Auth Hub mode (no 'domain' set) 42 42 portal_url http://localhost:8082 43 - allow @test.bsky.social 43 + allow @vvill.dev 44 44 } 45 45 46 46 respond "Welcome to Service App! You authenticated via the Hub."
+17 -1
gate.go
··· 265 265 if r.URL.Path == "/login" { 266 266 if r.Method == "POST" { 267 267 handle := r.FormValue("handle") 268 + // Strip leading @ if present 269 + if len(handle) > 0 && handle[0] == '@' { 270 + handle = handle[1:] 271 + } 272 + 268 273 redirectURI, err := g.oauth.StartAuthFlow(r.Context(), handle) 269 274 if err != nil { 270 - return caddyhttp.Error(http.StatusBadRequest, err) 275 + // Render error on login page instead of raw JSON 276 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 277 + // We return 400 Bad Request 278 + w.WriteHeader(http.StatusBadRequest) 279 + if renderErr := g.renderer.RenderLogin(w, ui.LoginData{ 280 + AppName: g.Domain, 281 + Redirect: "/", 282 + Error: fmt.Sprintf("Authentication failed: %v", err), 283 + }); renderErr != nil { 284 + g.logger.Error("failed to render login error", zap.Error(renderErr)) 285 + } 286 + return nil 271 287 } 272 288 http.Redirect(w, r, redirectURI, http.StatusFound) 273 289 return nil
+15 -2
portal.go
··· 192 192 // 3. Login Start (Form Action) 193 193 if r.URL.Path == "/login" && r.Method == "POST" { 194 194 handle := r.FormValue("handle") 195 + // Strip leading @ if present 196 + if len(handle) > 0 && handle[0] == '@' { 197 + handle = handle[1:] 198 + } 199 + 195 200 if handle == "" { 196 201 http.Error(w, "Handle required", http.StatusBadRequest) 197 202 return nil ··· 200 205 // Start Auth Flow 201 206 redirectURI, err := p.oauth.StartAuthFlow(r.Context(), handle) 202 207 if err != nil { 203 - p.logger.Error("failed to start auth flow", zap.Error(err)) 204 - http.Error(w, fmt.Sprintf("Failed to resolve identity: %v", err), http.StatusBadRequest) 208 + // Render error on login page 209 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 210 + w.WriteHeader(http.StatusBadRequest) 211 + if renderErr := p.renderer.RenderLogin(w, ui.LoginData{ 212 + AppName: p.Name, 213 + Redirect: "/", 214 + Error: fmt.Sprintf("Authentication failed: %v", err), 215 + }); renderErr != nil { 216 + p.logger.Error("failed to render login error", zap.Error(renderErr)) 217 + } 205 218 return nil 206 219 } 207 220