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.

1package caddyatprotoauth 2 3import ( 4 "encoding/json" 5 "fmt" 6 "net/http" 7 "net/url" 8 "time" 9 10 "github.com/caddyserver/caddy/v2" 11 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 12 "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 13 "github.com/caddyserver/caddy/v2/modules/caddyhttp" 14 "go.uber.org/zap" 15 "tangled.org/vvill.dev/caddy-atproto-auth/internal/oauth" 16 "tangled.org/vvill.dev/caddy-atproto-auth/internal/resolver" 17 "tangled.org/vvill.dev/caddy-atproto-auth/internal/session" 18 "tangled.org/vvill.dev/caddy-atproto-auth/internal/ui" 19) 20 21func init() { 22 caddy.RegisterModule(Gate{}) 23 httpcaddyfile.RegisterHandlerDirective("atproto_gate", parseCaddyfileGate) 24} 25 26// Gate acts as a middleware that guards endpoints 27// and validates the session cookie. 28type Gate struct { 29 Allow []string `json:"allow,omitempty"` 30 Domain string `json:"domain,omitempty"` // Public domain for standalone mode (e.g. app.example.com) 31 PortalURL string `json:"portal_url,omitempty"` // URL of the central auth portal if NOT in standalone mode 32 UI ui.Config `json:"ui,omitempty"` // Custom UI configuration 33 34 // Dependencies 35 app *App 36 resolver *resolver.Resolver 37 sessions *session.Manager 38 oauth *oauth.Manager 39 renderer *ui.Renderer 40 logger *zap.Logger 41} 42 43// CaddyModule returns the Caddy module information. 44func (Gate) CaddyModule() caddy.ModuleInfo { 45 return caddy.ModuleInfo{ 46 ID: "http.handlers.atproto_gate", 47 New: func() caddy.Module { return new(Gate) }, 48 } 49} 50 51// Provision sets up the module. 52func (g *Gate) Provision(ctx caddy.Context) error { 53 g.logger = ctx.Logger() 54 55 // 1. Get Global App 56 app, err := ctx.App("atproto") 57 if err != nil { 58 return fmt.Errorf("getting atproto app: %w", err) 59 } 60 g.app = app.(*App) 61 62 // 2. Initialize Session Manager (using global secret) 63 if g.app.CookieSecret == "" { 64 return fmt.Errorf("global atproto cookie_secret is required") 65 } 66 g.sessions = session.NewManager(g.app.CookieSecret) 67 68 // 3. Initialize Identity Resolver 69 g.resolver = resolver.New() 70 71 // 4. Initialize UI Renderer 72 renderer, err := ui.NewRenderer(g.UI) 73 if err != nil { 74 return fmt.Errorf("failed to init ui renderer: %w", err) 75 } 76 g.renderer = renderer 77 78 // 5. Initialize OAuth Manager (if domain set for standalone mode) 79 if g.Domain != "" { 80 clientID := fmt.Sprintf("https://%s/.well-known/oauth-client-metadata.json", g.Domain) 81 callbackURL := fmt.Sprintf("https://%s/callback", g.Domain) 82 83 mgr, err := oauth.NewManager(g.app.Store, clientID, callbackURL) 84 if err != nil { 85 return fmt.Errorf("failed to init oauth manager: %w", err) 86 } 87 g.oauth = mgr 88 } 89 90 return nil 91} 92 93// Validate checks that the configuration is valid. 94func (g *Gate) Validate() error { 95 if len(g.Allow) == 0 { 96 return fmt.Errorf("atproto_gate requires at least one 'allow' entry") 97 } 98 return nil 99} 100 101// UnmarshalCaddyfile implements caddyfile.Unmarshaler. 102func (g *Gate) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { 103 for d.Next() { 104 for nesting := d.Nesting(); d.NextBlock(nesting); { 105 switch d.Val() { 106 case "allow": 107 g.Allow = append(g.Allow, d.RemainingArgs()...) 108 case "domain": 109 if !d.NextArg() { 110 return d.ArgErr() 111 } 112 g.Domain = d.Val() 113 case "portal_url": 114 if !d.NextArg() { 115 return d.ArgErr() 116 } 117 g.PortalURL = d.Val() 118 case "ui": 119 for nesting := d.Nesting(); d.NextBlock(nesting); { 120 switch d.Val() { 121 case "login_template": 122 if !d.NextArg() { 123 return d.ArgErr() 124 } 125 g.UI.LoginTemplatePath = d.Val() 126 case "forbidden_template": 127 if !d.NextArg() { 128 return d.ArgErr() 129 } 130 g.UI.ForbiddenTemplatePath = d.Val() 131 default: 132 return d.Errf("unrecognized subdirective '%s'", d.Val()) 133 } 134 } 135 default: 136 return d.Errf("unrecognized subdirective '%s'", d.Val()) 137 } 138 } 139 } 140 return nil 141} 142 143// parseCaddyfileGate parses the atproto_gate directive from a Caddyfile. 144func parseCaddyfileGate(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { 145 var g Gate 146 err := g.UnmarshalCaddyfile(h.Dispenser) 147 return &g, err 148} 149 150// ServeHTTP implements caddyhttp.MiddlewareHandler. 151func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { 152 // If in Standalone Mode, handle OAuth paths 153 if g.oauth != nil { 154 if r.URL.Path == "/.well-known/oauth-client-metadata.json" { 155 meta, err := g.oauth.GetClientMetadata() 156 if err != nil { 157 return caddyhttp.Error(http.StatusInternalServerError, err) 158 } 159 w.Header().Set("Content-Type", "application/json") 160 return json.NewEncoder(w).Encode(meta) 161 } 162 if r.URL.Path == "/logout" { 163 // Clear session cookie 164 http.SetCookie(w, g.sessions.ClearCookie(g.Domain)) 165 http.Redirect(w, r, "/login", http.StatusFound) 166 return nil 167 } 168 169 if r.URL.Path == "/callback" { 170 // Process callback 171 sessionData, handle, err := g.oauth.ProcessCallback(r.Context(), r.URL.Query()) 172 if err != nil { 173 return caddyhttp.Error(http.StatusBadRequest, err) 174 } 175 176 // Create Session Cookie 177 cookie, err := g.sessions.CreateCookie( 178 sessionData.AccountDID, 179 handle, 180 24*7*time.Hour, 181 g.Domain, 182 ) 183 if err != nil { 184 return caddyhttp.Error(http.StatusInternalServerError, err) 185 } 186 187 http.SetCookie(w, cookie) 188 http.Redirect(w, r, "/", http.StatusFound) 189 return nil 190 } 191 } 192 193 // 1. Verify stateless cookie here 194 sess, err := g.sessions.VerifyCookie(r) 195 if err == session.ErrExpired { 196 // Attempt transparent refresh if we are in a mode that supports it. 197 // We need an OAuth manager to refresh. 198 // If Standalone, g.oauth is set. 199 // If Auth Hub, g.oauth is nil in Gate. Gate relies on Portal. 200 // However, Gate and Portal SHARE the same DB (g.app.Store). 201 // We can spin up a temporary OAuth manager or use a shared one if we had config. 202 // But Gate in Hub mode doesn't know ClientID/CallbackURL. 203 // Wait, the Refresh Token is bound to the ClientID. 204 // If Gate is just a gate, it can't refresh on behalf of the Portal unless it acts AS the Portal client. 205 206 // For Standalone mode, we can refresh. 207 if g.oauth != nil && sess != nil { 208 clientSession, err := g.oauth.ResumeSession(r.Context(), sess.DID) 209 if err == nil { 210 // Refresh tokens 211 if _, err := clientSession.RefreshTokens(r.Context()); err == nil { 212 // Success! Update cookie. 213 // We need to extend expiration. 214 cookie, err := g.sessions.CreateCookie( 215 clientSession.Data.AccountDID, 216 sess.Handle, // Keep handle from old cookie or lookup 217 24*7*time.Hour, 218 g.Domain, 219 ) 220 if err == nil { 221 http.SetCookie(w, cookie) 222 // Proceed as authorized 223 r.Header.Set("X-Atproto-Did", sess.DID) 224 r.Header.Set("X-Atproto-Handle", sess.Handle) 225 return next.ServeHTTP(w, r) 226 } 227 } 228 } 229 // If refresh failed, fall through to re-login logic 230 } 231 } else if err == nil { 232 // Session valid! 233 // Check authorization against allowlist 234 allowed := false 235 for _, allow := range g.Allow { 236 if allow == sess.DID || allow == sess.Handle { 237 allowed = true 238 break 239 } 240 } 241 242 if allowed { 243 // Inject headers 244 r.Header.Set("X-Atproto-Did", sess.DID) 245 r.Header.Set("X-Atproto-Handle", sess.Handle) 246 return next.ServeHTTP(w, r) 247 } 248 249 // Authenticated but not authorized 250 w.Header().Set("Content-Type", "text/html; charset=utf-8") 251 w.WriteHeader(http.StatusForbidden) 252 if err := g.renderer.RenderForbidden(w, ui.ForbiddenData{ 253 AppName: g.Domain, 254 DID: sess.DID, 255 Handle: sess.Handle, 256 }); err != nil { 257 g.logger.Error("failed to render forbidden page", zap.Error(err)) 258 } 259 return nil 260 } 261 262 // 2. If invalid/missing, initiate redirect to PDS or Auth Hub 263 // If standalone mode (g.oauth != nil), we can initiate flow here? 264 // But which identity? We need to prompt user for handle. 265 // So we should redirect to a login page or show a simple form. 266 // For simplicity, let's just 401 and tell user to go to /login (which we handle if standalone?) 267 // Wait, we didn't add /login handler to Gate yet. 268 // If standalone, Gate should act as Portal. 269 // Let's implement a simple /login handler in Gate if oauth is enabled. 270 271 if g.oauth != nil { 272 if r.URL.Path == "/login" { 273 if r.Method == "POST" { 274 handle := r.FormValue("handle") 275 // Strip leading @ if present 276 if len(handle) > 0 && handle[0] == '@' { 277 handle = handle[1:] 278 } 279 280 redirectURI, err := g.oauth.StartAuthFlow(r.Context(), handle) 281 if err != nil { 282 // Render error on login page instead of raw JSON 283 w.Header().Set("Content-Type", "text/html; charset=utf-8") 284 // We return 400 Bad Request 285 w.WriteHeader(http.StatusBadRequest) 286 if renderErr := g.renderer.RenderLogin(w, ui.LoginData{ 287 AppName: g.Domain, 288 Redirect: "/", 289 Error: fmt.Sprintf("Authentication failed: %v", err), 290 }); renderErr != nil { 291 g.logger.Error("failed to render login error", zap.Error(renderErr)) 292 } 293 return nil 294 } 295 http.Redirect(w, r, redirectURI, http.StatusFound) 296 return nil 297 } 298 // Show login form 299 w.Header().Set("Content-Type", "text/html; charset=utf-8") 300 if err := g.renderer.RenderLogin(w, ui.LoginData{ 301 AppName: g.Domain, 302 Redirect: "/", 303 }); err != nil { 304 g.logger.Error("failed to render login page", zap.Error(err)) 305 return caddyhttp.Error(http.StatusInternalServerError, err) 306 } 307 return nil 308 } 309 // Redirect to /login 310 http.Redirect(w, r, "/login", http.StatusFound) 311 return nil 312 } 313 314 // If NOT standalone (Auth Hub mode), redirect to the central Auth Portal if configured. 315 if g.PortalURL != "" { 316 // Construct redirect URL: ${PortalURL}/login?redirect_uri=${CurrentURL} 317 // We need to encode the current URL as a query param. 318 // NOTE: Assuming https for now, Caddy usually knows scheme but r.URL.Scheme might be empty. 319 scheme := "https" 320 if r.TLS == nil { 321 scheme = "http" 322 } 323 host := r.Host 324 currentURL := fmt.Sprintf("%s://%s%s", scheme, host, r.URL.RequestURI()) 325 326 portalLogin := fmt.Sprintf("%s/login?redirect_to=%s", g.PortalURL, url.QueryEscape(currentURL)) 327 http.Redirect(w, r, portalLogin, http.StatusFound) 328 return nil 329 } 330 331 // Fallback: 401 332 return caddyhttp.Error(http.StatusUnauthorized, fmt.Errorf("unauthorized")) 333} 334 335// Interface guards 336var ( 337 _ caddy.Provisioner = (*Gate)(nil) 338 _ caddy.Validator = (*Gate)(nil) 339 _ caddyhttp.MiddlewareHandler = (*Gate)(nil) 340 _ caddyfile.Unmarshaler = (*Gate)(nil) 341)