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/resolver"
15 "github.com/vvill/caddy-atproto-auth/internal/session"
16)
17
18func init() {
19 caddy.RegisterModule(Gate{})
20 httpcaddyfile.RegisterHandlerDirective("atproto_gate", parseCaddyfileGate)
21}
22
23// Gate acts as a middleware that guards endpoints
24// and validates the session cookie.
25type Gate struct {
26 Allow []string `json:"allow,omitempty"`
27 Domain string `json:"domain,omitempty"` // Public domain for standalone mode (e.g. app.example.com)
28
29 // Dependencies
30 app *App
31 resolver *resolver.Resolver
32 sessions *session.Manager
33 oauth *oauth.Manager
34}
35
36// CaddyModule returns the Caddy module information.
37func (Gate) CaddyModule() caddy.ModuleInfo {
38 return caddy.ModuleInfo{
39 ID: "http.handlers.atproto_gate",
40 New: func() caddy.Module { return new(Gate) },
41 }
42}
43
44// Provision sets up the module.
45func (g *Gate) Provision(ctx caddy.Context) error {
46 // 1. Get Global App
47 app, err := ctx.App("atproto")
48 if err != nil {
49 return fmt.Errorf("getting atproto app: %w", err)
50 }
51 g.app = app.(*App)
52
53 // 2. Initialize Session Manager (using global secret)
54 if g.app.CookieSecret == "" {
55 return fmt.Errorf("global atproto cookie_secret is required")
56 }
57 g.sessions = session.NewManager(g.app.CookieSecret)
58
59 // 3. Initialize Identity Resolver
60 g.resolver = resolver.New()
61
62 // 4. Initialize OAuth Manager (if domain set for standalone mode)
63 if g.Domain != "" {
64 clientID := fmt.Sprintf("https://%s/.well-known/oauth-client-metadata.json", g.Domain)
65 callbackURL := fmt.Sprintf("https://%s/callback", g.Domain)
66
67 mgr, err := oauth.NewManager(g.app.Store, clientID, callbackURL)
68 if err != nil {
69 return fmt.Errorf("failed to init oauth manager: %w", err)
70 }
71 g.oauth = mgr
72 }
73
74 return nil
75}
76
77// Validate checks that the configuration is valid.
78func (g *Gate) Validate() error {
79 if len(g.Allow) == 0 {
80 return fmt.Errorf("atproto_gate requires at least one 'allow' entry")
81 }
82 return nil
83}
84
85// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
86func (g *Gate) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
87 for d.Next() {
88 for nesting := d.Nesting(); d.NextBlock(nesting); {
89 switch d.Val() {
90 case "allow":
91 g.Allow = append(g.Allow, d.RemainingArgs()...)
92 case "domain":
93 if !d.NextArg() {
94 return d.ArgErr()
95 }
96 g.Domain = d.Val()
97 default:
98 return d.Errf("unrecognized subdirective '%s'", d.Val())
99 }
100 }
101 }
102 return nil
103}
104
105// parseCaddyfileGate parses the atproto_gate directive from a Caddyfile.
106func parseCaddyfileGate(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
107 var g Gate
108 err := g.UnmarshalCaddyfile(h.Dispenser)
109 return &g, err
110}
111
112// ServeHTTP implements caddyhttp.MiddlewareHandler.
113func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
114 // If in Standalone Mode, handle OAuth paths
115 if g.oauth != nil {
116 if r.URL.Path == "/.well-known/oauth-client-metadata.json" {
117 meta, err := g.oauth.GetClientMetadata()
118 if err != nil {
119 return caddyhttp.Error(http.StatusInternalServerError, err)
120 }
121 w.Header().Set("Content-Type", "application/json")
122 return json.NewEncoder(w).Encode(meta)
123 }
124 if r.URL.Path == "/callback" {
125 // Process callback
126 sessionData, err := g.oauth.ProcessCallback(r.Context(), r.URL.Query())
127 if err != nil {
128 return caddyhttp.Error(http.StatusBadRequest, err)
129 }
130
131 // Create Session Cookie
132 cookie, err := g.sessions.CreateCookie(
133 sessionData.AccountDID,
134 "user", // Placeholder handle
135 24*7*time.Hour,
136 g.Domain,
137 )
138 if err != nil {
139 return caddyhttp.Error(http.StatusInternalServerError, err)
140 }
141
142 http.SetCookie(w, cookie)
143 http.Redirect(w, r, "/", http.StatusFound)
144 return nil
145 }
146 }
147
148 // 1. Verify stateless cookie here
149 sess, err := g.sessions.VerifyCookie(r)
150 if err == nil {
151 // Session valid!
152 // Check authorization against allowlist
153 allowed := false
154 for _, allow := range g.Allow {
155 if allow == sess.DID || allow == sess.Handle {
156 allowed = true
157 break
158 }
159 }
160
161 if allowed {
162 // Inject headers
163 r.Header.Set("X-Atproto-Did", sess.DID)
164 r.Header.Set("X-Atproto-Handle", sess.Handle)
165 return next.ServeHTTP(w, r)
166 }
167
168 // Authenticated but not authorized
169 return caddyhttp.Error(http.StatusForbidden, fmt.Errorf("user not authorized"))
170 }
171
172 // 2. If invalid/missing, initiate redirect to PDS or Auth Hub
173 // If standalone mode (g.oauth != nil), we can initiate flow here?
174 // But which identity? We need to prompt user for handle.
175 // So we should redirect to a login page or show a simple form.
176 // For simplicity, let's just 401 and tell user to go to /login (which we handle if standalone?)
177 // Wait, we didn't add /login handler to Gate yet.
178 // If standalone, Gate should act as Portal.
179 // Let's implement a simple /login handler in Gate if oauth is enabled.
180
181 if g.oauth != nil {
182 if r.URL.Path == "/login" {
183 if r.Method == "POST" {
184 handle := r.FormValue("handle")
185 redirectURI, err := g.oauth.StartAuthFlow(r.Context(), handle)
186 if err != nil {
187 return caddyhttp.Error(http.StatusBadRequest, err)
188 }
189 http.Redirect(w, r, redirectURI, http.StatusFound)
190 return nil
191 }
192 // Show login form
193 w.Header().Set("Content-Type", "text/html")
194 fmt.Fprintf(w, `
195 <html><body>
196 <h1>Login</h1>
197 <form method="POST" action="/login">
198 <input name="handle" placeholder="@user.bsky.social" required>
199 <button>Login</button>
200 </form>
201 </body></html>
202 `)
203 return nil
204 }
205 // Redirect to /login
206 http.Redirect(w, r, "/login", http.StatusFound)
207 return nil
208 }
209
210 // If NOT standalone (Auth Hub mode), we should redirect to the central Auth Portal.
211 // We don't know where it is unless configured.
212 // For now, return 401.
213 return caddyhttp.Error(http.StatusUnauthorized, fmt.Errorf("unauthorized"))
214}
215
216// Interface guards
217var (
218 _ caddy.Provisioner = (*Gate)(nil)
219 _ caddy.Validator = (*Gate)(nil)
220 _ caddyhttp.MiddlewareHandler = (*Gate)(nil)
221 _ caddyfile.Unmarshaler = (*Gate)(nil)
222)