diff --git a/Caddyfile.test b/Caddyfile.test
deleted file mode 100644
index 8a3e843..0000000
--- a/Caddyfile.test
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- admin off
- atproto {
- storage_path ./test.db
- cookie_secret "my-secret-key-must-be-very-long-and-secure"
- }
-}
-
-:8080 {
- route /auth/* {
- atproto_portal {
- domain localhost:8080
- name "Test Portal"
- }
- }
-
- route /protected/* {
- atproto_gate {
- allow @test.bsky.social
- }
- respond "You are authorized!"
- }
-
- respond "Hello World"
-}
diff --git a/gate.go b/gate.go
index 2f68f74..0663fce 100644
--- a/gate.go
+++ b/gate.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
+ "net/url"
"time"
"github.com/caddyserver/caddy/v2"
@@ -13,6 +14,8 @@ import (
"github.com/vvill/caddy-atproto-auth/internal/oauth"
"github.com/vvill/caddy-atproto-auth/internal/resolver"
"github.com/vvill/caddy-atproto-auth/internal/session"
+ "github.com/vvill/caddy-atproto-auth/internal/ui"
+ "go.uber.org/zap"
)
func init() {
@@ -23,14 +26,18 @@ func init() {
// Gate acts as a middleware that guards endpoints
// and validates the session cookie.
type Gate struct {
- Allow []string `json:"allow,omitempty"`
- Domain string `json:"domain,omitempty"` // Public domain for standalone mode (e.g. app.example.com)
+ Allow []string `json:"allow,omitempty"`
+ Domain string `json:"domain,omitempty"` // Public domain for standalone mode (e.g. app.example.com)
+ PortalURL string `json:"portal_url,omitempty"` // URL of the central auth portal if NOT in standalone mode
+ UI ui.Config `json:"ui,omitempty"` // Custom UI configuration
// Dependencies
app *App
resolver *resolver.Resolver
sessions *session.Manager
oauth *oauth.Manager
+ renderer *ui.Renderer
+ logger *zap.Logger
}
// CaddyModule returns the Caddy module information.
@@ -43,6 +50,8 @@ func (Gate) CaddyModule() caddy.ModuleInfo {
// Provision sets up the module.
func (g *Gate) Provision(ctx caddy.Context) error {
+ g.logger = ctx.Logger()
+
// 1. Get Global App
app, err := ctx.App("atproto")
if err != nil {
@@ -59,7 +68,14 @@ func (g *Gate) Provision(ctx caddy.Context) error {
// 3. Initialize Identity Resolver
g.resolver = resolver.New()
- // 4. Initialize OAuth Manager (if domain set for standalone mode)
+ // 4. Initialize UI Renderer
+ renderer, err := ui.NewRenderer(g.UI)
+ if err != nil {
+ return fmt.Errorf("failed to init ui renderer: %w", err)
+ }
+ g.renderer = renderer
+
+ // 5. Initialize OAuth Manager (if domain set for standalone mode)
if g.Domain != "" {
clientID := fmt.Sprintf("https://%s/.well-known/oauth-client-metadata.json", g.Domain)
callbackURL := fmt.Sprintf("https://%s/callback", g.Domain)
@@ -94,6 +110,28 @@ func (g *Gate) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return d.ArgErr()
}
g.Domain = d.Val()
+ case "portal_url":
+ if !d.NextArg() {
+ return d.ArgErr()
+ }
+ g.PortalURL = d.Val()
+ case "ui":
+ for nesting := d.Nesting(); d.NextBlock(nesting); {
+ switch d.Val() {
+ case "login_template":
+ if !d.NextArg() {
+ return d.ArgErr()
+ }
+ g.UI.LoginTemplatePath = d.Val()
+ case "forbidden_template":
+ if !d.NextArg() {
+ return d.ArgErr()
+ }
+ g.UI.ForbiddenTemplatePath = d.Val()
+ default:
+ return d.Errf("unrecognized subdirective '%s'", d.Val())
+ }
+ }
default:
return d.Errf("unrecognized subdirective '%s'", d.Val())
}
@@ -166,7 +204,16 @@ func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.
}
// Authenticated but not authorized
- return caddyhttp.Error(http.StatusForbidden, fmt.Errorf("user not authorized"))
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusForbidden)
+ if err := g.renderer.RenderForbidden(w, ui.ForbiddenData{
+ AppName: g.Domain,
+ DID: sess.DID,
+ Handle: sess.Handle,
+ }); err != nil {
+ g.logger.Error("failed to render forbidden page", zap.Error(err))
+ }
+ return nil
}
// 2. If invalid/missing, initiate redirect to PDS or Auth Hub
@@ -190,16 +237,14 @@ func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.
return nil
}
// Show login form
- w.Header().Set("Content-Type", "text/html")
- fmt.Fprintf(w, `
-
- Login
-
-
- `)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := g.renderer.RenderLogin(w, ui.LoginData{
+ AppName: g.Domain,
+ Redirect: "/",
+ }); err != nil {
+ g.logger.Error("failed to render login page", zap.Error(err))
+ return caddyhttp.Error(http.StatusInternalServerError, err)
+ }
return nil
}
// Redirect to /login
@@ -207,9 +252,24 @@ func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.
return nil
}
- // If NOT standalone (Auth Hub mode), we should redirect to the central Auth Portal.
- // We don't know where it is unless configured.
- // For now, return 401.
+ // If NOT standalone (Auth Hub mode), redirect to the central Auth Portal if configured.
+ if g.PortalURL != "" {
+ // Construct redirect URL: ${PortalURL}/login?redirect_uri=${CurrentURL}
+ // We need to encode the current URL as a query param.
+ // NOTE: Assuming https for now, Caddy usually knows scheme but r.URL.Scheme might be empty.
+ scheme := "https"
+ if r.TLS == nil {
+ scheme = "http"
+ }
+ host := r.Host
+ currentURL := fmt.Sprintf("%s://%s%s", scheme, host, r.URL.RequestURI())
+
+ portalLogin := fmt.Sprintf("%s/login?redirect_to=%s", g.PortalURL, url.QueryEscape(currentURL))
+ http.Redirect(w, r, portalLogin, http.StatusFound)
+ return nil
+ }
+
+ // Fallback: 401
return caddyhttp.Error(http.StatusUnauthorized, fmt.Errorf("unauthorized"))
}
diff --git a/go.mod b/go.mod
index 7bf0ca2..b365e40 100644
--- a/go.mod
+++ b/go.mod
@@ -6,6 +6,7 @@ require (
github.com/bluesky-social/indigo v0.0.0-20260303011501-01fde705a450
github.com/caddyserver/caddy/v2 v2.8.4
github.com/mattn/go-sqlite3 v1.14.34
+ go.uber.org/zap v1.27.0
)
require (
@@ -135,7 +136,6 @@ require (
go.uber.org/automaxprocs v1.5.3 // indirect
go.uber.org/mock v0.4.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
- go.uber.org/zap v1.27.0 // indirect
go.uber.org/zap/exp v0.2.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
diff --git a/go.sum b/go.sum
index 4dc50a8..0f33c7d 100644
--- a/go.sum
+++ b/go.sum
@@ -164,9 +164,6 @@ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEe
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
-github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
-github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
-github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
diff --git a/internal/test/integration_test.go b/internal/test/integration_test.go
new file mode 100644
index 0000000..e2543e1
--- /dev/null
+++ b/internal/test/integration_test.go
@@ -0,0 +1,134 @@
+package test
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/caddyserver/caddy/v2"
+ "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
+ "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
+ _ "github.com/caddyserver/caddy/v2/modules/standard"
+
+ _ "github.com/vvill/caddy-atproto-auth" // Register modules
+)
+
+func TestCaddyIntegration(t *testing.T) {
+ // 1. Setup Caddyfile
+ input := `
+ {
+ admin off
+ atproto {
+ storage_path :memory:
+ cookie_secret "my-secret-key-must-be-very-long-and-secure"
+ }
+ }
+
+ :8080 {
+ route /auth/* {
+ uri strip_prefix /auth
+ atproto_portal {
+ domain localhost:8080
+ name "Test Portal"
+ }
+ }
+
+ route /protected/* {
+ atproto_gate {
+ allow @test.bsky.social
+ portal_url http://localhost:8080/auth
+ }
+ respond "Authorized Content"
+ }
+ }
+ `
+
+ // 2. Parse and Load Config
+ adapter := caddyfile.Adapter{
+ ServerType: httpcaddyfile.ServerType{},
+ }
+
+ jsonConfig, warnings, err := adapter.Adapt([]byte(input), nil)
+ if err != nil {
+ t.Fatalf("Failed to adapt config: %v", err)
+ }
+ if len(warnings) > 0 {
+ t.Logf("Warnings: %v", warnings)
+ }
+
+ err = caddy.Load(jsonConfig, true)
+ if err != nil {
+ t.Fatalf("Failed to load caddy: %v", err)
+ }
+ defer caddy.Stop()
+
+ // 3. Helper to simulate requests
+ // Since Caddy is running its own listeners, we can just make HTTP requests to it.
+ // But in a test environment, binding ports might be flaky.
+ // Ideally we'd invoke the handler directly, but getting the handler chain from Caddy is complex.
+ // We'll rely on the real HTTP server since we used :8080.
+
+ // Wait a moment for server start
+ time.Sleep(100 * time.Millisecond)
+
+ baseURL := "http://localhost:8080"
+ client := &http.Client{
+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
+ return http.ErrUseLastResponse // Don't follow redirects
+ },
+ }
+
+ t.Run("Unauthorized Access Redirects to Portal", func(t *testing.T) {
+ resp, err := client.Get(baseURL + "/protected/resource")
+ if err != nil {
+ t.Fatalf("Request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusFound {
+ t.Errorf("Expected status 302, got %d", resp.StatusCode)
+ }
+
+ location := resp.Header.Get("Location")
+ if !strings.Contains(location, "/auth/login") {
+ t.Errorf("Expected redirect to portal login, got %s", location)
+ }
+ })
+
+ t.Run("Portal Serves Login Page", func(t *testing.T) {
+ resp, err := client.Get(baseURL + "/auth/login")
+ if err != nil {
+ t.Fatalf("Request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", resp.StatusCode)
+ }
+
+ // Verify content type (HTML)
+ ct := resp.Header.Get("Content-Type")
+ if !strings.Contains(ct, "text/html") {
+ t.Errorf("Expected HTML content, got %s", ct)
+ }
+ })
+
+ t.Run("Portal Serves Metadata", func(t *testing.T) {
+ resp, err := client.Get(baseURL + "/auth/.well-known/oauth-client-metadata.json")
+ if err != nil {
+ t.Fatalf("Request failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", resp.StatusCode)
+ }
+
+ // Verify content type (JSON)
+ ct := resp.Header.Get("Content-Type")
+ if !strings.Contains(ct, "application/json") {
+ t.Errorf("Expected JSON content, got %s", ct)
+ }
+ })
+}
diff --git a/internal/ui/templates/forbidden.html b/internal/ui/templates/forbidden.html
new file mode 100644
index 0000000..06a5f1c
--- /dev/null
+++ b/internal/ui/templates/forbidden.html
@@ -0,0 +1,107 @@
+
+
+
+
+
+ Access Denied - {{ .AppName }}
+
+
+
+
+
+
Access Denied
+
You are logged in as {{ .Handle }} ({{ .DID }}), but you are not authorized to access this resource.
+
Log Out
+
+
+
diff --git a/internal/ui/templates/login.html b/internal/ui/templates/login.html
new file mode 100644
index 0000000..037bce0
--- /dev/null
+++ b/internal/ui/templates/login.html
@@ -0,0 +1,141 @@
+
+
+
+
+
+ Login - Authenticate
+
+
+
+
+
+
Sign in with At-Protocol
+
+
+
+
diff --git a/internal/ui/ui.go b/internal/ui/ui.go
new file mode 100644
index 0000000..937a074
--- /dev/null
+++ b/internal/ui/ui.go
@@ -0,0 +1,84 @@
+package ui
+
+import (
+ "embed"
+ "fmt"
+ "html/template"
+ "io"
+ "os"
+)
+
+//go:embed templates/login.html templates/forbidden.html
+var embeddedTemplates embed.FS
+
+// Config holds user overrides for UI templates.
+type Config struct {
+ LoginTemplatePath string `json:"login_template_path,omitempty"`
+ ForbiddenTemplatePath string `json:"forbidden_template_path,omitempty"`
+}
+
+// Renderer handles rendering of HTML pages.
+type Renderer struct {
+ login *template.Template
+ forbidden *template.Template
+}
+
+// NewRenderer initializes a new template renderer, loading defaults and user overrides.
+func NewRenderer(config Config) (*Renderer, error) {
+ r := &Renderer{}
+
+ // Helper to load or fallback
+ load := func(userPath, defaultName string) (*template.Template, error) {
+ var tmplStr string
+ var b []byte
+ var err error
+
+ if userPath != "" {
+ b, err = os.ReadFile(userPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read custom template %s: %w", userPath, err)
+ }
+ tmplStr = string(b)
+ } else {
+ b, err = embeddedTemplates.ReadFile("templates/" + defaultName)
+ if err != nil {
+ // Should not happen if embedded correctly
+ return nil, fmt.Errorf("failed to read embedded template %s: %w", defaultName, err)
+ }
+ tmplStr = string(b)
+ }
+ return template.New(defaultName).Parse(tmplStr)
+ }
+
+ var err error
+ if r.login, err = load(config.LoginTemplatePath, "login.html"); err != nil {
+ return nil, err
+ }
+ if r.forbidden, err = load(config.ForbiddenTemplatePath, "forbidden.html"); err != nil {
+ return nil, err
+ }
+
+ return r, nil
+}
+
+// LoginData is the context for login.html
+type LoginData struct {
+ AppName string
+ Error string
+ Redirect string
+}
+
+// ForbiddenData is the context for forbidden.html
+type ForbiddenData struct {
+ AppName string
+ DID string
+ Handle string
+}
+
+func (r *Renderer) RenderLogin(w io.Writer, data LoginData) error {
+ return r.login.Execute(w, data)
+}
+
+func (r *Renderer) RenderForbidden(w io.Writer, data ForbiddenData) error {
+ return r.forbidden.Execute(w, data)
+}
diff --git a/portal.go b/portal.go
index 9d4cea6..0b3bad6 100644
--- a/portal.go
+++ b/portal.go
@@ -12,6 +12,7 @@ import (
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/vvill/caddy-atproto-auth/internal/oauth"
"github.com/vvill/caddy-atproto-auth/internal/session"
+ "github.com/vvill/caddy-atproto-auth/internal/ui"
"go.uber.org/zap"
)
@@ -22,13 +23,15 @@ func init() {
// Portal is the centralized authentication portal for Path B (Auth Hub).
type Portal struct {
- Name string `json:"name,omitempty"`
- Domain string `json:"domain,omitempty"` // Public domain of the portal (e.g. auth.example.com)
+ Name string `json:"name,omitempty"`
+ Domain string `json:"domain,omitempty"` // Public domain of the portal (e.g. auth.example.com)
+ UI ui.Config `json:"ui,omitempty"` // Custom UI configuration
// Dependencies
app *App
oauth *oauth.Manager
sessions *session.Manager
+ renderer *ui.Renderer
logger *zap.Logger
}
@@ -57,7 +60,14 @@ func (p *Portal) Provision(ctx caddy.Context) error {
}
p.sessions = session.NewManager(p.app.CookieSecret)
- // 3. Initialize OAuth Manager
+ // 4. Initialize UI Renderer
+ renderer, err := ui.NewRenderer(p.UI)
+ if err != nil {
+ return fmt.Errorf("failed to init ui renderer: %w", err)
+ }
+ p.renderer = renderer
+
+ // 5. Initialize OAuth Manager
// We need the domain to construct ClientID and CallbackURL.
// If domain is missing, we might defer initialization? No, Manager needs it.
// User must configure 'domain' in Caddyfile for now.
@@ -100,6 +110,23 @@ func (p *Portal) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
return d.ArgErr()
}
p.Domain = d.Val()
+ case "ui":
+ for nesting := d.Nesting(); d.NextBlock(nesting); {
+ switch d.Val() {
+ case "login_template":
+ if !d.NextArg() {
+ return d.ArgErr()
+ }
+ p.UI.LoginTemplatePath = d.Val()
+ case "forbidden_template":
+ if !d.NextArg() {
+ return d.ArgErr()
+ }
+ p.UI.ForbiddenTemplatePath = d.Val()
+ default:
+ return d.Errf("unrecognized subdirective '%s'", d.Val())
+ }
+ }
default:
return d.Errf("unrecognized subdirective '%s'", d.Val())
}
@@ -189,19 +216,14 @@ func (p *Portal) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhtt
// 4. Default: Login Page
if r.URL.Path == "/" || r.URL.Path == "/login" {
- w.Header().Set("Content-Type", "text/html")
- fmt.Fprintf(w, `
-
-
- %s
-
-
-
- `, p.Name)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := p.renderer.RenderLogin(w, ui.LoginData{
+ AppName: p.Name,
+ Redirect: "/",
+ }); err != nil {
+ p.logger.Error("failed to render login page", zap.Error(err))
+ return caddyhttp.Error(http.StatusInternalServerError, err)
+ }
return nil
}