diff --git a/Caddyfile.test b/Caddyfile.test new file mode 100644 index 0000000..8a3e843 --- /dev/null +++ b/Caddyfile.test @@ -0,0 +1,25 @@ +{ + 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 29ed813..2f68f74 100644 --- a/gate.go +++ b/gate.go @@ -1,13 +1,18 @@ package caddyatprotoauth import ( + "encoding/json" "fmt" "net/http" + "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/caddyhttp" + "github.com/vvill/caddy-atproto-auth/internal/oauth" + "github.com/vvill/caddy-atproto-auth/internal/resolver" + "github.com/vvill/caddy-atproto-auth/internal/session" ) func init() { @@ -18,7 +23,14 @@ func init() { // Gate acts as a middleware that guards endpoints // and validates the session cookie. type Gate struct { - Allow []string `json:"allow,omitempty"` + Allow []string `json:"allow,omitempty"` + Domain string `json:"domain,omitempty"` // Public domain for standalone mode (e.g. app.example.com) + + // Dependencies + app *App + resolver *resolver.Resolver + sessions *session.Manager + oauth *oauth.Manager } // CaddyModule returns the Caddy module information. @@ -31,7 +43,34 @@ func (Gate) CaddyModule() caddy.ModuleInfo { // Provision sets up the module. func (g *Gate) Provision(ctx caddy.Context) error { - // Initialize identities and cache resolved handles here. + // 1. Get Global App + app, err := ctx.App("atproto") + if err != nil { + return fmt.Errorf("getting atproto app: %w", err) + } + g.app = app.(*App) + + // 2. Initialize Session Manager (using global secret) + if g.app.CookieSecret == "" { + return fmt.Errorf("global atproto cookie_secret is required") + } + g.sessions = session.NewManager(g.app.CookieSecret) + + // 3. Initialize Identity Resolver + g.resolver = resolver.New() + + // 4. 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) + + mgr, err := oauth.NewManager(g.app.Store, clientID, callbackURL) + if err != nil { + return fmt.Errorf("failed to init oauth manager: %w", err) + } + g.oauth = mgr + } + return nil } @@ -50,6 +89,11 @@ func (g *Gate) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { switch d.Val() { case "allow": g.Allow = append(g.Allow, d.RemainingArgs()...) + case "domain": + if !d.NextArg() { + return d.ArgErr() + } + g.Domain = d.Val() default: return d.Errf("unrecognized subdirective '%s'", d.Val()) } @@ -67,12 +111,112 @@ func parseCaddyfileGate(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, er // ServeHTTP implements caddyhttp.MiddlewareHandler. func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { + // If in Standalone Mode, handle OAuth paths + if g.oauth != nil { + if r.URL.Path == "/.well-known/oauth-client-metadata.json" { + meta, err := g.oauth.GetClientMetadata() + if err != nil { + return caddyhttp.Error(http.StatusInternalServerError, err) + } + w.Header().Set("Content-Type", "application/json") + return json.NewEncoder(w).Encode(meta) + } + if r.URL.Path == "/callback" { + // Process callback + sessionData, err := g.oauth.ProcessCallback(r.Context(), r.URL.Query()) + if err != nil { + return caddyhttp.Error(http.StatusBadRequest, err) + } + + // Create Session Cookie + cookie, err := g.sessions.CreateCookie( + sessionData.AccountDID, + "user", // Placeholder handle + 24*7*time.Hour, + g.Domain, + ) + if err != nil { + return caddyhttp.Error(http.StatusInternalServerError, err) + } + + http.SetCookie(w, cookie) + http.Redirect(w, r, "/", http.StatusFound) + return nil + } + } + // 1. Verify stateless cookie here + sess, err := g.sessions.VerifyCookie(r) + if err == nil { + // Session valid! + // Check authorization against allowlist + allowed := false + for _, allow := range g.Allow { + if allow == sess.DID || allow == sess.Handle { + allowed = true + break + } + } + + if allowed { + // Inject headers + r.Header.Set("X-Atproto-Did", sess.DID) + r.Header.Set("X-Atproto-Handle", sess.Handle) + return next.ServeHTTP(w, r) + } + + // Authenticated but not authorized + return caddyhttp.Error(http.StatusForbidden, fmt.Errorf("user not authorized")) + } + // 2. If invalid/missing, initiate redirect to PDS or Auth Hub - // 3. If valid, set headers (X-Atproto-Did) and proceed - - // Example: inject header for downstream (to be implemented correctly) - // r.Header.Set("X-Atproto-Did", "did:plc:xxx") + // If standalone mode (g.oauth != nil), we can initiate flow here? + // But which identity? We need to prompt user for handle. + // So we should redirect to a login page or show a simple form. + // For simplicity, let's just 401 and tell user to go to /login (which we handle if standalone?) + // Wait, we didn't add /login handler to Gate yet. + // If standalone, Gate should act as Portal. + // Let's implement a simple /login handler in Gate if oauth is enabled. + + if g.oauth != nil { + if r.URL.Path == "/login" { + if r.Method == "POST" { + handle := r.FormValue("handle") + redirectURI, err := g.oauth.StartAuthFlow(r.Context(), handle) + if err != nil { + return caddyhttp.Error(http.StatusBadRequest, err) + } + http.Redirect(w, r, redirectURI, http.StatusFound) + return nil + } + // Show login form + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, ` + +

Login

+
+ + +
+ + `) + return nil + } + // Redirect to /login + http.Redirect(w, r, "/login", http.StatusFound) + return nil + } - return next.ServeHTTP(w, r) + // 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. + return caddyhttp.Error(http.StatusUnauthorized, fmt.Errorf("unauthorized")) } + +// Interface guards +var ( + _ caddy.Provisioner = (*Gate)(nil) + _ caddy.Validator = (*Gate)(nil) + _ caddyhttp.MiddlewareHandler = (*Gate)(nil) + _ caddyfile.Unmarshaler = (*Gate)(nil) +) diff --git a/global.go b/global.go index 5042743..051e17c 100644 --- a/global.go +++ b/global.go @@ -1,17 +1,30 @@ package caddyatprotoauth import ( + "fmt" + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig" + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" + "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" + + "github.com/vvill/caddy-atproto-auth/internal/db" + "github.com/vvill/caddy-atproto-auth/internal/oauth" ) func init() { caddy.RegisterModule(App{}) + httpcaddyfile.RegisterGlobalOption("atproto", parseGlobalAtproto) } // App configures the global atproto integration. type App struct { StoragePath string `json:"storage_path,omitempty"` CookieSecret string `json:"cookie_secret,omitempty"` + + // Internal state + Store *db.Store `json:"-"` + OAuthManager *oauth.Manager `json:"-"` } // CaddyModule returns the Caddy module information. @@ -22,13 +35,90 @@ func (App) CaddyModule() caddy.ModuleInfo { } } +// Provision sets up the global app state. +func (a *App) Provision(ctx caddy.Context) error { + // Defaults + if a.StoragePath == "" { + a.StoragePath = "atproto.db" // Relative to workdir or specific path + } + // Resolve relative path against Caddy's storage or workdir if needed. + // For simplicity, we assume absolute or relative to CWD. + + // Initialize DB + store, err := db.NewStore(a.StoragePath) + if err != nil { + return fmt.Errorf("failed to initialize atproto storage: %w", err) + } + a.Store = store + + // Initialize OAuth Manager (requires client ID and callback URL to be fully configured, + // but those might be per-portal or global. The spec says "acts as an OAuth Client". + // If the plugin acts as a *single* client for many subdomains, we need global config for client ID. + // But spec says: "Path A: The Self-Contained Route" and "Path B: The Auth Hub". + // This implies potentially different client IDs for different sites OR one central hub. + // For now, let's defer OAuthManager creation to the Portal or Gate if it's per-route, + // OR we need to add ClientID/CallbackURL to the global config if it's shared. + // + // Looking at the spec: + // "The module acts as an OAuth Client" + // "Global Configuration: storage_path, cookie_secret" + // + // It seems the App module holds the *Storage* and *Keys*. + // The *Portal* (or Gate) defines the "Client" identity (metadata, callback). + // However, `oauth.NewManager` takes a `db.Store`. So the App owns the Store. + // The Portal will instantiate the Manager using the App's Store. + + return nil +} + // Start starts the application. func (a *App) Start() error { - // Initialize global database and settings here return nil } // Stop stops the application. func (a *App) Stop() error { + if a.Store != nil { + return a.Store.Close() + } return nil } + +// Interface guards +var ( + _ caddy.App = (*App)(nil) + _ caddy.Provisioner = (*App)(nil) +) + +// parseGlobalAtproto parses the global 'atproto' Caddyfile option. +// Format: +// +// atproto { +// storage_path /path/to/db +// cookie_secret +// } +func parseGlobalAtproto(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) { + app := &App{} + for d.Next() { + for d.NextBlock(0) { + switch d.Val() { + case "storage_path": + if !d.NextArg() { + return nil, d.ArgErr() + } + app.StoragePath = d.Val() + case "cookie_secret": + if !d.NextArg() { + return nil, d.ArgErr() + } + app.CookieSecret = d.Val() + default: + return nil, d.Errf("unrecognized subdirective '%s'", d.Val()) + } + } + } + return httpcaddyfile.App{ + Name: "atproto", + Value: caddyconfig.JSON(app, nil), + }, nil +} diff --git a/go.mod b/go.mod index b82faae..7bf0ca2 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,11 @@ module github.com/vvill/caddy-atproto-auth go 1.25.5 -require github.com/caddyserver/caddy/v2 v2.8.4 +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 +) require ( filippo.io/edwards25519 v1.1.0 // indirect @@ -29,6 +33,7 @@ require ( github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.6.0 // indirect github.com/go-chi/chi/v5 v5.0.12 // indirect @@ -40,16 +45,19 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-sql-driver/mysql v1.7.1 // indirect github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang/glog v1.2.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/cel-go v0.20.1 // indirect github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-tpm v0.9.0 // indirect github.com/google/go-tspi v0.3.0 // indirect github.com/google/pprof v0.0.0-20231212022811-ec68065c825e // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/huandu/xstrings v1.3.3 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -73,6 +81,7 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect github.com/onsi/ginkgo/v2 v2.13.2 // indirect github.com/pires/go-proxyproto v0.7.0 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -98,13 +107,14 @@ require ( github.com/spf13/cobra v1.8.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect - github.com/stretchr/testify v1.10.0 // indirect github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect github.com/urfave/cli v1.22.14 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yuin/goldmark v1.7.1 // indirect github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect github.com/zeebo/blake3 v0.2.3 // indirect + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect go.etcd.io/bbolt v1.3.9 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect go.opentelemetry.io/contrib/propagators/autoprop v0.42.0 // indirect diff --git a/go.sum b/go.sum index 7a98e0a..4dc50a8 100644 --- a/go.sum +++ b/go.sum @@ -73,6 +73,8 @@ github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bluesky-social/indigo v0.0.0-20260303011501-01fde705a450 h1:XXOO2pfb5mdJowNpMz9SlBcLrH/Ovkc4qi3OJ9zH54A= +github.com/bluesky-social/indigo v0.0.0-20260303011501-01fde705a450/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag= github.com/caddyserver/caddy/v2 v2.8.4 h1:q3pe0wpBj1OcHFZ3n/1nl4V4bxBrYoSoab7rL9BMYNk= github.com/caddyserver/caddy/v2 v2.8.4/go.mod h1:vmDAHp3d05JIvuhc24LmnxVlsZmWnUwbP5WMjzcMPWw= github.com/caddyserver/certmagic v0.21.3 h1:pqRRry3yuB4CWBVq9+cUqu+Y6E2z8TswbhNx1AZeYm0= @@ -128,6 +130,8 @@ github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cn github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= +github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -160,6 +164,11 @@ 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= github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68= github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= @@ -178,9 +187,12 @@ github.com/google/cel-go v0.20.1/go.mod h1:kWcIzTsPX0zmQ+H3TirHstLLf9ep5QTsZBN9u github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745 h1:heyoXNxkRT155x4jTAiSv5BVSVkueifPUm+Q8LUXMRo= github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745/go.mod h1:zN0wUQgV9LjwLZeFHnrAbQi8hzMVvEWePyk+MhPOk7k= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk= github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU= github.com/google/go-tpm-tools v0.4.4 h1:oiQfAIkc6xTy9Fl5NKTeTJkBTlXdHsxAofmQyxBKY98= @@ -201,6 +213,8 @@ github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI= github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 h1:6UKoz5ujsI55KNpsJH3UwCq3T8kKbZwNZBNPuTTje8U= github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1/go.mod h1:YvJ2f6MplWDhfxiUC3KpyTy76kYUZA4W3pTv/wdKQ9Y= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= @@ -212,6 +226,8 @@ github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= +github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= @@ -299,12 +315,16 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mholt/acmez/v2 v2.0.1 h1:3/3N0u1pLjMK4sNEAFSI+bcvzbPhRpY383sy1kLHJ6k= github.com/mholt/acmez/v2 v2.0.1/go.mod h1:fX4c9r5jYwMyMsC+7tkYRxHibkOTgta5DIFGoe67e1U= github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs= github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= @@ -315,6 +335,18 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= +github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= +github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= +github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= +github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= +github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= +github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= github.com/onsi/ginkgo/v2 v2.13.2 h1:Bi2gGVkfn6gQcjNjZJVO8Gf0FHzMPf2phUei9tejVMs= github.com/onsi/ginkgo/v2 v2.13.2/go.mod h1:XStQ8QcGwLyF4HdfcZB8SFOS/MWCgDuXMSBe6zrvLgM= github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= @@ -422,6 +454,8 @@ github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933/go.mod h1:kNGUQ3V github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk= github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA= +github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= +github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= @@ -438,6 +472,10 @@ github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvv github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= +gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= +gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= +gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI= go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= @@ -611,7 +649,10 @@ golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= google.golang.org/api v0.180.0 h1:M2D87Yo0rGBPWpo1orwfCLehUUL6E7/TYe5gvMQWDh4= google.golang.org/api v0.180.0/go.mod h1:51AiyoEg1MJPSZ9zvklA8VnRILPXxn1iVen9v25XHAE= google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda h1:wu/KJm9KJwpfHWhkkZGohVC6KRrc1oJNr4jwtQMOQXw= @@ -644,3 +685,5 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= +lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..085a3da --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,202 @@ +package db + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" + _ "github.com/mattn/go-sqlite3" +) + +// Ensure DB implements ClientAuthStore +var _ oauth.ClientAuthStore = (*Store)(nil) + +// Store handles SQLite persistence for the plugin. +type Store struct { + db *sql.DB +} + +// NewStore initializes a new SQLite-backed storage. +func NewStore(path string) (*Store, error) { + // Enable WAL mode for better concurrency + db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000") + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + store := &Store{db: db} + if err := store.initSchema(); err != nil { + return nil, fmt.Errorf("failed to initialize schema: %w", err) + } + + return store, nil +} + +func (s *Store) initSchema() error { + const schema = ` + CREATE TABLE IF NOT EXISTS auth_requests ( + state TEXT PRIMARY KEY, + data TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS auth_sessions ( + did TEXT NOT NULL, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (did, session_id) + ); + + CREATE TABLE IF NOT EXISTS system_keys ( + id TEXT PRIMARY KEY, + key_data BLOB NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + ` + _, err := s.db.Exec(schema) + return err +} + +// Close closes the underlying database. +func (s *Store) Close() error { + return s.db.Close() +} + +// GetClientKey retrieves the main client key, generating it if it doesn't exist. +// Returns the private key and its ID (a hash of the public key or a random string). +func (s *Store) GetClientKey(ctx context.Context) (atcrypto.PrivateKey, string, error) { + var keyData []byte + err := s.db.QueryRowContext(ctx, "SELECT key_data FROM system_keys WHERE id = 'client_key'").Scan(&keyData) + if err == sql.ErrNoRows { + // Generate a new P-256 key + pk, err := atcrypto.GeneratePrivateKeyP256() + if err != nil { + return nil, "", fmt.Errorf("failed to generate new client key: %w", err) + } + + keyData = pk.Bytes() + + _, err = s.db.ExecContext(ctx, "INSERT INTO system_keys (id, key_data) VALUES ('client_key', ?)", keyData) + if err != nil { + return nil, "", fmt.Errorf("failed to save generated client key: %w", err) + } + + return pk, "client_key", nil + } else if err != nil { + return nil, "", fmt.Errorf("failed to load client key: %w", err) + } + + pk, err := atcrypto.ParsePrivateBytesP256(keyData) + if err != nil { + return nil, "", fmt.Errorf("failed to parse existing client key: %w", err) + } + + return pk, "client_key", nil +} + +// GetSession retrieves session data from the database. +func (s *Store) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { + var dataStr string + err := s.db.QueryRowContext(ctx, "SELECT data FROM auth_sessions WHERE did = ? AND session_id = ?", did.String(), sessionID).Scan(&dataStr) + if err != nil { + if err == sql.ErrNoRows { + // Some oauth methods in indigo might expect a specific error if not found, let's return it as nil/not found or check indigo docs. + // Indigo's memstore returns (nil, nil) or custom error. We'll return nil for the session and potentially an error if we need to. + // Currently indigo's oauth store interface doesn't strictly dictate `ErrNotFound`, but usually `nil, nil` or `nil, Err` is handled. Let's return nil, nil. + return nil, nil + } + return nil, fmt.Errorf("failed to query session: %w", err) + } + + var sessionData oauth.ClientSessionData + if err := json.Unmarshal([]byte(dataStr), &sessionData); err != nil { + return nil, fmt.Errorf("failed to parse session data: %w", err) + } + + return &sessionData, nil +} + +// SaveSession saves session data into the database. +func (s *Store) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { + dataBytes, err := json.Marshal(sess) + if err != nil { + return fmt.Errorf("failed to serialize session data: %w", err) + } + + _, err = s.db.ExecContext(ctx, ` + INSERT INTO auth_sessions (did, session_id, data, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(did, session_id) DO UPDATE SET data = excluded.data, updated_at = CURRENT_TIMESTAMP + `, sess.AccountDID.String(), sess.SessionID, string(dataBytes)) + if err != nil { + return fmt.Errorf("failed to save session: %w", err) + } + + return nil +} + +// DeleteSession removes session data from the database. +func (s *Store) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { + _, err := s.db.ExecContext(ctx, "DELETE FROM auth_sessions WHERE did = ? AND session_id = ?", did.String(), sessionID) + if err != nil { + return fmt.Errorf("failed to delete session: %w", err) + } + return nil +} + +// GetAuthRequestInfo retrieves the auth request data by state. +func (s *Store) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { + var dataStr string + err := s.db.QueryRowContext(ctx, "SELECT data FROM auth_requests WHERE state = ?", state).Scan(&dataStr) + if err != nil { + if err == sql.ErrNoRows { + // AuthRequestData not found + return nil, fmt.Errorf("auth request info not found for state") + } + return nil, fmt.Errorf("failed to query auth request: %w", err) + } + + var reqData oauth.AuthRequestData + if err := json.Unmarshal([]byte(dataStr), &reqData); err != nil { + return nil, fmt.Errorf("failed to parse auth request data: %w", err) + } + + return &reqData, nil +} + +// SaveAuthRequestInfo saves the auth request info. +func (s *Store) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { + dataBytes, err := json.Marshal(info) + if err != nil { + return fmt.Errorf("failed to serialize auth request data: %w", err) + } + + // Creating is fine. It shouldn't exist, but we can do an INSERT OR REPLACE just in case. + _, err = s.db.ExecContext(ctx, ` + INSERT OR REPLACE INTO auth_requests (state, data, created_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + `, info.State, string(dataBytes)) + if err != nil { + return fmt.Errorf("failed to save auth request: %w", err) + } + + return nil +} + +// DeleteAuthRequestInfo removes the auth request info after it's been used or expired. +func (s *Store) DeleteAuthRequestInfo(ctx context.Context, state string) error { + _, err := s.db.ExecContext(ctx, "DELETE FROM auth_requests WHERE state = ?", state) + if err != nil { + return fmt.Errorf("failed to delete auth request: %w", err) + } + return nil +} diff --git a/internal/oauth/manager.go b/internal/oauth/manager.go new file mode 100644 index 0000000..476663b --- /dev/null +++ b/internal/oauth/manager.go @@ -0,0 +1,98 @@ +package oauth + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/bluesky-social/indigo/atproto/atcrypto" + indigoOauth "github.com/bluesky-social/indigo/atproto/auth/oauth" + + "github.com/vvill/caddy-atproto-auth/internal/db" +) + +// Manager wraps the bluesky oauth client app to handle the lifecycle. +type Manager struct { + App *indigoOauth.ClientApp + pk atcrypto.PrivateKey + kid string +} + +// NewManager initializes the OAuth manager. +func NewManager(store *db.Store, clientID, callbackURL string) (*Manager, error) { + pk, kid, err := store.GetClientKey(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get client key: %w", err) + } + + config := &indigoOauth.ClientConfig{ + ClientID: clientID, + CallbackURL: callbackURL, + Scopes: []string{"atproto", "transition:generic"}, + UserAgent: "caddy-atproto-auth/1.0", + PrivateKey: pk, + KeyID: &kid, + } + + app := indigoOauth.NewClientApp(config, store) + + return &Manager{ + App: app, + pk: pk, + kid: kid, + }, nil +} + +// GetClientMetadata builds the client metadata to serve at /.well-known/oauth-client-metadata.json +func (m *Manager) GetClientMetadata() (*indigoOauth.ClientMetadata, error) { + pub, err := m.pk.PublicKey() + if err != nil { + return nil, fmt.Errorf("failed to extract public key: %w", err) + } + + jwk, err := pub.JWK() + if err != nil { + return nil, fmt.Errorf("failed to extract JWK: %w", err) + } + jwk.KeyID = &m.kid + + appType := "web" + alg := "ES256" + + meta := &indigoOauth.ClientMetadata{ + ClientID: m.App.Config.ClientID, + ApplicationType: &appType, + GrantTypes: []string{"authorization_code", "refresh_token"}, + Scope: strings.Join(m.App.Config.Scopes, " "), + ResponseTypes: []string{"code"}, + RedirectURIs: []string{m.App.Config.CallbackURL}, + TokenEndpointAuthMethod: "private_key_jwt", + TokenEndpointAuthSigningAlg: &alg, + DPoPBoundAccessTokens: true, + JWKS: &indigoOauth.JWKS{ + Keys: []atcrypto.JWK{*jwk}, + }, + } + + return meta, nil +} + +// StartAuthFlow begins the OAuth flow for a given identifier (handle or DID) +func (m *Manager) StartAuthFlow(ctx context.Context, identifier string) (string, error) { + redirectURI, err := m.App.StartAuthFlow(ctx, identifier) + if err != nil { + return "", fmt.Errorf("failed to start auth flow: %w", err) + } + return redirectURI, nil +} + +// ProcessCallback exchanges the authorization code for an access token +func (m *Manager) ProcessCallback(ctx context.Context, query url.Values) (*indigoOauth.ClientSessionData, error) { + sess, err := m.App.ProcessCallback(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to process callback: %w", err) + } + return sess, nil +} + diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go new file mode 100644 index 0000000..46b0e79 --- /dev/null +++ b/internal/resolver/resolver.go @@ -0,0 +1,71 @@ +package resolver + +import ( + "context" + "fmt" + "strings" + + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// Resolver handles AT Protocol identity resolution (Handles, DID:PLC, DID:Web). +type Resolver struct { + dir identity.Directory +} + +// New initializes a new identity resolver using the default Bluesky directory. +// This handles DNS TXT, HTTPS well-known, PLC, and DID:Web lookups with caching. +func New() *Resolver { + return &Resolver{ + dir: identity.DefaultDirectory(), + } +} + +// ResolveIdentifier converts either a handle (e.g., @user.com) or a DID into +// a verified DID, validating it against the directory. +func (r *Resolver) ResolveIdentifier(ctx context.Context, ident string) (string, error) { + ident = strings.TrimPrefix(ident, "@") + + atID, err := syntax.ParseAtIdentifier(ident) + if err != nil { + return "", fmt.Errorf("invalid identifier '%s': %w", ident, err) + } + + id, err := r.dir.Lookup(ctx, atID) + if err != nil { + return "", fmt.Errorf("failed to resolve identity '%s': %w", ident, err) + } + + if id == nil { + return "", fmt.Errorf("identity '%s' not found", ident) + } + + return id.DID.String(), nil +} + +// GetPDSEndpoint returns the Personal Data Server (PDS) URL for a given DID or handle. +func (r *Resolver) GetPDSEndpoint(ctx context.Context, ident string) (string, error) { + ident = strings.TrimPrefix(ident, "@") + + atID, err := syntax.ParseAtIdentifier(ident) + if err != nil { + return "", fmt.Errorf("invalid identifier '%s': %w", ident, err) + } + + id, err := r.dir.Lookup(ctx, atID) + if err != nil { + return "", fmt.Errorf("failed to resolve identity '%s': %w", ident, err) + } + + if id == nil { + return "", fmt.Errorf("identity '%s' not found", ident) + } + + pds := id.PDSEndpoint() + if pds == "" { + return "", fmt.Errorf("no PDS endpoint found for identity '%s'", ident) + } + + return pds, nil +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..3b0bd0b --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,130 @@ +package session + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// Session represents the authenticated user data stored in the cookie. +type Session struct { + DID string `json:"did"` + Handle string `json:"handle"` + ExpiresAt int64 `json:"exp"` +} + +// Manager handles cookie signing and verification. +type Manager struct { + Secret []byte + CookieName string +} + +// NewManager creates a new session manager with the given secret. +func NewManager(secret string) *Manager { + if len(secret) < 32 { + // Warn or error if secret is too short? For now, we assume user configures it properly. + } + return &Manager{ + Secret: []byte(secret), + CookieName: "atproto_session", + } +} + +// sign creates a signature for the given data. +func (m *Manager) sign(data []byte) string { + h := hmac.New(sha256.New, m.Secret) + h.Write(data) + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) +} + +// CreateCookie generates a signed http.Cookie for the session. +func (m *Manager) CreateCookie(did syntax.DID, handle string, duration time.Duration, domain string) (*http.Cookie, error) { + exp := time.Now().Add(duration).Unix() + sess := Session{ + DID: did.String(), + Handle: handle, + ExpiresAt: exp, + } + + data, err := json.Marshal(sess) + if err != nil { + return nil, fmt.Errorf("failed to marshal session: %w", err) + } + + encoded := base64.RawURLEncoding.EncodeToString(data) + signature := m.sign([]byte(encoded)) + value := fmt.Sprintf("%s.%s", encoded, signature) + + cookie := &http.Cookie{ + Name: m.CookieName, + Value: value, + Path: "/", + Domain: domain, + Expires: time.Unix(exp, 0), + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } + + return cookie, nil +} + +// VerifyCookie extracts and validates the session from the request. +func (m *Manager) VerifyCookie(r *http.Request) (*Session, error) { + cookie, err := r.Cookie(m.CookieName) + if err != nil { + return nil, err + } + + parts := strings.Split(cookie.Value, ".") + if len(parts) != 2 { + return nil, errors.New("invalid cookie format") + } + + encodedData := parts[0] + signature := parts[1] + + expectedSig := m.sign([]byte(encodedData)) + if !hmac.Equal([]byte(signature), []byte(expectedSig)) { + return nil, errors.New("invalid cookie signature") + } + + data, err := base64.RawURLEncoding.DecodeString(encodedData) + if err != nil { + return nil, fmt.Errorf("failed to decode cookie data: %w", err) + } + + var sess Session + if err := json.Unmarshal(data, &sess); err != nil { + return nil, fmt.Errorf("failed to unmarshal session: %w", err) + } + + if time.Now().Unix() > sess.ExpiresAt { + return nil, errors.New("session expired") + } + + return &sess, nil +} + +// ClearCookie returns a cookie that clears the session. +func (m *Manager) ClearCookie(domain string) *http.Cookie { + return &http.Cookie{ + Name: m.CookieName, + Value: "", + Path: "/", + Domain: domain, + Expires: time.Unix(0, 0), + MaxAge: -1, + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + } +} diff --git a/portal.go b/portal.go index 17e441e..9d4cea6 100644 --- a/portal.go +++ b/portal.go @@ -1,13 +1,18 @@ package caddyatprotoauth import ( + "encoding/json" "fmt" "net/http" + "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/caddyhttp" + "github.com/vvill/caddy-atproto-auth/internal/oauth" + "github.com/vvill/caddy-atproto-auth/internal/session" + "go.uber.org/zap" ) func init() { @@ -17,7 +22,14 @@ func init() { // Portal is the centralized authentication portal for Path B (Auth Hub). type Portal struct { - Name string `json:"name,omitempty"` + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` // Public domain of the portal (e.g. auth.example.com) + + // Dependencies + app *App + oauth *oauth.Manager + sessions *session.Manager + logger *zap.Logger } // CaddyModule returns the Caddy module information. @@ -30,13 +42,45 @@ func (Portal) CaddyModule() caddy.ModuleInfo { // Provision sets up the module. func (p *Portal) Provision(ctx caddy.Context) error { + p.logger = ctx.Logger() + + // 1. Get Global App + app, err := ctx.App("atproto") + if err != nil { + return fmt.Errorf("getting atproto app: %w", err) + } + p.app = app.(*App) + + // 2. Initialize Session Manager + if p.app.CookieSecret == "" { + return fmt.Errorf("global atproto cookie_secret is required") + } + p.sessions = session.NewManager(p.app.CookieSecret) + + // 3. 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. + if p.Domain == "" { + return fmt.Errorf("atproto_portal requires 'domain' to be set (e.g. auth.example.com)") + } + + clientID := fmt.Sprintf("https://%s/.well-known/oauth-client-metadata.json", p.Domain) + callbackURL := fmt.Sprintf("https://%s/callback", p.Domain) + + mgr, err := oauth.NewManager(p.app.Store, clientID, callbackURL) + if err != nil { + return fmt.Errorf("failed to init oauth manager: %w", err) + } + p.oauth = mgr + return nil } // Validate checks that the configuration is valid. func (p *Portal) Validate() error { if p.Name == "" { - p.Name = "Authentication Portal" // Provide a default + p.Name = "Authentication Portal" } return nil } @@ -51,6 +95,11 @@ func (p *Portal) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return d.ArgErr() } p.Name = d.Val() + case "domain": + if !d.NextArg() { + return d.ArgErr() + } + p.Domain = d.Val() default: return d.Errf("unrecognized subdirective '%s'", d.Val()) } @@ -67,15 +116,102 @@ func parseCaddyfilePortal(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, // ServeHTTP implements caddyhttp.MiddlewareHandler. func (p *Portal) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { - // Central Auth Hub Logic: - // - Serves /.well-known/oauth-client-metadata.json - // - Serves /callback from PDS - // - Serves a login page with the configured 'Name' if not authenticated - // - Initiates PDS resolution and PAR redirect - - // Example naive bypass for now: - w.WriteHeader(http.StatusOK) - w.Write([]byte(fmt.Sprintf("Welcome to %s", p.Name))) - - return nil + // 1. Metadata Endpoint + if r.URL.Path == "/.well-known/oauth-client-metadata.json" { + meta, err := p.oauth.GetClientMetadata() + if err != nil { + p.logger.Error("failed to get client metadata", zap.Error(err)) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return nil + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(meta) + return nil + } + + // 2. Callback Endpoint + if r.URL.Path == "/callback" { + // Process callback + ctx := r.Context() + query := r.URL.Query() + + sessionData, err := p.oauth.ProcessCallback(ctx, query) + if err != nil { + p.logger.Error("oauth callback failed", zap.Error(err)) + http.Error(w, fmt.Sprintf("Authentication failed: %v", err), http.StatusBadRequest) + return nil + } + + // Create Session Cookie + // Use root domain for Auth Hub? Or specific? + // For now, use the portal's domain. + cookie, err := p.sessions.CreateCookie( + sessionData.AccountDID, + "user", // We don't have handle in ClientSessionData yet? Indigo might resolve it. + // Actually ClientSessionData only has AccountDID. + // We might need to resolve handle separately or store it in state? + // For now, placeholder handle. + 24*7*time.Hour, + p.Domain, + ) + if err != nil { + p.logger.Error("failed to create session cookie", zap.Error(err)) + http.Error(w, "Internal Error", http.StatusInternalServerError) + return nil + } + + http.SetCookie(w, cookie) + + // Redirect to home or saved location + http.Redirect(w, r, "/", http.StatusFound) + return nil + } + + // 3. Login Start (Form Action) + if r.URL.Path == "/login" && r.Method == "POST" { + handle := r.FormValue("handle") + if handle == "" { + http.Error(w, "Handle required", http.StatusBadRequest) + return nil + } + + // Start Auth Flow + redirectURI, err := p.oauth.StartAuthFlow(r.Context(), handle) + if err != nil { + p.logger.Error("failed to start auth flow", zap.Error(err)) + http.Error(w, fmt.Sprintf("Failed to resolve identity: %v", err), http.StatusBadRequest) + return nil + } + + http.Redirect(w, r, redirectURI, http.StatusFound) + return nil + } + + // 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) + return nil + } + + return next.ServeHTTP(w, r) } + +// Interface guards +var ( + _ caddy.Provisioner = (*Portal)(nil) + _ caddy.Validator = (*Portal)(nil) + _ caddyhttp.MiddlewareHandler = (*Portal)(nil) + _ caddyfile.Unmarshaler = (*Portal)(nil) +)