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 "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)