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)
+)