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.

1package caddyatprotoauth 2 3import ( 4 "encoding/json" 5 "fmt" 6 "net/http" 7 "net/url" 8 "strings" 9 "time" 10 11 "github.com/caddyserver/caddy/v2" 12 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 13 "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 14 "github.com/caddyserver/caddy/v2/modules/caddyhttp" 15 "go.uber.org/zap" 16 "tangled.org/vvill.dev/caddy-atproto-auth/internal/oauth" 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(Portal{}) 23 httpcaddyfile.RegisterHandlerDirective("atproto_portal", parseCaddyfilePortal) 24} 25 26// Portal is the centralized authentication portal for Path B (Auth Hub). 27type Portal struct { 28 Name string `json:"name,omitempty"` 29 Domain string `json:"domain,omitempty"` // Public domain of the portal (e.g. auth.example.com) 30 UI ui.Config `json:"ui,omitempty"` // Custom UI configuration 31 32 // Paths configuration 33 PathPrefix string `json:"path_prefix,omitempty"` 34 35 // Dependencies 36 app *App 37 oauth *oauth.Manager 38 sessions *session.Manager 39 renderer *ui.Renderer 40 logger *zap.Logger 41} 42 43// CaddyModule returns the Caddy module information. 44func (Portal) CaddyModule() caddy.ModuleInfo { 45 return caddy.ModuleInfo{ 46 ID: "http.handlers.atproto_portal", 47 New: func() caddy.Module { return new(Portal) }, 48 } 49} 50 51// Provision sets up the module. 52func (p *Portal) Provision(ctx caddy.Context) error { 53 p.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 p.app = app.(*App) 61 62 // 2. Initialize Session Manager from global app 63 p.sessions = p.app.SessionManager 64 65 // 4. Initialize UI Renderer 66 renderer, err := ui.NewRenderer(p.UI) 67 if err != nil { 68 return fmt.Errorf("failed to init ui renderer: %w", err) 69 } 70 p.renderer = renderer 71 72 // 5. Initialize OAuth Manager 73 // We need the domain to construct ClientID and CallbackURL. 74 // If domain is missing, we might defer initialization? No, Manager needs it. 75 // User must configure 'domain' in Caddyfile for now. 76 if p.Domain == "" { 77 return fmt.Errorf("atproto_portal requires 'domain' to be set (e.g. auth.example.com)") 78 } 79 80 clientID := fmt.Sprintf("https://%s/.well-known/oauth-client-metadata.json", p.Domain) 81 callbackURL := fmt.Sprintf("https://%s/callback", p.Domain) 82 83 mgr, err := oauth.NewManager(p.app.Store, clientID, callbackURL) 84 if err != nil { 85 return fmt.Errorf("failed to init oauth manager: %w", err) 86 } 87 p.oauth = mgr 88 89 // Defaults for paths 90 // If PathPrefix is set (e.g. /auth), endpoints become /auth/login and /auth/logout 91 // If PathPrefix is empty, endpoints are /login and /logout 92 93 return nil 94} 95 96// Validate checks that the configuration is valid. 97func (p *Portal) Validate() error { 98 if p.Name == "" { 99 p.Name = "Authentication Portal" 100 } 101 return nil 102} 103 104// UnmarshalCaddyfile implements caddyfile.Unmarshaler. 105func (p *Portal) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { 106 for d.Next() { 107 for nesting := d.Nesting(); d.NextBlock(nesting); { 108 switch d.Val() { 109 case "name": 110 if !d.NextArg() { 111 return d.ArgErr() 112 } 113 p.Name = d.Val() 114 case "domain": 115 if !d.NextArg() { 116 return d.ArgErr() 117 } 118 p.Domain = d.Val() 119 case "path_prefix": 120 if !d.NextArg() { 121 return d.ArgErr() 122 } 123 p.PathPrefix = d.Val() 124 case "ui": 125 for nesting := d.Nesting(); d.NextBlock(nesting); { 126 switch d.Val() { 127 case "login_template": 128 if !d.NextArg() { 129 return d.ArgErr() 130 } 131 p.UI.LoginTemplatePath = d.Val() 132 case "forbidden_template": 133 if !d.NextArg() { 134 return d.ArgErr() 135 } 136 p.UI.ForbiddenTemplatePath = d.Val() 137 default: 138 return d.Errf("unrecognized subdirective '%s'", d.Val()) 139 } 140 } 141 default: 142 return d.Errf("unrecognized subdirective '%s'", d.Val()) 143 } 144 } 145 } 146 return nil 147} 148 149func parseCaddyfilePortal(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { 150 var p Portal 151 err := p.UnmarshalCaddyfile(h.Dispenser) 152 return &p, err 153} 154 155// ServeHTTP implements caddyhttp.MiddlewareHandler. 156func (p *Portal) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { 157 // 1. Metadata Endpoint 158 if r.URL.Path == "/.well-known/oauth-client-metadata.json" { 159 meta, err := p.oauth.GetClientMetadata() 160 if err != nil { 161 p.logger.Error("failed to get client metadata", zap.Error(err)) 162 http.Error(w, "Internal Server Error", http.StatusInternalServerError) 163 return nil 164 } 165 w.Header().Set("Content-Type", "application/json") 166 json.NewEncoder(w).Encode(meta) 167 return nil 168 } 169 170 // 2. Callback Endpoint 171 if r.URL.Path == "/callback" { 172 // Process callback 173 ctx := r.Context() 174 query := r.URL.Query() 175 176 sessionData, handle, err := p.oauth.ProcessCallback(ctx, query) 177 if err != nil { 178 p.logger.Error("oauth callback failed", zap.Error(err)) 179 w.Header().Set("Content-Type", "text/html; charset=utf-8") 180 w.WriteHeader(http.StatusBadRequest) 181 _ = p.renderer.RenderLogin(w, ui.LoginData{AppName: p.Name, Error: fmt.Sprintf("Authentication failed: %v", err)}) 182 return nil 183 } 184 185 reqDomain := strings.Split(p.Domain, ":")[0] 186 187 // Create Session Cookie 188 cookie, err := p.sessions.CreateCookie( 189 sessionData.AccountDID, 190 handle, 191 sessionData.SessionID, 192 24*7*time.Hour, 193 reqDomain, 194 ) 195 if err != nil { 196 p.logger.Error("failed to create session cookie", zap.Error(err)) 197 http.Error(w, "Internal Error", http.StatusInternalServerError) 198 return nil 199 } 200 201 http.SetCookie(w, cookie) 202 203 appNameForLog := p.Name 204 if appNameForLog == "" { 205 appNameForLog = reqDomain 206 } 207 p.logger.Info(fmt.Sprintf("@%s (did: %s) has logged in for %s", handle, sessionData.AccountDID.String(), appNameForLog)) 208 209 // Check for redirect_to cookie 210 redirectTo := "/" 211 state := r.URL.Query().Get("state") 212 cookieName := fmt.Sprintf("atproto_redirect_to_%s", state) 213 if redirectCookie, err := r.Cookie(cookieName); err == nil && redirectCookie.Value != "" { 214 redirectTo = redirectCookie.Value 215 // Clear cookie 216 http.SetCookie(w, &http.Cookie{ 217 Name: cookieName, 218 Value: "", 219 Path: "/", 220 MaxAge: -1, 221 HttpOnly: true, 222 Secure: true, 223 }) 224 225 // Basic open redirect mitigation: ensure it's a relative path or matches CookieDomain/Domain 226 if strings.HasPrefix(redirectTo, "http://") || strings.HasPrefix(redirectTo, "https://") { 227 parsed, err := url.Parse(redirectTo) 228 // Allow redirect if host is our exact domain, or if cookie domain is a parent of the host 229 isAllowedDomain := false 230 if err == nil { 231 h := parsed.Host 232 if h == p.Domain { 233 isAllowedDomain = true 234 } 235 } 236 237 if !isAllowedDomain { 238 p.logger.Warn("blocked cross-domain redirect", zap.String("url", redirectTo)) 239 redirectTo = "/" // Fallback to home if invalid or not matching domain 240 } 241 } 242 } 243 244 // Redirect to home or saved location 245 http.Redirect(w, r, redirectTo, http.StatusFound) 246 return nil 247 } 248 249 // 3. Login Start (Form Action) 250 loginPath := p.PathPrefix + "/login" 251 logoutPath := p.PathPrefix + "/logout" 252 253 if r.URL.Path == loginPath && r.Method == "POST" { 254 handle := r.FormValue("handle") 255 // Strip leading @ if present 256 if len(handle) > 0 && handle[0] == '@' { 257 handle = handle[1:] 258 } 259 260 if handle == "" { 261 http.Error(w, "Handle required", http.StatusBadRequest) 262 return nil 263 } 264 265 // Start Auth Flow 266 redirectURI, err := p.oauth.StartAuthFlow(r.Context(), handle) 267 if err != nil { 268 // Render error on login page 269 w.Header().Set("Content-Type", "text/html; charset=utf-8") 270 w.WriteHeader(http.StatusBadRequest) 271 if renderErr := p.renderer.RenderLogin(w, ui.LoginData{ 272 AppName: p.Name, 273 Redirect: r.FormValue("redirect_to"), 274 Error: fmt.Sprintf("Authentication failed: %v", err), 275 }); renderErr != nil { 276 p.logger.Error("failed to render login error", zap.Error(renderErr)) 277 } 278 return nil 279 } 280 281 if redirectTo := r.FormValue("redirect_to"); redirectTo != "" { 282 u, _ := url.Parse(redirectURI) 283 state := u.Query().Get("state") 284 http.SetCookie(w, &http.Cookie{ 285 Name: fmt.Sprintf("atproto_redirect_to_%s", state), 286 Value: redirectTo, 287 Path: "/", 288 MaxAge: 300, 289 HttpOnly: true, 290 Secure: true, 291 SameSite: http.SameSiteLaxMode, 292 }) 293 } 294 295 http.Redirect(w, r, redirectURI, http.StatusFound) 296 return nil 297 } 298 299 // 4. Default: Login Page 300 if r.URL.Path == loginPath || (loginPath == "/login" && r.URL.Path == "/") { 301 w.Header().Set("Content-Type", "text/html; charset=utf-8") 302 if err := p.renderer.RenderLogin(w, ui.LoginData{ 303 AppName: p.Name, 304 Redirect: r.URL.Query().Get("redirect_to"), 305 }); err != nil { 306 p.logger.Error("failed to render login page", zap.Error(err)) 307 return caddyhttp.Error(http.StatusInternalServerError, err) 308 } 309 return nil 310 } 311 312 // 5. Logout 313 if r.URL.Path == logoutPath { 314 // Invalidate credential if session exists 315 sess, err := p.sessions.VerifyCookie(r) 316 317 reqDomain := strings.Split(p.Domain, ":")[0] 318 319 if err == nil || err == session.ErrExpired { 320 appNameForLog := p.Name 321 if appNameForLog == "" { 322 appNameForLog = reqDomain 323 } 324 p.logger.Info(fmt.Sprintf("@%s (did: %s) has logged out for %s", sess.Handle, sess.DID, appNameForLog)) 325 326 if err := p.oauth.Logout(r.Context(), sess.DID, sess.SessionID); err != nil { 327 p.logger.Error("failed to revoke session during logout", zap.Error(err)) 328 } 329 } 330 331 http.SetCookie(w, p.sessions.ClearCookie(reqDomain)) 332 333 // Handle redirect_to for logout 334 redirectTo := r.URL.Query().Get("redirect_to") 335 if redirectTo == "" { 336 redirectTo = loginPath 337 } else { 338 // Basic open redirect mitigation: ensure it's a relative path or matches CookieDomain/Domain 339 if strings.HasPrefix(redirectTo, "http://") || strings.HasPrefix(redirectTo, "https://") { 340 parsed, err := url.Parse(redirectTo) 341 isAllowedDomain := false 342 if err == nil { 343 h := parsed.Host 344 if h == p.Domain { 345 isAllowedDomain = true 346 } 347 } 348 349 if !isAllowedDomain { 350 p.logger.Warn("blocked cross-domain redirect on logout", zap.String("url", redirectTo)) 351 redirectTo = loginPath // Fallback to login page 352 } 353 } 354 } 355 356 http.Redirect(w, r, redirectTo, http.StatusFound) 357 return nil 358 } 359 360 return next.ServeHTTP(w, r) 361} 362 363// Interface guards 364var ( 365 _ caddy.Provisioner = (*Portal)(nil) 366 _ caddy.Validator = (*Portal)(nil) 367 _ caddyhttp.MiddlewareHandler = (*Portal)(nil) 368 _ caddyfile.Unmarshaler = (*Portal)(nil) 369)