Caddy module to require at-proto authentication and restrict routes to DIDs
1package caddyatprotoauth
2
3import (
4 "fmt"
5 "net/http"
6 "net/url"
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/resolver"
16 "tangled.org/vvill.dev/caddy-atproto-auth/internal/session"
17 "tangled.org/vvill.dev/caddy-atproto-auth/internal/ui"
18)
19
20func init() {
21 caddy.RegisterModule(Gate{})
22 httpcaddyfile.RegisterHandlerDirective("atproto_gate", parseCaddyfileGate)
23}
24
25// Gate acts as a middleware that guards endpoints
26// and validates the session cookie.
27type Gate struct {
28 Allow []string `json:"allow,omitempty"`
29 ClientID string `json:"client_id,omitempty"` // ClientID for session refreshing (e.g. https://example.com/client-metadata.json)
30 PortalURL string `json:"portal_url,omitempty"` // URL of the auth portal (e.g. http://localhost:8080 or /)
31 UI ui.Config `json:"ui,omitempty"` // Custom UI configuration
32
33 // Paths configuration - Removed Login/Logout path from Gate as it no longer serves them.
34 // But it might need to know them for redirection?
35 // Currently Gate redirects to PortalURL/login.
36 // If PortalURL is local, we just redirect there.
37
38 // Dependencies
39 app *App
40 resolver *resolver.Resolver
41 sessions *session.Manager
42 oauth *oauth.Manager
43 renderer *ui.Renderer
44 logger *zap.Logger
45}
46
47// CaddyModule returns the Caddy module information.
48func (Gate) CaddyModule() caddy.ModuleInfo {
49 return caddy.ModuleInfo{
50 ID: "http.handlers.atproto_gate",
51 New: func() caddy.Module { return new(Gate) },
52 }
53}
54
55// Provision sets up the module.
56func (g *Gate) Provision(ctx caddy.Context) error {
57 g.logger = ctx.Logger()
58
59 // 1. Get Global App
60 app, err := ctx.App("atproto")
61 if err != nil {
62 return fmt.Errorf("getting atproto app: %w", err)
63 }
64 g.app = app.(*App)
65
66 // 2. Initialize Session Manager (using global secret)
67 if g.app.CookieSecret == "" {
68 return fmt.Errorf("global atproto cookie_secret is required")
69 }
70 g.sessions = session.NewManager(g.app.CookieSecret)
71
72 // 3. Initialize Identity Resolver
73 g.resolver = resolver.New()
74
75 // 4. Initialize UI Renderer
76 renderer, err := ui.NewRenderer(g.UI)
77 if err != nil {
78 return fmt.Errorf("failed to init ui renderer: %w", err)
79 }
80 g.renderer = renderer
81
82 // 5. Initialize OAuth Manager (if client_id set for refresh)
83 if g.ClientID != "" {
84 // We don't strictly need callbackURL for refresh, but we pass empty string.
85 // If Manager needs it, we might need to add it to config.
86 mgr, err := oauth.NewManager(g.app.Store, g.ClientID, "")
87 if err != nil {
88 return fmt.Errorf("failed to init oauth manager for refresh: %w", err)
89 }
90 g.oauth = mgr
91 }
92
93 // Default PortalURL if empty?
94 // If empty, we can't really redirect anywhere meaningful unless we assume /login.
95 if g.PortalURL == "" {
96 g.PortalURL = "/"
97 }
98
99 return nil
100}
101
102// Validate checks that the configuration is valid.
103func (g *Gate) Validate() error {
104 if len(g.Allow) == 0 {
105 return fmt.Errorf("atproto_gate requires at least one 'allow' entry")
106 }
107 return nil
108}
109
110// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
111func (g *Gate) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
112 for d.Next() {
113 for nesting := d.Nesting(); d.NextBlock(nesting); {
114 switch d.Val() {
115 case "allow":
116 g.Allow = append(g.Allow, d.RemainingArgs()...)
117 case "client_id":
118 if !d.NextArg() {
119 return d.ArgErr()
120 }
121 g.ClientID = d.Val()
122 case "portal_url":
123 if !d.NextArg() {
124 return d.ArgErr()
125 }
126 g.PortalURL = d.Val()
127 case "ui":
128 for nesting := d.Nesting(); d.NextBlock(nesting); {
129 switch d.Val() {
130 case "login_template":
131 if !d.NextArg() {
132 return d.ArgErr()
133 }
134 g.UI.LoginTemplatePath = d.Val()
135 case "forbidden_template":
136 if !d.NextArg() {
137 return d.ArgErr()
138 }
139 g.UI.ForbiddenTemplatePath = d.Val()
140 default:
141 return d.Errf("unrecognized subdirective '%s'", d.Val())
142 }
143 }
144 default:
145 return d.Errf("unrecognized subdirective '%s'", d.Val())
146 }
147 }
148 }
149 return nil
150}
151
152// parseCaddyfileGate parses the atproto_gate directive from a Caddyfile.
153func parseCaddyfileGate(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
154 var g Gate
155 err := g.UnmarshalCaddyfile(h.Dispenser)
156 return &g, err
157}
158
159// ServeHTTP implements caddyhttp.MiddlewareHandler.
160func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
161 // 1. Verify stateless cookie here
162 sess, err := g.sessions.VerifyCookie(r)
163 if err == session.ErrExpired {
164 // Attempt transparent refresh if we are in a mode that supports it.
165 // We need an OAuth manager to refresh.
166 // If ClientID is set, g.oauth is set.
167
168 if g.oauth != nil && sess != nil {
169 clientSession, err := g.oauth.ResumeSession(r.Context(), sess.DID)
170 if err == nil {
171 // Refresh tokens
172 if _, err := clientSession.RefreshTokens(r.Context()); err == nil {
173 // Success! Update cookie.
174 // We need to extend expiration.
175 // Handle lookup might be needed if not in session?
176 // Sess has Handle.
177 cookie, err := g.sessions.CreateCookie(
178 clientSession.Data.AccountDID,
179 sess.Handle, // Keep handle from old cookie
180 24*7*time.Hour,
181 // Domain for cookie?
182 // Previously we used g.Domain. Now we don't have it.
183 // We can use request host or empty (current domain).
184 // If we leave it empty, it defaults to host.
185 // But CreateCookie expects a domain string?
186 // Let's check session.CreateCookie signature.
187 r.Host,
188 )
189 if err == nil {
190 http.SetCookie(w, cookie)
191 // Proceed as authorized
192 r.Header.Set("X-Atproto-Did", sess.DID)
193 r.Header.Set("X-Atproto-Handle", sess.Handle)
194 return next.ServeHTTP(w, r)
195 }
196 }
197 }
198 // If refresh failed, fall through to re-login logic
199 }
200 } else if err == nil {
201 // Session valid!
202 // Check authorization against allowlist
203 allowed := false
204 for _, allow := range g.Allow {
205 if allow == sess.DID || allow == sess.Handle {
206 allowed = true
207 break
208 }
209 }
210
211 if allowed {
212 // Inject headers
213 r.Header.Set("X-Atproto-Did", sess.DID)
214 r.Header.Set("X-Atproto-Handle", sess.Handle)
215 return next.ServeHTTP(w, r)
216 }
217
218 // Authenticated but not authorized
219 w.Header().Set("Content-Type", "text/html; charset=utf-8")
220 w.WriteHeader(http.StatusForbidden)
221 if err := g.renderer.RenderForbidden(w, ui.ForbiddenData{
222 AppName: "Gate", // We don't have Domain/AppName anymore, maybe use Host?
223 DID: sess.DID,
224 Handle: sess.Handle,
225 }); err != nil {
226 g.logger.Error("failed to render forbidden page", zap.Error(err))
227 }
228 return nil
229 }
230
231 // 2. If invalid/missing, initiate redirect to Portal
232 if g.PortalURL != "" {
233 // Construct redirect URL: ${PortalURL}/login?redirect_uri=${CurrentURL}
234 scheme := "https"
235 if r.TLS == nil {
236 scheme = "http"
237 }
238 host := r.Host
239 currentURL := fmt.Sprintf("%s://%s%s", scheme, host, r.URL.RequestURI())
240
241 // Ensure PortalURL doesn't end with / if we append /login
242 portalURL := g.PortalURL
243 if portalURL == "/" {
244 portalURL = ""
245 } else if len(portalURL) > 0 && portalURL[len(portalURL)-1] == '/' {
246 portalURL = portalURL[:len(portalURL)-1]
247 }
248
249 portalLogin := fmt.Sprintf("%s/login?redirect_to=%s", portalURL, url.QueryEscape(currentURL))
250 http.Redirect(w, r, portalLogin, http.StatusFound)
251 return nil
252 }
253
254 // Fallback: 401
255 return caddyhttp.Error(http.StatusUnauthorized, fmt.Errorf("unauthorized"))
256}
257
258// Interface guards
259var (
260 _ caddy.Provisioner = (*Gate)(nil)
261 _ caddy.Validator = (*Gate)(nil)
262 _ caddyhttp.MiddlewareHandler = (*Gate)(nil)
263 _ caddyfile.Unmarshaler = (*Gate)(nil)
264)