Caddy module to require at-proto authentication and restrict routes to DIDs
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)