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.

feat: simplify portal path config with path_prefix and update composition docs

+53 -29
+40 -8
README.md
··· 70 70 # Path to a custom HTML template for the login page. 71 71 login_template /path/to/login.html 72 72 } 73 + 74 + # Path Prefix Configuration (Optional) 75 + # Prepends a prefix to /login and /logout paths. 76 + # Useful to avoid conflicts with downstream apps. 77 + # e.g., path_prefix /auth -> /auth/login, /auth/logout 78 + # path_prefix /auth 73 79 } 74 80 } 75 81 ``` ··· 88 94 89 95 # URL of the central Auth Portal. 90 96 # Requests without a valid session will be redirected here. 91 - # REQUIRED (unless in Standalone Mode). 97 + # REQUIRED. 98 + # If the Portal uses a path_prefix (e.g. /auth), append it here (e.g. https://auth.example.com/auth) 92 99 portal_url https://auth.example.com 93 100 94 - # Standalone Mode Configuration (Alternative to portal_url) 95 - # If set, this gate acts as its own portal. 96 - # domain app.example.com 101 + # Client ID for Transparent Refresh (Optional) 102 + # If provided, enables background token refreshing using the shared DB. 103 + # Should match the Portal's Client ID (usually https://domain/.well-known/oauth-client-metadata.json). 104 + # client_id https://app.example.com/.well-known/oauth-client-metadata.json 97 105 98 106 # Custom UI templates (optional) 99 107 ui { 100 108 # Path to a custom HTML template for the "Access Denied" page. 101 109 forbidden_template /path/to/forbidden.html 102 110 } 103 - 104 - # Standalone Mode Path Configuration (Optional) 105 - # login_path /auth/login 106 - # logout_path /auth/logout 107 111 } 108 112 109 113 reverse_proxy localhost:8080 114 + } 115 + ``` 116 + 117 + ### Composition: "Standalone Mode" 118 + 119 + To act as a self-contained Authentication Server and Gate in one route, simply compose both directives. 120 + 121 + ```caddyfile 122 + app.example.com { 123 + route { 124 + atproto_portal { 125 + domain app.example.com 126 + # Optional: move auth paths to /auth/... 127 + path_prefix /auth 128 + } 129 + 130 + atproto_gate { 131 + # Redirect to local portal (respecting prefix) 132 + portal_url /auth 133 + 134 + # Enable refresh 135 + client_id https://app.example.com/.well-known/oauth-client-metadata.json 136 + 137 + allow @alice.bsky.social 138 + } 139 + 140 + reverse_proxy localhost:8080 141 + } 110 142 } 111 143 ``` 112 144
+1 -2
e2e/Caddyfile
··· 62 62 atproto_portal { 63 63 domain localhost:8084 64 64 name "Standalone App 3" 65 - login_path /atproto/login 66 - logout_path /atproto/logout 65 + path_prefix /atproto 67 66 } 68 67 # Then, make sure user is authenticated 69 68 atproto_gate {
+12 -19
portal.go
··· 28 28 UI ui.Config `json:"ui,omitempty"` // Custom UI configuration 29 29 30 30 // Paths configuration 31 - LoginPath string `json:"login_path,omitempty"` 32 - LogoutPath string `json:"logout_path,omitempty"` 31 + PathPrefix string `json:"path_prefix,omitempty"` 33 32 34 33 // Dependencies 35 34 app *App ··· 89 88 p.oauth = mgr 90 89 91 90 // Defaults for paths 92 - if p.LoginPath == "" { 93 - p.LoginPath = "/login" 94 - } 95 - if p.LogoutPath == "" { 96 - p.LogoutPath = "/logout" 97 - } 91 + // If PathPrefix is set (e.g. /auth), endpoints become /auth/login and /auth/logout 92 + // If PathPrefix is empty, endpoints are /login and /logout 98 93 99 94 return nil 100 95 } ··· 122 117 return d.ArgErr() 123 118 } 124 119 p.Domain = d.Val() 125 - case "login_path": 120 + case "path_prefix": 126 121 if !d.NextArg() { 127 122 return d.ArgErr() 128 123 } 129 - p.LoginPath = d.Val() 130 - case "logout_path": 131 - if !d.NextArg() { 132 - return d.ArgErr() 133 - } 134 - p.LogoutPath = d.Val() 124 + p.PathPrefix = d.Val() 135 125 case "ui": 136 126 for nesting := d.Nesting(); d.NextBlock(nesting); { 137 127 switch d.Val() { ··· 212 202 } 213 203 214 204 // 3. Login Start (Form Action) 215 - if r.URL.Path == p.LoginPath && r.Method == "POST" { 205 + loginPath := p.PathPrefix + "/login" 206 + logoutPath := p.PathPrefix + "/logout" 207 + 208 + if r.URL.Path == loginPath && r.Method == "POST" { 216 209 handle := r.FormValue("handle") 217 210 // Strip leading @ if present 218 211 if len(handle) > 0 && handle[0] == '@' { ··· 245 238 } 246 239 247 240 // 4. Default: Login Page 248 - if r.URL.Path == p.LoginPath || (p.LoginPath == "/" && r.URL.Path == "/") { 241 + if r.URL.Path == loginPath || (loginPath == "/login" && r.URL.Path == "/") { 249 242 w.Header().Set("Content-Type", "text/html; charset=utf-8") 250 243 if err := p.renderer.RenderLogin(w, ui.LoginData{ 251 244 AppName: p.Name, ··· 258 251 } 259 252 260 253 // 5. Logout 261 - if r.URL.Path == p.LogoutPath { 254 + if r.URL.Path == logoutPath { 262 255 // Invalidate credential if session exists 263 256 sess, err := p.sessions.VerifyCookie(r) 264 257 if err == nil || err == session.ErrExpired { ··· 268 261 } 269 262 270 263 http.SetCookie(w, p.sessions.ClearCookie(p.Domain)) 271 - http.Redirect(w, r, p.LoginPath, http.StatusFound) 264 + http.Redirect(w, r, loginPath, http.StatusFound) 272 265 return nil 273 266 } 274 267