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 "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 "github.com/vvill/caddy-atproto-auth/internal/oauth" 14 "github.com/vvill/caddy-atproto-auth/internal/session" 15 "github.com/vvill/caddy-atproto-auth/internal/ui" 16 "go.uber.org/zap" 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 // Dependencies 31 app *App 32 oauth *oauth.Manager 33 sessions *session.Manager 34 renderer *ui.Renderer 35 logger *zap.Logger 36} 37 38// CaddyModule returns the Caddy module information. 39func (Portal) CaddyModule() caddy.ModuleInfo { 40 return caddy.ModuleInfo{ 41 ID: "http.handlers.atproto_portal", 42 New: func() caddy.Module { return new(Portal) }, 43 } 44} 45 46// Provision sets up the module. 47func (p *Portal) Provision(ctx caddy.Context) error { 48 p.logger = ctx.Logger() 49 50 // 1. Get Global App 51 app, err := ctx.App("atproto") 52 if err != nil { 53 return fmt.Errorf("getting atproto app: %w", err) 54 } 55 p.app = app.(*App) 56 57 // 2. Initialize Session Manager 58 if p.app.CookieSecret == "" { 59 return fmt.Errorf("global atproto cookie_secret is required") 60 } 61 p.sessions = session.NewManager(p.app.CookieSecret) 62 63 // 4. Initialize UI Renderer 64 renderer, err := ui.NewRenderer(p.UI) 65 if err != nil { 66 return fmt.Errorf("failed to init ui renderer: %w", err) 67 } 68 p.renderer = renderer 69 70 // 5. Initialize OAuth Manager 71 // We need the domain to construct ClientID and CallbackURL. 72 // If domain is missing, we might defer initialization? No, Manager needs it. 73 // User must configure 'domain' in Caddyfile for now. 74 if p.Domain == "" { 75 return fmt.Errorf("atproto_portal requires 'domain' to be set (e.g. auth.example.com)") 76 } 77 78 clientID := fmt.Sprintf("https://%s/.well-known/oauth-client-metadata.json", p.Domain) 79 callbackURL := fmt.Sprintf("https://%s/callback", p.Domain) 80 81 mgr, err := oauth.NewManager(p.app.Store, clientID, callbackURL) 82 if err != nil { 83 return fmt.Errorf("failed to init oauth manager: %w", err) 84 } 85 p.oauth = mgr 86 87 return nil 88} 89 90// Validate checks that the configuration is valid. 91func (p *Portal) Validate() error { 92 if p.Name == "" { 93 p.Name = "Authentication Portal" 94 } 95 return nil 96} 97 98// UnmarshalCaddyfile implements caddyfile.Unmarshaler. 99func (p *Portal) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { 100 for d.Next() { 101 for nesting := d.Nesting(); d.NextBlock(nesting); { 102 switch d.Val() { 103 case "name": 104 if !d.NextArg() { 105 return d.ArgErr() 106 } 107 p.Name = d.Val() 108 case "domain": 109 if !d.NextArg() { 110 return d.ArgErr() 111 } 112 p.Domain = d.Val() 113 case "ui": 114 for nesting := d.Nesting(); d.NextBlock(nesting); { 115 switch d.Val() { 116 case "login_template": 117 if !d.NextArg() { 118 return d.ArgErr() 119 } 120 p.UI.LoginTemplatePath = d.Val() 121 case "forbidden_template": 122 if !d.NextArg() { 123 return d.ArgErr() 124 } 125 p.UI.ForbiddenTemplatePath = d.Val() 126 default: 127 return d.Errf("unrecognized subdirective '%s'", d.Val()) 128 } 129 } 130 default: 131 return d.Errf("unrecognized subdirective '%s'", d.Val()) 132 } 133 } 134 } 135 return nil 136} 137 138func parseCaddyfilePortal(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) { 139 var p Portal 140 err := p.UnmarshalCaddyfile(h.Dispenser) 141 return &p, err 142} 143 144// ServeHTTP implements caddyhttp.MiddlewareHandler. 145func (p *Portal) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { 146 // 1. Metadata Endpoint 147 if r.URL.Path == "/.well-known/oauth-client-metadata.json" { 148 meta, err := p.oauth.GetClientMetadata() 149 if err != nil { 150 p.logger.Error("failed to get client metadata", zap.Error(err)) 151 http.Error(w, "Internal Server Error", http.StatusInternalServerError) 152 return nil 153 } 154 w.Header().Set("Content-Type", "application/json") 155 json.NewEncoder(w).Encode(meta) 156 return nil 157 } 158 159 // 2. Callback Endpoint 160 if r.URL.Path == "/callback" { 161 // Process callback 162 ctx := r.Context() 163 query := r.URL.Query() 164 165 sessionData, err := p.oauth.ProcessCallback(ctx, query) 166 if err != nil { 167 p.logger.Error("oauth callback failed", zap.Error(err)) 168 http.Error(w, fmt.Sprintf("Authentication failed: %v", err), http.StatusBadRequest) 169 return nil 170 } 171 172 // Create Session Cookie 173 // Use root domain for Auth Hub? Or specific? 174 // For now, use the portal's domain. 175 cookie, err := p.sessions.CreateCookie( 176 sessionData.AccountDID, 177 "user", // We don't have handle in ClientSessionData yet? Indigo might resolve it. 178 // Actually ClientSessionData only has AccountDID. 179 // We might need to resolve handle separately or store it in state? 180 // For now, placeholder handle. 181 24*7*time.Hour, 182 p.Domain, 183 ) 184 if err != nil { 185 p.logger.Error("failed to create session cookie", zap.Error(err)) 186 http.Error(w, "Internal Error", http.StatusInternalServerError) 187 return nil 188 } 189 190 http.SetCookie(w, cookie) 191 192 // Redirect to home or saved location 193 http.Redirect(w, r, "/", http.StatusFound) 194 return nil 195 } 196 197 // 3. Login Start (Form Action) 198 if r.URL.Path == "/login" && r.Method == "POST" { 199 handle := r.FormValue("handle") 200 if handle == "" { 201 http.Error(w, "Handle required", http.StatusBadRequest) 202 return nil 203 } 204 205 // Start Auth Flow 206 redirectURI, err := p.oauth.StartAuthFlow(r.Context(), handle) 207 if err != nil { 208 p.logger.Error("failed to start auth flow", zap.Error(err)) 209 http.Error(w, fmt.Sprintf("Failed to resolve identity: %v", err), http.StatusBadRequest) 210 return nil 211 } 212 213 http.Redirect(w, r, redirectURI, http.StatusFound) 214 return nil 215 } 216 217 // 4. Default: Login Page 218 if r.URL.Path == "/" || r.URL.Path == "/login" { 219 w.Header().Set("Content-Type", "text/html; charset=utf-8") 220 if err := p.renderer.RenderLogin(w, ui.LoginData{ 221 AppName: p.Name, 222 Redirect: "/", 223 }); err != nil { 224 p.logger.Error("failed to render login page", zap.Error(err)) 225 return caddyhttp.Error(http.StatusInternalServerError, err) 226 } 227 return nil 228 } 229 230 return next.ServeHTTP(w, r) 231} 232 233// Interface guards 234var ( 235 _ caddy.Provisioner = (*Portal)(nil) 236 _ caddy.Validator = (*Portal)(nil) 237 _ caddyhttp.MiddlewareHandler = (*Portal)(nil) 238 _ caddyfile.Unmarshaler = (*Portal)(nil) 239)