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 }