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 "time" 8 9 "github.com/caddyserver/caddy/v2" 10 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 11 "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 12 "github.com/caddyserver/caddy/v2/modules/caddyhttp" 13 "go.uber.org/zap" 14 "tangled.org/vvill.dev/caddy-atproto-auth/internal/oauth" 15 "tangled.org/vvill.dev/caddy-atproto-auth/internal/session" 16 "tangled.org/vvill.dev/caddy-atproto-auth/internal/ui" 17) 18 19func init() { 20 caddy.RegisterModule(Portal{}) 21 httpcaddyfile.RegisterHandlerDirective("atproto_portal", parseCaddyfilePortal) 22} 23 24// Portal is the centralized authentication portal for Path B (Auth Hub). 25type 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 29 30 // Paths configuration 31 LoginPath string `json:"login_path,omitempty"` 32 LogoutPath string `json:"logout_path,omitempty"` 33 34 // Dependencies 35 app *App 36 oauth *oauth.Manager 37 sessions *session.Manager 38 renderer *ui.Renderer 39 logger *zap.Logger 40} 41 42// CaddyModule returns the Caddy module information. 43func (Portal) CaddyModule() caddy.ModuleInfo { 44 return caddy.ModuleInfo{ 45 ID: "http.handlers.atproto_portal", 46 New: func() caddy.Module { return new(Portal) }, 47 } 48} 49 50// Provision sets up the module. 51func (p *Portal) Provision(ctx caddy.Context) error { 52 p.logger = ctx.Logger() 53 54 // 1. Get Global App 55 app, err := ctx.App("atproto") 56 if err != nil { 57 return fmt.Errorf("getting atproto app: %w", err) 58 } 59 p.app = app.(*App) 60 61 // 2. Initialize Session Manager 62 if p.app.CookieSecret == "" { 63 return fmt.Errorf("global atproto cookie_secret is required") 64 } 65 p.sessions = session.NewManager(p.app.CookieSecret) 66 67 // 4. Initialize UI Renderer 68 renderer, err := ui.NewRenderer(p.UI) 69 if err != nil { 70 return fmt.Errorf("failed to init ui renderer: %w", err) 71 } 72 p.renderer = renderer 73 74 // 5. Initialize OAuth Manager 75 // We need the domain to construct ClientID and CallbackURL. 76 // If domain is missing, we might defer initialization? No, Manager needs it. 77 // User must configure 'domain' in Caddyfile for now. 78 if p.Domain == "" { 79 return fmt.Errorf("atproto_portal requires 'domain' to be set (e.g. auth.example.com)") 80 } 81 82 clientID := fmt.Sprintf("https://%s/.well-known/oauth-client-metadata.json", p.Domain) 83 callbackURL := fmt.Sprintf("https://%s/callback", p.Domain) 84 85 mgr, err := oauth.NewManager(p.app.Store, clientID, callbackURL) 86 if err != nil { 87 return fmt.Errorf("failed to init oauth manager: %w", err) 88 } 89 p.oauth = mgr 90 91 // Defaults for paths 92 if p.LoginPath == "" { 93 p.LoginPath = "/login" 94 } 95 if p.LogoutPath == "" { 96 p.LogoutPath = "/logout" 97 } 98 99 return nil 100} 101 102// Validate checks that the configuration is valid. 103func (p *Portal) Validate() error { 104 if p.Name == "" { 105 p.Name = "Authentication Portal" 106 } 107 return nil 108} 109 110// UnmarshalCaddyfile implements caddyfile.Unmarshaler. 111func (p *Portal) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { 112 for d.Next() { 113 for nesting := d.Nesting(); d.NextBlock(nesting); { 114 switch d.Val() { 115 case "name": 116 if !d.NextArg() { 117 return d.ArgErr() 118 } 119 p.Name = d.Val() 120 case "domain": 121 if !d.NextArg() { 122 return d.ArgErr() 123 } 124 p.Domain = d.Val() 125 case "login_path": 126 if !d.NextArg() { 127 return d.ArgErr() 128 } 129 p.LoginPath = d.Val() 130 case "logout_path": 131 if !d.NextArg() { 132 return d.ArgErr() 133 } 134 p.LogoutPath = d.Val() 135 case "ui": 136 for nesting := d.Nesting(); d.NextBlock(nesting); { 137 switch d.Val() { 138 case "login_template": 139 if !d.NextArg() { 140 return d.ArgErr() 141 } 142 p.UI.LoginTemplatePath = d.Val() 143 case "forbidden_template": 144 if !d.NextArg() { 145 return d.ArgErr() 146 } 147 p.UI.ForbiddenTemplatePath = d.Val() 148 default: 149 return d.Errf("unrecognized subdirective '%s'", d.Val()) 150 } 151 } 152 default: 153 return d.Errf("unrecognized subdirective '%s'", d.Val()) 154 } 155 } 156 } 157 return nil 158} 159 160func parseCaddyfilePortal(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { 161 var p Portal 162 err := p.UnmarshalCaddyfile(h.Dispenser) 163 return &p, err 164} 165 166// ServeHTTP implements caddyhttp.MiddlewareHandler. 167func (p *Portal) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { 168 // 1. Metadata Endpoint 169 if r.URL.Path == "/.well-known/oauth-client-metadata.json" { 170 meta, err := p.oauth.GetClientMetadata() 171 if err != nil { 172 p.logger.Error("failed to get client metadata", zap.Error(err)) 173 http.Error(w, "Internal Server Error", http.StatusInternalServerError) 174 return nil 175 } 176 w.Header().Set("Content-Type", "application/json") 177 json.NewEncoder(w).Encode(meta) 178 return nil 179 } 180 181 // 2. Callback Endpoint 182 if r.URL.Path == "/callback" { 183 // Process callback 184 ctx := r.Context() 185 query := r.URL.Query() 186 187 sessionData, handle, err := p.oauth.ProcessCallback(ctx, query) 188 if err != nil { 189 p.logger.Error("oauth callback failed", zap.Error(err)) 190 http.Error(w, fmt.Sprintf("Authentication failed: %v", err), http.StatusBadRequest) 191 return nil 192 } 193 194 // Create Session Cookie 195 cookie, err := p.sessions.CreateCookie( 196 sessionData.AccountDID, 197 handle, 198 24*7*time.Hour, 199 p.Domain, 200 ) 201 if err != nil { 202 p.logger.Error("failed to create session cookie", zap.Error(err)) 203 http.Error(w, "Internal Error", http.StatusInternalServerError) 204 return nil 205 } 206 207 http.SetCookie(w, cookie) 208 209 // Redirect to home or saved location 210 http.Redirect(w, r, "/", http.StatusFound) 211 return nil 212 } 213 214 // 3. Login Start (Form Action) 215 if r.URL.Path == p.LoginPath && r.Method == "POST" { 216 handle := r.FormValue("handle") 217 // Strip leading @ if present 218 if len(handle) > 0 && handle[0] == '@' { 219 handle = handle[1:] 220 } 221 222 if handle == "" { 223 http.Error(w, "Handle required", http.StatusBadRequest) 224 return nil 225 } 226 227 // Start Auth Flow 228 redirectURI, err := p.oauth.StartAuthFlow(r.Context(), handle) 229 if err != nil { 230 // Render error on login page 231 w.Header().Set("Content-Type", "text/html; charset=utf-8") 232 w.WriteHeader(http.StatusBadRequest) 233 if renderErr := p.renderer.RenderLogin(w, ui.LoginData{ 234 AppName: p.Name, 235 Redirect: "/", 236 Error: fmt.Sprintf("Authentication failed: %v", err), 237 }); renderErr != nil { 238 p.logger.Error("failed to render login error", zap.Error(renderErr)) 239 } 240 return nil 241 } 242 243 http.Redirect(w, r, redirectURI, http.StatusFound) 244 return nil 245 } 246 247 // 4. Default: Login Page 248 if r.URL.Path == p.LoginPath || (p.LoginPath == "/" && r.URL.Path == "/") { 249 w.Header().Set("Content-Type", "text/html; charset=utf-8") 250 if err := p.renderer.RenderLogin(w, ui.LoginData{ 251 AppName: p.Name, 252 Redirect: "/", 253 }); err != nil { 254 p.logger.Error("failed to render login page", zap.Error(err)) 255 return caddyhttp.Error(http.StatusInternalServerError, err) 256 } 257 return nil 258 } 259 260 // 5. Logout 261 if r.URL.Path == p.LogoutPath { 262 // Invalidate credential if session exists 263 sess, err := p.sessions.VerifyCookie(r) 264 if err == nil || err == session.ErrExpired { 265 if err := p.oauth.Logout(r.Context(), sess.DID); err != nil { 266 p.logger.Error("failed to revoke session during logout", zap.Error(err)) 267 } 268 } 269 270 http.SetCookie(w, p.sessions.ClearCookie(p.Domain)) 271 http.Redirect(w, r, p.LoginPath, http.StatusFound) 272 return nil 273 } 274 275 return next.ServeHTTP(w, r) 276} 277 278// Interface guards 279var ( 280 _ caddy.Provisioner = (*Portal)(nil) 281 _ caddy.Validator = (*Portal)(nil) 282 _ caddyhttp.MiddlewareHandler = (*Portal)(nil) 283 _ caddyfile.Unmarshaler = (*Portal)(nil) 284)