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.

feat: Complete UI customization and integration tests

+582 -62
-25
Caddyfile.test
··· 1 - { 2 - admin off 3 - atproto { 4 - storage_path ./test.db 5 - cookie_secret "my-secret-key-must-be-very-long-and-secure" 6 - } 7 - } 8 - 9 - :8080 { 10 - route /auth/* { 11 - atproto_portal { 12 - domain localhost:8080 13 - name "Test Portal" 14 - } 15 - } 16 - 17 - route /protected/* { 18 - atproto_gate { 19 - allow @test.bsky.social 20 - } 21 - respond "You are authorized!" 22 - } 23 - 24 - respond "Hello World" 25 - }
+77 -17
gate.go
··· 4 4 "encoding/json" 5 5 "fmt" 6 6 "net/http" 7 + "net/url" 7 8 "time" 8 9 9 10 "github.com/caddyserver/caddy/v2" ··· 13 14 "github.com/vvill/caddy-atproto-auth/internal/oauth" 14 15 "github.com/vvill/caddy-atproto-auth/internal/resolver" 15 16 "github.com/vvill/caddy-atproto-auth/internal/session" 17 + "github.com/vvill/caddy-atproto-auth/internal/ui" 18 + "go.uber.org/zap" 16 19 ) 17 20 18 21 func init() { ··· 23 26 // Gate acts as a middleware that guards endpoints 24 27 // and validates the session cookie. 25 28 type Gate struct { 26 - Allow []string `json:"allow,omitempty"` 27 - Domain string `json:"domain,omitempty"` // Public domain for standalone mode (e.g. app.example.com) 29 + 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 32 + UI ui.Config `json:"ui,omitempty"` // Custom UI configuration 28 33 29 34 // Dependencies 30 35 app *App 31 36 resolver *resolver.Resolver 32 37 sessions *session.Manager 33 38 oauth *oauth.Manager 39 + renderer *ui.Renderer 40 + logger *zap.Logger 34 41 } 35 42 36 43 // CaddyModule returns the Caddy module information. ··· 43 50 44 51 // Provision sets up the module. 45 52 func (g *Gate) Provision(ctx caddy.Context) error { 53 + g.logger = ctx.Logger() 54 + 46 55 // 1. Get Global App 47 56 app, err := ctx.App("atproto") 48 57 if err != nil { ··· 59 68 // 3. Initialize Identity Resolver 60 69 g.resolver = resolver.New() 61 70 62 - // 4. Initialize OAuth Manager (if domain set for standalone mode) 71 + // 4. Initialize UI Renderer 72 + renderer, err := ui.NewRenderer(g.UI) 73 + if err != nil { 74 + return fmt.Errorf("failed to init ui renderer: %w", err) 75 + } 76 + g.renderer = renderer 77 + 78 + // 5. Initialize OAuth Manager (if domain set for standalone mode) 63 79 if g.Domain != "" { 64 80 clientID := fmt.Sprintf("https://%s/.well-known/oauth-client-metadata.json", g.Domain) 65 81 callbackURL := fmt.Sprintf("https://%s/callback", g.Domain) ··· 94 110 return d.ArgErr() 95 111 } 96 112 g.Domain = d.Val() 113 + case "portal_url": 114 + if !d.NextArg() { 115 + return d.ArgErr() 116 + } 117 + g.PortalURL = d.Val() 118 + case "ui": 119 + for nesting := d.Nesting(); d.NextBlock(nesting); { 120 + switch d.Val() { 121 + case "login_template": 122 + if !d.NextArg() { 123 + return d.ArgErr() 124 + } 125 + g.UI.LoginTemplatePath = d.Val() 126 + case "forbidden_template": 127 + if !d.NextArg() { 128 + return d.ArgErr() 129 + } 130 + g.UI.ForbiddenTemplatePath = d.Val() 131 + default: 132 + return d.Errf("unrecognized subdirective '%s'", d.Val()) 133 + } 134 + } 97 135 default: 98 136 return d.Errf("unrecognized subdirective '%s'", d.Val()) 99 137 } ··· 166 204 } 167 205 168 206 // Authenticated but not authorized 169 - return caddyhttp.Error(http.StatusForbidden, fmt.Errorf("user not authorized")) 207 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 208 + w.WriteHeader(http.StatusForbidden) 209 + if err := g.renderer.RenderForbidden(w, ui.ForbiddenData{ 210 + AppName: g.Domain, 211 + DID: sess.DID, 212 + Handle: sess.Handle, 213 + }); err != nil { 214 + g.logger.Error("failed to render forbidden page", zap.Error(err)) 215 + } 216 + return nil 170 217 } 171 218 172 219 // 2. If invalid/missing, initiate redirect to PDS or Auth Hub ··· 190 237 return nil 191 238 } 192 239 // 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 - `) 240 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 241 + if err := g.renderer.RenderLogin(w, ui.LoginData{ 242 + AppName: g.Domain, 243 + Redirect: "/", 244 + }); err != nil { 245 + g.logger.Error("failed to render login page", zap.Error(err)) 246 + return caddyhttp.Error(http.StatusInternalServerError, err) 247 + } 203 248 return nil 204 249 } 205 250 // Redirect to /login ··· 207 252 return nil 208 253 } 209 254 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. 255 + // If NOT standalone (Auth Hub mode), redirect to the central Auth Portal if configured. 256 + if g.PortalURL != "" { 257 + // Construct redirect URL: ${PortalURL}/login?redirect_uri=${CurrentURL} 258 + // We need to encode the current URL as a query param. 259 + // NOTE: Assuming https for now, Caddy usually knows scheme but r.URL.Scheme might be empty. 260 + scheme := "https" 261 + if r.TLS == nil { 262 + scheme = "http" 263 + } 264 + host := r.Host 265 + currentURL := fmt.Sprintf("%s://%s%s", scheme, host, r.URL.RequestURI()) 266 + 267 + portalLogin := fmt.Sprintf("%s/login?redirect_to=%s", g.PortalURL, url.QueryEscape(currentURL)) 268 + http.Redirect(w, r, portalLogin, http.StatusFound) 269 + return nil 270 + } 271 + 272 + // Fallback: 401 213 273 return caddyhttp.Error(http.StatusUnauthorized, fmt.Errorf("unauthorized")) 214 274 } 215 275
+1 -1
go.mod
··· 6 6 github.com/bluesky-social/indigo v0.0.0-20260303011501-01fde705a450 7 7 github.com/caddyserver/caddy/v2 v2.8.4 8 8 github.com/mattn/go-sqlite3 v1.14.34 9 + go.uber.org/zap v1.27.0 9 10 ) 10 11 11 12 require ( ··· 135 136 go.uber.org/automaxprocs v1.5.3 // indirect 136 137 go.uber.org/mock v0.4.0 // indirect 137 138 go.uber.org/multierr v1.11.0 // indirect 138 - go.uber.org/zap v1.27.0 // indirect 139 139 go.uber.org/zap/exp v0.2.0 // indirect 140 140 golang.org/x/crypto v0.23.0 // indirect 141 141 golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
-3
go.sum
··· 164 164 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 165 165 github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= 166 166 github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 167 - github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 168 - github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 169 - github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 170 167 github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= 171 168 github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 172 169 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+134
internal/test/integration_test.go
··· 1 + package test 2 + 3 + import ( 4 + "net/http" 5 + "strings" 6 + "testing" 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/standard" 13 + 14 + _ "github.com/vvill/caddy-atproto-auth" // Register modules 15 + ) 16 + 17 + func TestCaddyIntegration(t *testing.T) { 18 + // 1. Setup Caddyfile 19 + input := ` 20 + { 21 + admin off 22 + atproto { 23 + storage_path :memory: 24 + cookie_secret "my-secret-key-must-be-very-long-and-secure" 25 + } 26 + } 27 + 28 + :8080 { 29 + route /auth/* { 30 + uri strip_prefix /auth 31 + atproto_portal { 32 + domain localhost:8080 33 + name "Test Portal" 34 + } 35 + } 36 + 37 + route /protected/* { 38 + atproto_gate { 39 + allow @test.bsky.social 40 + portal_url http://localhost:8080/auth 41 + } 42 + respond "Authorized Content" 43 + } 44 + } 45 + ` 46 + 47 + // 2. Parse and Load Config 48 + adapter := caddyfile.Adapter{ 49 + ServerType: httpcaddyfile.ServerType{}, 50 + } 51 + 52 + jsonConfig, warnings, err := adapter.Adapt([]byte(input), nil) 53 + if err != nil { 54 + t.Fatalf("Failed to adapt config: %v", err) 55 + } 56 + if len(warnings) > 0 { 57 + t.Logf("Warnings: %v", warnings) 58 + } 59 + 60 + err = caddy.Load(jsonConfig, true) 61 + if err != nil { 62 + t.Fatalf("Failed to load caddy: %v", err) 63 + } 64 + defer caddy.Stop() 65 + 66 + // 3. Helper to simulate requests 67 + // Since Caddy is running its own listeners, we can just make HTTP requests to it. 68 + // But in a test environment, binding ports might be flaky. 69 + // Ideally we'd invoke the handler directly, but getting the handler chain from Caddy is complex. 70 + // We'll rely on the real HTTP server since we used :8080. 71 + 72 + // Wait a moment for server start 73 + time.Sleep(100 * time.Millisecond) 74 + 75 + baseURL := "http://localhost:8080" 76 + client := &http.Client{ 77 + CheckRedirect: func(req *http.Request, via []*http.Request) error { 78 + return http.ErrUseLastResponse // Don't follow redirects 79 + }, 80 + } 81 + 82 + t.Run("Unauthorized Access Redirects to Portal", func(t *testing.T) { 83 + resp, err := client.Get(baseURL + "/protected/resource") 84 + if err != nil { 85 + t.Fatalf("Request failed: %v", err) 86 + } 87 + defer resp.Body.Close() 88 + 89 + if resp.StatusCode != http.StatusFound { 90 + t.Errorf("Expected status 302, got %d", resp.StatusCode) 91 + } 92 + 93 + location := resp.Header.Get("Location") 94 + if !strings.Contains(location, "/auth/login") { 95 + t.Errorf("Expected redirect to portal login, got %s", location) 96 + } 97 + }) 98 + 99 + t.Run("Portal Serves Login Page", func(t *testing.T) { 100 + resp, err := client.Get(baseURL + "/auth/login") 101 + if err != nil { 102 + t.Fatalf("Request failed: %v", err) 103 + } 104 + defer resp.Body.Close() 105 + 106 + if resp.StatusCode != http.StatusOK { 107 + t.Errorf("Expected status 200, got %d", resp.StatusCode) 108 + } 109 + 110 + // Verify content type (HTML) 111 + ct := resp.Header.Get("Content-Type") 112 + if !strings.Contains(ct, "text/html") { 113 + t.Errorf("Expected HTML content, got %s", ct) 114 + } 115 + }) 116 + 117 + t.Run("Portal Serves Metadata", func(t *testing.T) { 118 + resp, err := client.Get(baseURL + "/auth/.well-known/oauth-client-metadata.json") 119 + if err != nil { 120 + t.Fatalf("Request failed: %v", err) 121 + } 122 + defer resp.Body.Close() 123 + 124 + if resp.StatusCode != http.StatusOK { 125 + t.Errorf("Expected status 200, got %d", resp.StatusCode) 126 + } 127 + 128 + // Verify content type (JSON) 129 + ct := resp.Header.Get("Content-Type") 130 + if !strings.Contains(ct, "application/json") { 131 + t.Errorf("Expected JSON content, got %s", ct) 132 + } 133 + }) 134 + }
+107
internal/ui/templates/forbidden.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Access Denied - {{ .AppName }}</title> 7 + <style> 8 + :root { 9 + --bg-color: #f4f4f9; 10 + --card-bg: #ffffff; 11 + --text-color: #333333; 12 + --border-color: #dddddd; 13 + --error-color: #d32f2f; 14 + --shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 15 + } 16 + 17 + @media (prefers-color-scheme: dark) { 18 + :root { 19 + --bg-color: #1a1a1a; 20 + --card-bg: #2d2d2d; 21 + --text-color: #ffffff; 22 + --border-color: #444444; 23 + --shadow: 0 4px 6px rgba(0, 0, 0, 0.3); 24 + } 25 + } 26 + 27 + body { 28 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 29 + background-color: var(--bg-color); 30 + color: var(--text-color); 31 + display: flex; 32 + justify-content: center; 33 + align-items: center; 34 + height: 100vh; 35 + margin: 0; 36 + padding: 1rem; 37 + } 38 + 39 + .error-card { 40 + background-color: var(--card-bg); 41 + padding: 2rem; 42 + border-radius: 12px; 43 + box-shadow: var(--shadow); 44 + width: 100%; 45 + max-width: 400px; 46 + text-align: center; 47 + border-top: 4px solid var(--error-color); 48 + } 49 + 50 + h1 { 51 + margin-top: 0; 52 + font-size: 1.5rem; 53 + margin-bottom: 1rem; 54 + } 55 + 56 + p { 57 + margin-bottom: 1.5rem; 58 + line-height: 1.5; 59 + } 60 + 61 + .icon { 62 + color: var(--error-color); 63 + margin-bottom: 1rem; 64 + } 65 + 66 + .icon svg { 67 + width: 48px; 68 + height: 48px; 69 + fill: currentColor; 70 + } 71 + 72 + a.button { 73 + display: inline-block; 74 + background-color: transparent; 75 + color: var(--text-color); 76 + border: 1px solid var(--border-color); 77 + padding: 0.75rem 1.5rem; 78 + text-decoration: none; 79 + border-radius: 6px; 80 + font-weight: 500; 81 + transition: background-color 0.2s; 82 + } 83 + 84 + a.button:hover { 85 + background-color: rgba(0, 0, 0, 0.05); 86 + } 87 + 88 + @media (prefers-color-scheme: dark) { 89 + a.button:hover { 90 + background-color: rgba(255, 255, 255, 0.1); 91 + } 92 + } 93 + </style> 94 + </head> 95 + <body> 96 + <div class="error-card"> 97 + <div class="icon"> 98 + <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 99 + <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/> 100 + </svg> 101 + </div> 102 + <h1>Access Denied</h1> 103 + <p>You are logged in as <strong>{{ .Handle }}</strong> ({{ .DID }}), but you are not authorized to access this resource.</p> 104 + <a href="/logout" class="button">Log Out</a> 105 + </div> 106 + </body> 107 + </html>
+141
internal/ui/templates/login.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>Login - Authenticate</title> 7 + <style> 8 + :root { 9 + --bg-color: #f4f4f9; 10 + --card-bg: #ffffff; 11 + --text-color: #333333; 12 + --border-color: #dddddd; 13 + --primary-color: #0085ff; 14 + --primary-hover: #006bd1; 15 + --shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 16 + } 17 + 18 + @media (prefers-color-scheme: dark) { 19 + :root { 20 + --bg-color: #1a1a1a; 21 + --card-bg: #2d2d2d; 22 + --text-color: #ffffff; 23 + --border-color: #444444; 24 + --shadow: 0 4px 6px rgba(0, 0, 0, 0.3); 25 + } 26 + } 27 + 28 + body { 29 + font-family: 30 + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 31 + Helvetica, Arial, sans-serif; 32 + background-color: var(--bg-color); 33 + color: var(--text-color); 34 + display: flex; 35 + justify-content: center; 36 + align-items: center; 37 + height: 100vh; 38 + margin: 0; 39 + } 40 + 41 + .login-card { 42 + background-color: var(--card-bg); 43 + padding: 2rem; 44 + border-radius: 12px; 45 + box-shadow: var(--shadow); 46 + width: 100%; 47 + max-width: 400px; 48 + text-align: center; 49 + } 50 + 51 + h1 { 52 + margin-top: 0; 53 + font-size: 1.5rem; 54 + margin-bottom: 1.5rem; 55 + } 56 + 57 + .input-group { 58 + margin-bottom: 1.5rem; 59 + text-align: left; 60 + } 61 + 62 + label { 63 + display: block; 64 + margin-bottom: 0.5rem; 65 + font-weight: 500; 66 + font-size: 0.9rem; 67 + } 68 + 69 + input[type="text"] { 70 + width: 100%; 71 + padding: 0.75rem; 72 + border: 1px solid var(--border-color); 73 + border-radius: 6px; 74 + font-size: 1rem; 75 + background-color: var(--bg-color); 76 + color: var(--text-color); 77 + box-sizing: border-box; /* Ensures padding doesn't affect width */ 78 + transition: border-color 0.2s; 79 + } 80 + 81 + input[type="text"]:focus { 82 + outline: none; 83 + border-color: var(--primary-color); 84 + } 85 + 86 + button { 87 + background-color: var(--primary-color); 88 + color: white; 89 + border: none; 90 + padding: 0.75rem 1.5rem; 91 + font-size: 1rem; 92 + border-radius: 6px; 93 + cursor: pointer; 94 + width: 100%; 95 + font-weight: 600; 96 + transition: background-color 0.2s; 97 + } 98 + 99 + button:hover { 100 + background-color: var(--primary-hover); 101 + } 102 + 103 + .logo { 104 + margin-bottom: 1rem; 105 + } 106 + 107 + .logo svg { 108 + width: 48px; 109 + height: 48px; 110 + fill: var(--primary-color); 111 + } 112 + </style> 113 + </head> 114 + <body> 115 + <div class="login-card"> 116 + <div class="logo"> 117 + <!-- Simple Placeholder Icon --> 118 + <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 119 + <path 120 + d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" 121 + /> 122 + </svg> 123 + </div> 124 + <h1>Sign in with At-Protocol</h1> 125 + <form action="/login" method="POST"> 126 + <div class="input-group"> 127 + <label for="handle">User Handle</label> 128 + <input 129 + type="text" 130 + id="handle" 131 + name="handle" 132 + placeholder="@user.bsky.social" 133 + required 134 + autofocus 135 + /> 136 + </div> 137 + <button type="submit">Continue</button> 138 + </form> 139 + </div> 140 + </body> 141 + </html>
+84
internal/ui/ui.go
··· 1 + package ui 2 + 3 + import ( 4 + "embed" 5 + "fmt" 6 + "html/template" 7 + "io" 8 + "os" 9 + ) 10 + 11 + //go:embed templates/login.html templates/forbidden.html 12 + var embeddedTemplates embed.FS 13 + 14 + // Config holds user overrides for UI templates. 15 + type Config struct { 16 + LoginTemplatePath string `json:"login_template_path,omitempty"` 17 + ForbiddenTemplatePath string `json:"forbidden_template_path,omitempty"` 18 + } 19 + 20 + // Renderer handles rendering of HTML pages. 21 + type Renderer struct { 22 + login *template.Template 23 + forbidden *template.Template 24 + } 25 + 26 + // NewRenderer initializes a new template renderer, loading defaults and user overrides. 27 + func NewRenderer(config Config) (*Renderer, error) { 28 + r := &Renderer{} 29 + 30 + // Helper to load or fallback 31 + load := func(userPath, defaultName string) (*template.Template, error) { 32 + var tmplStr string 33 + var b []byte 34 + var err error 35 + 36 + if userPath != "" { 37 + b, err = os.ReadFile(userPath) 38 + if err != nil { 39 + return nil, fmt.Errorf("failed to read custom template %s: %w", userPath, err) 40 + } 41 + tmplStr = string(b) 42 + } else { 43 + b, err = embeddedTemplates.ReadFile("templates/" + defaultName) 44 + if err != nil { 45 + // Should not happen if embedded correctly 46 + return nil, fmt.Errorf("failed to read embedded template %s: %w", defaultName, err) 47 + } 48 + tmplStr = string(b) 49 + } 50 + return template.New(defaultName).Parse(tmplStr) 51 + } 52 + 53 + var err error 54 + if r.login, err = load(config.LoginTemplatePath, "login.html"); err != nil { 55 + return nil, err 56 + } 57 + if r.forbidden, err = load(config.ForbiddenTemplatePath, "forbidden.html"); err != nil { 58 + return nil, err 59 + } 60 + 61 + return r, nil 62 + } 63 + 64 + // LoginData is the context for login.html 65 + type LoginData struct { 66 + AppName string 67 + Error string 68 + Redirect string 69 + } 70 + 71 + // ForbiddenData is the context for forbidden.html 72 + type ForbiddenData struct { 73 + AppName string 74 + DID string 75 + Handle string 76 + } 77 + 78 + func (r *Renderer) RenderLogin(w io.Writer, data LoginData) error { 79 + return r.login.Execute(w, data) 80 + } 81 + 82 + func (r *Renderer) RenderForbidden(w io.Writer, data ForbiddenData) error { 83 + return r.forbidden.Execute(w, data) 84 + }
+38 -16
portal.go
··· 12 12 "github.com/caddyserver/caddy/v2/modules/caddyhttp" 13 13 "github.com/vvill/caddy-atproto-auth/internal/oauth" 14 14 "github.com/vvill/caddy-atproto-auth/internal/session" 15 + "github.com/vvill/caddy-atproto-auth/internal/ui" 15 16 "go.uber.org/zap" 16 17 ) 17 18 ··· 22 23 23 24 // Portal is the centralized authentication portal for Path B (Auth Hub). 24 25 type Portal struct { 25 - Name string `json:"name,omitempty"` 26 - Domain string `json:"domain,omitempty"` // Public domain of the portal (e.g. auth.example.com) 26 + Name string `json:"name,omitempty"` 27 + Domain string `json:"domain,omitempty"` // Public domain of the portal (e.g. auth.example.com) 28 + UI ui.Config `json:"ui,omitempty"` // Custom UI configuration 27 29 28 30 // Dependencies 29 31 app *App 30 32 oauth *oauth.Manager 31 33 sessions *session.Manager 34 + renderer *ui.Renderer 32 35 logger *zap.Logger 33 36 } 34 37 ··· 57 60 } 58 61 p.sessions = session.NewManager(p.app.CookieSecret) 59 62 60 - // 3. Initialize OAuth Manager 63 + // 4. Initialize UI Renderer 64 + renderer, err := ui.NewRenderer(p.UI) 65 + if err != nil { 66 + return fmt.Errorf("failed to init ui renderer: %w", err) 67 + } 68 + p.renderer = renderer 69 + 70 + // 5. Initialize OAuth Manager 61 71 // We need the domain to construct ClientID and CallbackURL. 62 72 // If domain is missing, we might defer initialization? No, Manager needs it. 63 73 // User must configure 'domain' in Caddyfile for now. ··· 100 110 return d.ArgErr() 101 111 } 102 112 p.Domain = d.Val() 113 + case "ui": 114 + for nesting := d.Nesting(); d.NextBlock(nesting); { 115 + switch d.Val() { 116 + case "login_template": 117 + if !d.NextArg() { 118 + return d.ArgErr() 119 + } 120 + p.UI.LoginTemplatePath = d.Val() 121 + case "forbidden_template": 122 + if !d.NextArg() { 123 + return d.ArgErr() 124 + } 125 + p.UI.ForbiddenTemplatePath = d.Val() 126 + default: 127 + return d.Errf("unrecognized subdirective '%s'", d.Val()) 128 + } 129 + } 103 130 default: 104 131 return d.Errf("unrecognized subdirective '%s'", d.Val()) 105 132 } ··· 189 216 190 217 // 4. Default: Login Page 191 218 if r.URL.Path == "/" || r.URL.Path == "/login" { 192 - w.Header().Set("Content-Type", "text/html") 193 - fmt.Fprintf(w, ` 194 - <html> 195 - <body> 196 - <h1>%s</h1> 197 - <form action="/login" method="POST"> 198 - <label>Bluesky Handle:</label> 199 - <input type="text" name="handle" placeholder="@user.bsky.social" required> 200 - <button type="submit">Log In</button> 201 - </form> 202 - </body> 203 - </html> 204 - `, p.Name) 219 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 220 + if err := p.renderer.RenderLogin(w, ui.LoginData{ 221 + AppName: p.Name, 222 + Redirect: "/", 223 + }); err != nil { 224 + p.logger.Error("failed to render login page", zap.Error(err)) 225 + return caddyhttp.Error(http.StatusInternalServerError, err) 226 + } 205 227 return nil 206 228 } 207 229