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