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