Caddy module to require at-proto authentication and restrict routes to DIDs
3

Configure Feed

Select the types of activity you want to include in your feed.

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)