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