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

Configure Feed

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

Refactor: remove standalone mode from Gate, rely on Portal composition

+102 -158
+36 -4
e2e/Caddyfile
··· 6 6 } 7 7 } 8 8 9 - # --- Scenario 1: Standalone App --- 10 - # Acts as its own portal. 9 + # --- Scenario 1: Standalone App (Composed) --- 10 + # Acts as its own portal using composition. 11 11 http://localhost:8081 { 12 12 route { 13 - atproto_gate { 14 - # Standalone mode enabled by setting 'domain' 13 + atproto_portal { 15 14 domain localhost:8081 15 + name "Standalone App 1" 16 + } 17 + atproto_gate { 18 + # Portal is local 19 + portal_url / 20 + # Enable refresh by providing client_id 21 + client_id https://localhost:8081/.well-known/oauth-client-metadata.json 16 22 allow @vvill.dev 17 23 } 18 24 ··· 46 52 respond "Welcome to Service App! You authenticated via the Hub." 47 53 } 48 54 } 55 + 56 + # --- Scenario 3: Standalone app with Custom Paths --- 57 + 58 + # Standalone app serves The Portal, gates access, then the App 59 + http://localhost:8084 { 60 + route { 61 + # First, auth portal 62 + atproto_portal { 63 + domain localhost:8084 64 + name "Standalone App 3" 65 + login_path /atproto/login 66 + logout_path /atproto/logout 67 + } 68 + # Then, make sure user is authenticated 69 + atproto_gate { 70 + # Portal is local but at custom path. 71 + # Gate appends /login to portal_url. 72 + # So we set portal_url to /atproto 73 + portal_url /atproto 74 + client_id https://localhost:8084/.well-known/oauth-client-metadata.json 75 + allow @vvill.dev 76 + } 77 + # Then, they have access to the App 78 + respond "Welcome to Standalone App 3! Custom paths working." 79 + } 80 + }
+40 -150
gate.go
··· 1 1 package caddyatprotoauth 2 2 3 3 import ( 4 - "encoding/json" 5 4 "fmt" 6 5 "net/http" 7 6 "net/url" ··· 27 26 // and validates the session cookie. 28 27 type Gate struct { 29 28 Allow []string `json:"allow,omitempty"` 30 - Domain string `json:"domain,omitempty"` // Public domain for standalone mode (e.g. app.example.com) 31 - PortalURL string `json:"portal_url,omitempty"` // URL of the central auth portal if NOT in standalone mode 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 /) 32 31 UI ui.Config `json:"ui,omitempty"` // Custom UI configuration 33 32 34 - // Paths configuration 35 - LoginPath string `json:"login_path,omitempty"` 36 - LogoutPath string `json:"logout_path,omitempty"` 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 37 38 38 // Dependencies 39 39 app *App ··· 79 79 } 80 80 g.renderer = renderer 81 81 82 - // 5. Initialize OAuth Manager (if domain set for standalone mode) 83 - if g.Domain != "" { 84 - clientID := fmt.Sprintf("https://%s/.well-known/oauth-client-metadata.json", g.Domain) 85 - callbackURL := fmt.Sprintf("https://%s/callback", g.Domain) 86 - 87 - mgr, err := oauth.NewManager(g.app.Store, clientID, callbackURL) 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, "") 88 87 if err != nil { 89 - return fmt.Errorf("failed to init oauth manager: %w", err) 88 + return fmt.Errorf("failed to init oauth manager for refresh: %w", err) 90 89 } 91 90 g.oauth = mgr 92 91 } 93 92 94 - // Defaults for paths 95 - if g.LoginPath == "" { 96 - g.LoginPath = "/login" 97 - } 98 - if g.LogoutPath == "" { 99 - g.LogoutPath = "/logout" 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 = "/" 100 97 } 101 98 102 99 return nil ··· 117 114 switch d.Val() { 118 115 case "allow": 119 116 g.Allow = append(g.Allow, d.RemainingArgs()...) 120 - case "domain": 117 + case "client_id": 121 118 if !d.NextArg() { 122 119 return d.ArgErr() 123 120 } 124 - g.Domain = d.Val() 121 + g.ClientID = d.Val() 125 122 case "portal_url": 126 123 if !d.NextArg() { 127 124 return d.ArgErr() 128 125 } 129 126 g.PortalURL = d.Val() 130 - case "login_path": 131 - if !d.NextArg() { 132 - return d.ArgErr() 133 - } 134 - g.LoginPath = d.Val() 135 - case "logout_path": 136 - if !d.NextArg() { 137 - return d.ArgErr() 138 - } 139 - g.LogoutPath = d.Val() 140 127 case "ui": 141 128 for nesting := d.Nesting(); d.NextBlock(nesting); { 142 129 switch d.Val() { ··· 171 158 172 159 // ServeHTTP implements caddyhttp.MiddlewareHandler. 173 160 func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { 174 - // If in Standalone Mode, handle OAuth paths 175 - if g.oauth != nil { 176 - if r.URL.Path == "/.well-known/oauth-client-metadata.json" { 177 - meta, err := g.oauth.GetClientMetadata() 178 - if err != nil { 179 - return caddyhttp.Error(http.StatusInternalServerError, err) 180 - } 181 - w.Header().Set("Content-Type", "application/json") 182 - return json.NewEncoder(w).Encode(meta) 183 - } 184 - if r.URL.Path == g.LogoutPath { 185 - // Invalidate credential if session exists 186 - sess, err := g.sessions.VerifyCookie(r) 187 - if err == nil || err == session.ErrExpired { 188 - if g.oauth != nil { 189 - // Standalone mode: we can logout directly 190 - if err := g.oauth.Logout(r.Context(), sess.DID); err != nil { 191 - g.logger.Error("failed to revoke session during logout", zap.Error(err)) 192 - } 193 - } 194 - } 195 - 196 - // Clear session cookie 197 - http.SetCookie(w, g.sessions.ClearCookie(g.Domain)) 198 - http.Redirect(w, r, g.LoginPath, http.StatusFound) 199 - return nil 200 - } 201 - 202 - if r.URL.Path == "/callback" { 203 - // Process callback 204 - sessionData, handle, err := g.oauth.ProcessCallback(r.Context(), r.URL.Query()) 205 - if err != nil { 206 - return caddyhttp.Error(http.StatusBadRequest, err) 207 - } 208 - 209 - // Create Session Cookie 210 - cookie, err := g.sessions.CreateCookie( 211 - sessionData.AccountDID, 212 - handle, 213 - 24*7*time.Hour, 214 - g.Domain, 215 - ) 216 - if err != nil { 217 - return caddyhttp.Error(http.StatusInternalServerError, err) 218 - } 219 - 220 - http.SetCookie(w, cookie) 221 - http.Redirect(w, r, "/", http.StatusFound) 222 - return nil 223 - } 224 - } 225 - 226 161 // 1. Verify stateless cookie here 227 162 sess, err := g.sessions.VerifyCookie(r) 228 163 if err == session.ErrExpired { 229 164 // Attempt transparent refresh if we are in a mode that supports it. 230 165 // We need an OAuth manager to refresh. 231 - // If Standalone, g.oauth is set. 232 - // If Auth Hub, g.oauth is nil in Gate. Gate relies on Portal. 233 - // However, Gate and Portal SHARE the same DB (g.app.Store). 234 - // We can spin up a temporary OAuth manager or use a shared one if we had config. 235 - // But Gate in Hub mode doesn't know ClientID/CallbackURL. 236 - // Wait, the Refresh Token is bound to the ClientID. 237 - // If Gate is just a gate, it can't refresh on behalf of the Portal unless it acts AS the Portal client. 166 + // If ClientID is set, g.oauth is set. 238 167 239 - // For Standalone mode, we can refresh. 240 168 if g.oauth != nil && sess != nil { 241 169 clientSession, err := g.oauth.ResumeSession(r.Context(), sess.DID) 242 170 if err == nil { ··· 244 172 if _, err := clientSession.RefreshTokens(r.Context()); err == nil { 245 173 // Success! Update cookie. 246 174 // We need to extend expiration. 175 + // Handle lookup might be needed if not in session? 176 + // Sess has Handle. 247 177 cookie, err := g.sessions.CreateCookie( 248 178 clientSession.Data.AccountDID, 249 - sess.Handle, // Keep handle from old cookie or lookup 179 + sess.Handle, // Keep handle from old cookie 250 180 24*7*time.Hour, 251 - g.Domain, 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, 252 188 ) 253 189 if err == nil { 254 190 http.SetCookie(w, cookie) ··· 283 219 w.Header().Set("Content-Type", "text/html; charset=utf-8") 284 220 w.WriteHeader(http.StatusForbidden) 285 221 if err := g.renderer.RenderForbidden(w, ui.ForbiddenData{ 286 - AppName: g.Domain, 222 + AppName: "Gate", // We don't have Domain/AppName anymore, maybe use Host? 287 223 DID: sess.DID, 288 224 Handle: sess.Handle, 289 225 }); err != nil { ··· 292 228 return nil 293 229 } 294 230 295 - // 2. If invalid/missing, initiate redirect to PDS or Auth Hub 296 - // If standalone mode (g.oauth != nil), we can initiate flow here? 297 - // But which identity? We need to prompt user for handle. 298 - // So we should redirect to a login page or show a simple form. 299 - // For simplicity, let's just 401 and tell user to go to /login (which we handle if standalone?) 300 - // Wait, we didn't add /login handler to Gate yet. 301 - // If standalone, Gate should act as Portal. 302 - // Let's implement a simple /login handler in Gate if oauth is enabled. 303 - 304 - if g.oauth != nil { 305 - if r.URL.Path == g.LoginPath { 306 - if r.Method == "POST" { 307 - handle := r.FormValue("handle") 308 - // Strip leading @ if present 309 - if len(handle) > 0 && handle[0] == '@' { 310 - handle = handle[1:] 311 - } 312 - 313 - redirectURI, err := g.oauth.StartAuthFlow(r.Context(), handle) 314 - if err != nil { 315 - // Render error on login page instead of raw JSON 316 - w.Header().Set("Content-Type", "text/html; charset=utf-8") 317 - // We return 400 Bad Request 318 - w.WriteHeader(http.StatusBadRequest) 319 - if renderErr := g.renderer.RenderLogin(w, ui.LoginData{ 320 - AppName: g.Domain, 321 - Redirect: "/", 322 - Error: fmt.Sprintf("Authentication failed: %v", err), 323 - }); renderErr != nil { 324 - g.logger.Error("failed to render login error", zap.Error(renderErr)) 325 - } 326 - return nil 327 - } 328 - http.Redirect(w, r, redirectURI, http.StatusFound) 329 - return nil 330 - } 331 - // Show login form 332 - w.Header().Set("Content-Type", "text/html; charset=utf-8") 333 - if err := g.renderer.RenderLogin(w, ui.LoginData{ 334 - AppName: g.Domain, 335 - Redirect: "/", 336 - }); err != nil { 337 - g.logger.Error("failed to render login page", zap.Error(err)) 338 - return caddyhttp.Error(http.StatusInternalServerError, err) 339 - } 340 - return nil 341 - } 342 - // Redirect to /login 343 - http.Redirect(w, r, g.LoginPath, http.StatusFound) 344 - return nil 345 - } 346 - 347 - // If NOT standalone (Auth Hub mode), redirect to the central Auth Portal if configured. 231 + // 2. If invalid/missing, initiate redirect to Portal 348 232 if g.PortalURL != "" { 349 233 // Construct redirect URL: ${PortalURL}/login?redirect_uri=${CurrentURL} 350 - // We need to encode the current URL as a query param. 351 - // NOTE: Assuming https for now, Caddy usually knows scheme but r.URL.Scheme might be empty. 352 234 scheme := "https" 353 235 if r.TLS == nil { 354 236 scheme = "http" ··· 356 238 host := r.Host 357 239 currentURL := fmt.Sprintf("%s://%s%s", scheme, host, r.URL.RequestURI()) 358 240 359 - portalLogin := fmt.Sprintf("%s/login?redirect_to=%s", g.PortalURL, url.QueryEscape(currentURL)) 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)) 360 250 http.Redirect(w, r, portalLogin, http.StatusFound) 361 251 return nil 362 252 }
+26 -4
portal.go
··· 27 27 Domain string `json:"domain,omitempty"` // Public domain of the portal (e.g. auth.example.com) 28 28 UI ui.Config `json:"ui,omitempty"` // Custom UI configuration 29 29 30 + // Paths configuration 31 + LoginPath string `json:"login_path,omitempty"` 32 + LogoutPath string `json:"logout_path,omitempty"` 33 + 30 34 // Dependencies 31 35 app *App 32 36 oauth *oauth.Manager ··· 84 88 } 85 89 p.oauth = mgr 86 90 91 + // Defaults for paths 92 + if p.LoginPath == "" { 93 + p.LoginPath = "/login" 94 + } 95 + if p.LogoutPath == "" { 96 + p.LogoutPath = "/logout" 97 + } 98 + 87 99 return nil 88 100 } 89 101 ··· 110 122 return d.ArgErr() 111 123 } 112 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() 113 135 case "ui": 114 136 for nesting := d.Nesting(); d.NextBlock(nesting); { 115 137 switch d.Val() { ··· 190 212 } 191 213 192 214 // 3. Login Start (Form Action) 193 - if r.URL.Path == "/login" && r.Method == "POST" { 215 + if r.URL.Path == p.LoginPath && r.Method == "POST" { 194 216 handle := r.FormValue("handle") 195 217 // Strip leading @ if present 196 218 if len(handle) > 0 && handle[0] == '@' { ··· 223 245 } 224 246 225 247 // 4. Default: Login Page 226 - if r.URL.Path == "/" || r.URL.Path == "/login" { 248 + if r.URL.Path == p.LoginPath || (p.LoginPath == "/" && r.URL.Path == "/") { 227 249 w.Header().Set("Content-Type", "text/html; charset=utf-8") 228 250 if err := p.renderer.RenderLogin(w, ui.LoginData{ 229 251 AppName: p.Name, ··· 236 258 } 237 259 238 260 // 5. Logout 239 - if r.URL.Path == "/logout" { 261 + if r.URL.Path == p.LogoutPath { 240 262 // Invalidate credential if session exists 241 263 sess, err := p.sessions.VerifyCookie(r) 242 264 if err == nil || err == session.ErrExpired { ··· 246 268 } 247 269 248 270 http.SetCookie(w, p.sessions.ClearCookie(p.Domain)) 249 - http.Redirect(w, r, "/login", http.StatusFound) 271 + http.Redirect(w, r, p.LoginPath, http.StatusFound) 250 272 return nil 251 273 } 252 274