Caddy module to require at-proto authentication and restrict routes to DIDs
3

Configure Feed

Select the types of activity you want to include in your feed.

feat: Implement Auth Portal and update Gate logic for Standalone mode

+972 -23
+25
Caddyfile.test
··· 1 + { 2 + admin off 3 + atproto { 4 + storage_path ./test.db 5 + cookie_secret "my-secret-key-must-be-very-long-and-secure" 6 + } 7 + } 8 + 9 + :8080 { 10 + route /auth/* { 11 + atproto_portal { 12 + domain localhost:8080 13 + name "Test Portal" 14 + } 15 + } 16 + 17 + route /protected/* { 18 + atproto_gate { 19 + allow @test.bsky.social 20 + } 21 + respond "You are authorized!" 22 + } 23 + 24 + respond "Hello World" 25 + }
+151 -7
gate.go
··· 1 1 package caddyatprotoauth 2 2 3 3 import ( 4 + "encoding/json" 4 5 "fmt" 5 6 "net/http" 7 + "time" 6 8 7 9 "github.com/caddyserver/caddy/v2" 8 10 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 9 11 "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 10 12 "github.com/caddyserver/caddy/v2/modules/caddyhttp" 13 + "github.com/vvill/caddy-atproto-auth/internal/oauth" 14 + "github.com/vvill/caddy-atproto-auth/internal/resolver" 15 + "github.com/vvill/caddy-atproto-auth/internal/session" 11 16 ) 12 17 13 18 func init() { ··· 18 23 // Gate acts as a middleware that guards endpoints 19 24 // and validates the session cookie. 20 25 type Gate struct { 21 - Allow []string `json:"allow,omitempty"` 26 + Allow []string `json:"allow,omitempty"` 27 + Domain string `json:"domain,omitempty"` // Public domain for standalone mode (e.g. app.example.com) 28 + 29 + // Dependencies 30 + app *App 31 + resolver *resolver.Resolver 32 + sessions *session.Manager 33 + oauth *oauth.Manager 22 34 } 23 35 24 36 // CaddyModule returns the Caddy module information. ··· 31 43 32 44 // Provision sets up the module. 33 45 func (g *Gate) Provision(ctx caddy.Context) error { 34 - // Initialize identities and cache resolved handles here. 46 + // 1. Get Global App 47 + app, err := ctx.App("atproto") 48 + if err != nil { 49 + return fmt.Errorf("getting atproto app: %w", err) 50 + } 51 + g.app = app.(*App) 52 + 53 + // 2. Initialize Session Manager (using global secret) 54 + if g.app.CookieSecret == "" { 55 + return fmt.Errorf("global atproto cookie_secret is required") 56 + } 57 + g.sessions = session.NewManager(g.app.CookieSecret) 58 + 59 + // 3. Initialize Identity Resolver 60 + g.resolver = resolver.New() 61 + 62 + // 4. Initialize OAuth Manager (if domain set for standalone mode) 63 + if g.Domain != "" { 64 + clientID := fmt.Sprintf("https://%s/.well-known/oauth-client-metadata.json", g.Domain) 65 + callbackURL := fmt.Sprintf("https://%s/callback", g.Domain) 66 + 67 + mgr, err := oauth.NewManager(g.app.Store, clientID, callbackURL) 68 + if err != nil { 69 + return fmt.Errorf("failed to init oauth manager: %w", err) 70 + } 71 + g.oauth = mgr 72 + } 73 + 35 74 return nil 36 75 } 37 76 ··· 50 89 switch d.Val() { 51 90 case "allow": 52 91 g.Allow = append(g.Allow, d.RemainingArgs()...) 92 + case "domain": 93 + if !d.NextArg() { 94 + return d.ArgErr() 95 + } 96 + g.Domain = d.Val() 53 97 default: 54 98 return d.Errf("unrecognized subdirective '%s'", d.Val()) 55 99 } ··· 67 111 68 112 // ServeHTTP implements caddyhttp.MiddlewareHandler. 69 113 func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { 114 + // If in Standalone Mode, handle OAuth paths 115 + if g.oauth != nil { 116 + if r.URL.Path == "/.well-known/oauth-client-metadata.json" { 117 + meta, err := g.oauth.GetClientMetadata() 118 + if err != nil { 119 + return caddyhttp.Error(http.StatusInternalServerError, err) 120 + } 121 + w.Header().Set("Content-Type", "application/json") 122 + return json.NewEncoder(w).Encode(meta) 123 + } 124 + if r.URL.Path == "/callback" { 125 + // Process callback 126 + sessionData, err := g.oauth.ProcessCallback(r.Context(), r.URL.Query()) 127 + if err != nil { 128 + return caddyhttp.Error(http.StatusBadRequest, err) 129 + } 130 + 131 + // Create Session Cookie 132 + cookie, err := g.sessions.CreateCookie( 133 + sessionData.AccountDID, 134 + "user", // Placeholder handle 135 + 24*7*time.Hour, 136 + g.Domain, 137 + ) 138 + if err != nil { 139 + return caddyhttp.Error(http.StatusInternalServerError, err) 140 + } 141 + 142 + http.SetCookie(w, cookie) 143 + http.Redirect(w, r, "/", http.StatusFound) 144 + return nil 145 + } 146 + } 147 + 70 148 // 1. Verify stateless cookie here 149 + sess, err := g.sessions.VerifyCookie(r) 150 + if err == nil { 151 + // Session valid! 152 + // Check authorization against allowlist 153 + allowed := false 154 + for _, allow := range g.Allow { 155 + if allow == sess.DID || allow == sess.Handle { 156 + allowed = true 157 + break 158 + } 159 + } 160 + 161 + if allowed { 162 + // Inject headers 163 + r.Header.Set("X-Atproto-Did", sess.DID) 164 + r.Header.Set("X-Atproto-Handle", sess.Handle) 165 + return next.ServeHTTP(w, r) 166 + } 167 + 168 + // Authenticated but not authorized 169 + return caddyhttp.Error(http.StatusForbidden, fmt.Errorf("user not authorized")) 170 + } 171 + 71 172 // 2. If invalid/missing, initiate redirect to PDS or Auth Hub 72 - // 3. If valid, set headers (X-Atproto-Did) and proceed 73 - 74 - // Example: inject header for downstream (to be implemented correctly) 75 - // r.Header.Set("X-Atproto-Did", "did:plc:xxx") 173 + // If standalone mode (g.oauth != nil), we can initiate flow here? 174 + // But which identity? We need to prompt user for handle. 175 + // So we should redirect to a login page or show a simple form. 176 + // For simplicity, let's just 401 and tell user to go to /login (which we handle if standalone?) 177 + // Wait, we didn't add /login handler to Gate yet. 178 + // If standalone, Gate should act as Portal. 179 + // Let's implement a simple /login handler in Gate if oauth is enabled. 180 + 181 + if g.oauth != nil { 182 + if r.URL.Path == "/login" { 183 + if r.Method == "POST" { 184 + handle := r.FormValue("handle") 185 + redirectURI, err := g.oauth.StartAuthFlow(r.Context(), handle) 186 + if err != nil { 187 + return caddyhttp.Error(http.StatusBadRequest, err) 188 + } 189 + http.Redirect(w, r, redirectURI, http.StatusFound) 190 + return nil 191 + } 192 + // Show login form 193 + w.Header().Set("Content-Type", "text/html") 194 + fmt.Fprintf(w, ` 195 + <html><body> 196 + <h1>Login</h1> 197 + <form method="POST" action="/login"> 198 + <input name="handle" placeholder="@user.bsky.social" required> 199 + <button>Login</button> 200 + </form> 201 + </body></html> 202 + `) 203 + return nil 204 + } 205 + // Redirect to /login 206 + http.Redirect(w, r, "/login", http.StatusFound) 207 + return nil 208 + } 76 209 77 - return next.ServeHTTP(w, r) 210 + // If NOT standalone (Auth Hub mode), we should redirect to the central Auth Portal. 211 + // We don't know where it is unless configured. 212 + // For now, return 401. 213 + return caddyhttp.Error(http.StatusUnauthorized, fmt.Errorf("unauthorized")) 78 214 } 215 + 216 + // Interface guards 217 + var ( 218 + _ caddy.Provisioner = (*Gate)(nil) 219 + _ caddy.Validator = (*Gate)(nil) 220 + _ caddyhttp.MiddlewareHandler = (*Gate)(nil) 221 + _ caddyfile.Unmarshaler = (*Gate)(nil) 222 + )
+91 -1
global.go
··· 1 1 package caddyatprotoauth 2 2 3 3 import ( 4 + "fmt" 5 + 4 6 "github.com/caddyserver/caddy/v2" 7 + "github.com/caddyserver/caddy/v2/caddyconfig" 8 + "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 9 + "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 10 + 11 + "github.com/vvill/caddy-atproto-auth/internal/db" 12 + "github.com/vvill/caddy-atproto-auth/internal/oauth" 5 13 ) 6 14 7 15 func init() { 8 16 caddy.RegisterModule(App{}) 17 + httpcaddyfile.RegisterGlobalOption("atproto", parseGlobalAtproto) 9 18 } 10 19 11 20 // App configures the global atproto integration. 12 21 type App struct { 13 22 StoragePath string `json:"storage_path,omitempty"` 14 23 CookieSecret string `json:"cookie_secret,omitempty"` 24 + 25 + // Internal state 26 + Store *db.Store `json:"-"` 27 + OAuthManager *oauth.Manager `json:"-"` 15 28 } 16 29 17 30 // CaddyModule returns the Caddy module information. ··· 22 35 } 23 36 } 24 37 38 + // Provision sets up the global app state. 39 + func (a *App) Provision(ctx caddy.Context) error { 40 + // Defaults 41 + if a.StoragePath == "" { 42 + a.StoragePath = "atproto.db" // Relative to workdir or specific path 43 + } 44 + // Resolve relative path against Caddy's storage or workdir if needed. 45 + // For simplicity, we assume absolute or relative to CWD. 46 + 47 + // Initialize DB 48 + store, err := db.NewStore(a.StoragePath) 49 + if err != nil { 50 + return fmt.Errorf("failed to initialize atproto storage: %w", err) 51 + } 52 + a.Store = store 53 + 54 + // Initialize OAuth Manager (requires client ID and callback URL to be fully configured, 55 + // but those might be per-portal or global. The spec says "acts as an OAuth Client". 56 + // If the plugin acts as a *single* client for many subdomains, we need global config for client ID. 57 + // But spec says: "Path A: The Self-Contained Route" and "Path B: The Auth Hub". 58 + // This implies potentially different client IDs for different sites OR one central hub. 59 + // For now, let's defer OAuthManager creation to the Portal or Gate if it's per-route, 60 + // OR we need to add ClientID/CallbackURL to the global config if it's shared. 61 + // 62 + // Looking at the spec: 63 + // "The module acts as an OAuth Client" 64 + // "Global Configuration: storage_path, cookie_secret" 65 + // 66 + // It seems the App module holds the *Storage* and *Keys*. 67 + // The *Portal* (or Gate) defines the "Client" identity (metadata, callback). 68 + // However, `oauth.NewManager` takes a `db.Store`. So the App owns the Store. 69 + // The Portal will instantiate the Manager using the App's Store. 70 + 71 + return nil 72 + } 73 + 25 74 // Start starts the application. 26 75 func (a *App) Start() error { 27 - // Initialize global database and settings here 28 76 return nil 29 77 } 30 78 31 79 // Stop stops the application. 32 80 func (a *App) Stop() error { 81 + if a.Store != nil { 82 + return a.Store.Close() 83 + } 33 84 return nil 34 85 } 86 + 87 + // Interface guards 88 + var ( 89 + _ caddy.App = (*App)(nil) 90 + _ caddy.Provisioner = (*App)(nil) 91 + ) 92 + 93 + // parseGlobalAtproto parses the global 'atproto' Caddyfile option. 94 + // Format: 95 + // 96 + // atproto { 97 + // storage_path /path/to/db 98 + // cookie_secret <secret> 99 + // } 100 + func parseGlobalAtproto(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) { 101 + app := &App{} 102 + for d.Next() { 103 + for d.NextBlock(0) { 104 + switch d.Val() { 105 + case "storage_path": 106 + if !d.NextArg() { 107 + return nil, d.ArgErr() 108 + } 109 + app.StoragePath = d.Val() 110 + case "cookie_secret": 111 + if !d.NextArg() { 112 + return nil, d.ArgErr() 113 + } 114 + app.CookieSecret = d.Val() 115 + default: 116 + return nil, d.Errf("unrecognized subdirective '%s'", d.Val()) 117 + } 118 + } 119 + } 120 + return httpcaddyfile.App{ 121 + Name: "atproto", 122 + Value: caddyconfig.JSON(app, nil), 123 + }, nil 124 + }
+12 -2
go.mod
··· 2 2 3 3 go 1.25.5 4 4 5 - require github.com/caddyserver/caddy/v2 v2.8.4 5 + require ( 6 + github.com/bluesky-social/indigo v0.0.0-20260303011501-01fde705a450 7 + github.com/caddyserver/caddy/v2 v2.8.4 8 + github.com/mattn/go-sqlite3 v1.14.34 9 + ) 6 10 7 11 require ( 8 12 filippo.io/edwards25519 v1.1.0 // indirect ··· 29 33 github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect 30 34 github.com/dlclark/regexp2 v1.11.0 // indirect 31 35 github.com/dustin/go-humanize v1.0.1 // indirect 36 + github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 32 37 github.com/felixge/httpsnoop v1.0.4 // indirect 33 38 github.com/fxamacker/cbor/v2 v2.6.0 // indirect 34 39 github.com/go-chi/chi/v5 v5.0.12 // indirect ··· 40 45 github.com/go-logr/stdr v1.2.2 // indirect 41 46 github.com/go-sql-driver/mysql v1.7.1 // indirect 42 47 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 48 + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect 43 49 github.com/golang/glog v1.2.0 // indirect 44 50 github.com/golang/protobuf v1.5.4 // indirect 45 51 github.com/golang/snappy v0.0.4 // indirect 46 52 github.com/google/cel-go v0.20.1 // indirect 47 53 github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745 // indirect 54 + github.com/google/go-querystring v1.1.0 // indirect 48 55 github.com/google/go-tpm v0.9.0 // indirect 49 56 github.com/google/go-tspi v0.3.0 // indirect 50 57 github.com/google/pprof v0.0.0-20231212022811-ec68065c825e // indirect 51 58 github.com/google/uuid v1.6.0 // indirect 52 59 github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 // indirect 60 + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 53 61 github.com/huandu/xstrings v1.3.3 // indirect 54 62 github.com/imdario/mergo v0.3.12 // indirect 55 63 github.com/inconshreveable/mousetrap v1.1.0 // indirect ··· 73 81 github.com/mitchellh/copystructure v1.2.0 // indirect 74 82 github.com/mitchellh/go-ps v1.0.0 // indirect 75 83 github.com/mitchellh/reflectwalk v1.0.2 // indirect 84 + github.com/mr-tron/base58 v1.2.0 // indirect 76 85 github.com/onsi/ginkgo/v2 v2.13.2 // indirect 77 86 github.com/pires/go-proxyproto v0.7.0 // indirect 78 87 github.com/pkg/errors v0.9.1 // indirect ··· 98 107 github.com/spf13/cobra v1.8.0 // indirect 99 108 github.com/spf13/pflag v1.0.5 // indirect 100 109 github.com/stoewer/go-strcase v1.2.0 // indirect 101 - github.com/stretchr/testify v1.10.0 // indirect 102 110 github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect 103 111 github.com/urfave/cli v1.22.14 // indirect 104 112 github.com/x448/float16 v0.8.4 // indirect 105 113 github.com/yuin/goldmark v1.7.1 // indirect 106 114 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect 107 115 github.com/zeebo/blake3 v0.2.3 // indirect 116 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 117 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 108 118 go.etcd.io/bbolt v1.3.9 // indirect 109 119 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 110 120 go.opentelemetry.io/contrib/propagators/autoprop v0.42.0 // indirect
+43
go.sum
··· 73 73 github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= 74 74 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 75 75 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 76 + github.com/bluesky-social/indigo v0.0.0-20260303011501-01fde705a450 h1:XXOO2pfb5mdJowNpMz9SlBcLrH/Ovkc4qi3OJ9zH54A= 77 + github.com/bluesky-social/indigo v0.0.0-20260303011501-01fde705a450/go.mod h1:VG/LeqLGNI3Ew7lsYixajnZGFfWPv144qbUddh+Oyag= 76 78 github.com/caddyserver/caddy/v2 v2.8.4 h1:q3pe0wpBj1OcHFZ3n/1nl4V4bxBrYoSoab7rL9BMYNk= 77 79 github.com/caddyserver/caddy/v2 v2.8.4/go.mod h1:vmDAHp3d05JIvuhc24LmnxVlsZmWnUwbP5WMjzcMPWw= 78 80 github.com/caddyserver/certmagic v0.21.3 h1:pqRRry3yuB4CWBVq9+cUqu+Y6E2z8TswbhNx1AZeYm0= ··· 128 130 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= 129 131 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 130 132 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 133 + github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 134 + github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 131 135 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 132 136 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 133 137 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= ··· 160 164 github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= 161 165 github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= 162 166 github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 167 + github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 168 + github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 169 + github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 170 + github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= 171 + github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 163 172 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 164 173 github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68= 165 174 github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= ··· 178 187 github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= 179 188 github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745 h1:heyoXNxkRT155x4jTAiSv5BVSVkueifPUm+Q8LUXMRo= 180 189 github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745/go.mod h1:zN0wUQgV9LjwLZeFHnrAbQi8hzMVvEWePyk+MhPOk7k= 190 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 181 191 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 182 192 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 183 193 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 194 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 195 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 184 196 github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk= 185 197 github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU= 186 198 github.com/google/go-tpm-tools v0.4.4 h1:oiQfAIkc6xTy9Fl5NKTeTJkBTlXdHsxAofmQyxBKY98= ··· 201 213 github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI= 202 214 github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1 h1:6UKoz5ujsI55KNpsJH3UwCq3T8kKbZwNZBNPuTTje8U= 203 215 github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.1/go.mod h1:YvJ2f6MplWDhfxiUC3KpyTy76kYUZA4W3pTv/wdKQ9Y= 216 + github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 217 + github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 204 218 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 205 219 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 206 220 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= ··· 212 226 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 213 227 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 214 228 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 229 + github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= 230 + github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 215 231 github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= 216 232 github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= 217 233 github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= ··· 299 315 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 300 316 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 301 317 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 318 + github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= 319 + github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 302 320 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= 303 321 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 304 322 github.com/mholt/acmez/v2 v2.0.1 h1:3/3N0u1pLjMK4sNEAFSI+bcvzbPhRpY383sy1kLHJ6k= 305 323 github.com/mholt/acmez/v2 v2.0.1/go.mod h1:fX4c9r5jYwMyMsC+7tkYRxHibkOTgta5DIFGoe67e1U= 306 324 github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs= 307 325 github.com/miekg/dns v1.1.59/go.mod h1:nZpewl5p6IvctfgrckopVx2OlSEHPRO/U4SYkRklrEk= 326 + github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 327 + github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 308 328 github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= 309 329 github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 310 330 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= ··· 315 335 github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 316 336 github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 317 337 github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 338 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 339 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 340 + github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= 341 + github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 342 + github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 343 + github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 344 + github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 345 + github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 346 + github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 347 + github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 348 + github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 349 + github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 318 350 github.com/onsi/ginkgo/v2 v2.13.2 h1:Bi2gGVkfn6gQcjNjZJVO8Gf0FHzMPf2phUei9tejVMs= 319 351 github.com/onsi/ginkgo/v2 v2.13.2/go.mod h1:XStQ8QcGwLyF4HdfcZB8SFOS/MWCgDuXMSBe6zrvLgM= 320 352 github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg= ··· 422 454 github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= 423 455 github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk= 424 456 github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA= 457 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 458 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 425 459 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 426 460 github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 427 461 github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= ··· 438 472 github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= 439 473 github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= 440 474 github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= 475 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 476 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 477 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 478 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 441 479 go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI= 442 480 go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE= 443 481 go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= ··· 611 649 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 612 650 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 613 651 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 652 + golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 614 653 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 654 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 655 + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 615 656 google.golang.org/api v0.180.0 h1:M2D87Yo0rGBPWpo1orwfCLehUUL6E7/TYe5gvMQWDh4= 616 657 google.golang.org/api v0.180.0/go.mod h1:51AiyoEg1MJPSZ9zvklA8VnRILPXxn1iVen9v25XHAE= 617 658 google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda h1:wu/KJm9KJwpfHWhkkZGohVC6KRrc1oJNr4jwtQMOQXw= ··· 644 685 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 645 686 howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= 646 687 howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= 688 + lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= 689 + lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
+202
internal/db/db.go
··· 1 + package db 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "encoding/json" 7 + "fmt" 8 + 9 + "github.com/bluesky-social/indigo/atproto/atcrypto" 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + _ "github.com/mattn/go-sqlite3" 13 + ) 14 + 15 + // Ensure DB implements ClientAuthStore 16 + var _ oauth.ClientAuthStore = (*Store)(nil) 17 + 18 + // Store handles SQLite persistence for the plugin. 19 + type Store struct { 20 + db *sql.DB 21 + } 22 + 23 + // NewStore initializes a new SQLite-backed storage. 24 + func NewStore(path string) (*Store, error) { 25 + // Enable WAL mode for better concurrency 26 + db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000") 27 + if err != nil { 28 + return nil, fmt.Errorf("failed to open database: %w", err) 29 + } 30 + 31 + if err := db.Ping(); err != nil { 32 + return nil, fmt.Errorf("failed to ping database: %w", err) 33 + } 34 + 35 + store := &Store{db: db} 36 + if err := store.initSchema(); err != nil { 37 + return nil, fmt.Errorf("failed to initialize schema: %w", err) 38 + } 39 + 40 + return store, nil 41 + } 42 + 43 + func (s *Store) initSchema() error { 44 + const schema = ` 45 + CREATE TABLE IF NOT EXISTS auth_requests ( 46 + state TEXT PRIMARY KEY, 47 + data TEXT NOT NULL, 48 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP 49 + ); 50 + 51 + CREATE TABLE IF NOT EXISTS auth_sessions ( 52 + did TEXT NOT NULL, 53 + session_id TEXT NOT NULL, 54 + data TEXT NOT NULL, 55 + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, 56 + PRIMARY KEY (did, session_id) 57 + ); 58 + 59 + CREATE TABLE IF NOT EXISTS system_keys ( 60 + id TEXT PRIMARY KEY, 61 + key_data BLOB NOT NULL, 62 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP 63 + ); 64 + ` 65 + _, err := s.db.Exec(schema) 66 + return err 67 + } 68 + 69 + // Close closes the underlying database. 70 + func (s *Store) Close() error { 71 + return s.db.Close() 72 + } 73 + 74 + // GetClientKey retrieves the main client key, generating it if it doesn't exist. 75 + // Returns the private key and its ID (a hash of the public key or a random string). 76 + func (s *Store) GetClientKey(ctx context.Context) (atcrypto.PrivateKey, string, error) { 77 + var keyData []byte 78 + err := s.db.QueryRowContext(ctx, "SELECT key_data FROM system_keys WHERE id = 'client_key'").Scan(&keyData) 79 + if err == sql.ErrNoRows { 80 + // Generate a new P-256 key 81 + pk, err := atcrypto.GeneratePrivateKeyP256() 82 + if err != nil { 83 + return nil, "", fmt.Errorf("failed to generate new client key: %w", err) 84 + } 85 + 86 + keyData = pk.Bytes() 87 + 88 + _, err = s.db.ExecContext(ctx, "INSERT INTO system_keys (id, key_data) VALUES ('client_key', ?)", keyData) 89 + if err != nil { 90 + return nil, "", fmt.Errorf("failed to save generated client key: %w", err) 91 + } 92 + 93 + return pk, "client_key", nil 94 + } else if err != nil { 95 + return nil, "", fmt.Errorf("failed to load client key: %w", err) 96 + } 97 + 98 + pk, err := atcrypto.ParsePrivateBytesP256(keyData) 99 + if err != nil { 100 + return nil, "", fmt.Errorf("failed to parse existing client key: %w", err) 101 + } 102 + 103 + return pk, "client_key", nil 104 + } 105 + 106 + // GetSession retrieves session data from the database. 107 + func (s *Store) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 108 + var dataStr string 109 + err := s.db.QueryRowContext(ctx, "SELECT data FROM auth_sessions WHERE did = ? AND session_id = ?", did.String(), sessionID).Scan(&dataStr) 110 + if err != nil { 111 + if err == sql.ErrNoRows { 112 + // 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. 113 + // Indigo's memstore returns (nil, nil) or custom error. We'll return nil for the session and potentially an error if we need to. 114 + // 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. 115 + return nil, nil 116 + } 117 + return nil, fmt.Errorf("failed to query session: %w", err) 118 + } 119 + 120 + var sessionData oauth.ClientSessionData 121 + if err := json.Unmarshal([]byte(dataStr), &sessionData); err != nil { 122 + return nil, fmt.Errorf("failed to parse session data: %w", err) 123 + } 124 + 125 + return &sessionData, nil 126 + } 127 + 128 + // SaveSession saves session data into the database. 129 + func (s *Store) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { 130 + dataBytes, err := json.Marshal(sess) 131 + if err != nil { 132 + return fmt.Errorf("failed to serialize session data: %w", err) 133 + } 134 + 135 + _, err = s.db.ExecContext(ctx, ` 136 + INSERT INTO auth_sessions (did, session_id, data, updated_at) 137 + VALUES (?, ?, ?, CURRENT_TIMESTAMP) 138 + ON CONFLICT(did, session_id) DO UPDATE SET data = excluded.data, updated_at = CURRENT_TIMESTAMP 139 + `, sess.AccountDID.String(), sess.SessionID, string(dataBytes)) 140 + if err != nil { 141 + return fmt.Errorf("failed to save session: %w", err) 142 + } 143 + 144 + return nil 145 + } 146 + 147 + // DeleteSession removes session data from the database. 148 + func (s *Store) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 149 + _, err := s.db.ExecContext(ctx, "DELETE FROM auth_sessions WHERE did = ? AND session_id = ?", did.String(), sessionID) 150 + if err != nil { 151 + return fmt.Errorf("failed to delete session: %w", err) 152 + } 153 + return nil 154 + } 155 + 156 + // GetAuthRequestInfo retrieves the auth request data by state. 157 + func (s *Store) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 158 + var dataStr string 159 + err := s.db.QueryRowContext(ctx, "SELECT data FROM auth_requests WHERE state = ?", state).Scan(&dataStr) 160 + if err != nil { 161 + if err == sql.ErrNoRows { 162 + // AuthRequestData not found 163 + return nil, fmt.Errorf("auth request info not found for state") 164 + } 165 + return nil, fmt.Errorf("failed to query auth request: %w", err) 166 + } 167 + 168 + var reqData oauth.AuthRequestData 169 + if err := json.Unmarshal([]byte(dataStr), &reqData); err != nil { 170 + return nil, fmt.Errorf("failed to parse auth request data: %w", err) 171 + } 172 + 173 + return &reqData, nil 174 + } 175 + 176 + // SaveAuthRequestInfo saves the auth request info. 177 + func (s *Store) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 178 + dataBytes, err := json.Marshal(info) 179 + if err != nil { 180 + return fmt.Errorf("failed to serialize auth request data: %w", err) 181 + } 182 + 183 + // Creating is fine. It shouldn't exist, but we can do an INSERT OR REPLACE just in case. 184 + _, err = s.db.ExecContext(ctx, ` 185 + INSERT OR REPLACE INTO auth_requests (state, data, created_at) 186 + VALUES (?, ?, CURRENT_TIMESTAMP) 187 + `, info.State, string(dataBytes)) 188 + if err != nil { 189 + return fmt.Errorf("failed to save auth request: %w", err) 190 + } 191 + 192 + return nil 193 + } 194 + 195 + // DeleteAuthRequestInfo removes the auth request info after it's been used or expired. 196 + func (s *Store) DeleteAuthRequestInfo(ctx context.Context, state string) error { 197 + _, err := s.db.ExecContext(ctx, "DELETE FROM auth_requests WHERE state = ?", state) 198 + if err != nil { 199 + return fmt.Errorf("failed to delete auth request: %w", err) 200 + } 201 + return nil 202 + }
+98
internal/oauth/manager.go
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/url" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/atproto/atcrypto" 10 + indigoOauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + 12 + "github.com/vvill/caddy-atproto-auth/internal/db" 13 + ) 14 + 15 + // Manager wraps the bluesky oauth client app to handle the lifecycle. 16 + type Manager struct { 17 + App *indigoOauth.ClientApp 18 + pk atcrypto.PrivateKey 19 + kid string 20 + } 21 + 22 + // NewManager initializes the OAuth manager. 23 + func NewManager(store *db.Store, clientID, callbackURL string) (*Manager, error) { 24 + pk, kid, err := store.GetClientKey(context.Background()) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to get client key: %w", err) 27 + } 28 + 29 + config := &indigoOauth.ClientConfig{ 30 + ClientID: clientID, 31 + CallbackURL: callbackURL, 32 + Scopes: []string{"atproto", "transition:generic"}, 33 + UserAgent: "caddy-atproto-auth/1.0", 34 + PrivateKey: pk, 35 + KeyID: &kid, 36 + } 37 + 38 + app := indigoOauth.NewClientApp(config, store) 39 + 40 + return &Manager{ 41 + App: app, 42 + pk: pk, 43 + kid: kid, 44 + }, nil 45 + } 46 + 47 + // GetClientMetadata builds the client metadata to serve at /.well-known/oauth-client-metadata.json 48 + func (m *Manager) GetClientMetadata() (*indigoOauth.ClientMetadata, error) { 49 + pub, err := m.pk.PublicKey() 50 + if err != nil { 51 + return nil, fmt.Errorf("failed to extract public key: %w", err) 52 + } 53 + 54 + jwk, err := pub.JWK() 55 + if err != nil { 56 + return nil, fmt.Errorf("failed to extract JWK: %w", err) 57 + } 58 + jwk.KeyID = &m.kid 59 + 60 + appType := "web" 61 + alg := "ES256" 62 + 63 + meta := &indigoOauth.ClientMetadata{ 64 + ClientID: m.App.Config.ClientID, 65 + ApplicationType: &appType, 66 + GrantTypes: []string{"authorization_code", "refresh_token"}, 67 + Scope: strings.Join(m.App.Config.Scopes, " "), 68 + ResponseTypes: []string{"code"}, 69 + RedirectURIs: []string{m.App.Config.CallbackURL}, 70 + TokenEndpointAuthMethod: "private_key_jwt", 71 + TokenEndpointAuthSigningAlg: &alg, 72 + DPoPBoundAccessTokens: true, 73 + JWKS: &indigoOauth.JWKS{ 74 + Keys: []atcrypto.JWK{*jwk}, 75 + }, 76 + } 77 + 78 + return meta, nil 79 + } 80 + 81 + // StartAuthFlow begins the OAuth flow for a given identifier (handle or DID) 82 + func (m *Manager) StartAuthFlow(ctx context.Context, identifier string) (string, error) { 83 + redirectURI, err := m.App.StartAuthFlow(ctx, identifier) 84 + if err != nil { 85 + return "", fmt.Errorf("failed to start auth flow: %w", err) 86 + } 87 + return redirectURI, nil 88 + } 89 + 90 + // ProcessCallback exchanges the authorization code for an access token 91 + func (m *Manager) ProcessCallback(ctx context.Context, query url.Values) (*indigoOauth.ClientSessionData, error) { 92 + sess, err := m.App.ProcessCallback(ctx, query) 93 + if err != nil { 94 + return nil, fmt.Errorf("failed to process callback: %w", err) 95 + } 96 + return sess, nil 97 + } 98 +
+71
internal/resolver/resolver.go
··· 1 + package resolver 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/bluesky-social/indigo/atproto/identity" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + // Resolver handles AT Protocol identity resolution (Handles, DID:PLC, DID:Web). 13 + type Resolver struct { 14 + dir identity.Directory 15 + } 16 + 17 + // New initializes a new identity resolver using the default Bluesky directory. 18 + // This handles DNS TXT, HTTPS well-known, PLC, and DID:Web lookups with caching. 19 + func New() *Resolver { 20 + return &Resolver{ 21 + dir: identity.DefaultDirectory(), 22 + } 23 + } 24 + 25 + // ResolveIdentifier converts either a handle (e.g., @user.com) or a DID into 26 + // a verified DID, validating it against the directory. 27 + func (r *Resolver) ResolveIdentifier(ctx context.Context, ident string) (string, error) { 28 + ident = strings.TrimPrefix(ident, "@") 29 + 30 + atID, err := syntax.ParseAtIdentifier(ident) 31 + if err != nil { 32 + return "", fmt.Errorf("invalid identifier '%s': %w", ident, err) 33 + } 34 + 35 + id, err := r.dir.Lookup(ctx, atID) 36 + if err != nil { 37 + return "", fmt.Errorf("failed to resolve identity '%s': %w", ident, err) 38 + } 39 + 40 + if id == nil { 41 + return "", fmt.Errorf("identity '%s' not found", ident) 42 + } 43 + 44 + return id.DID.String(), nil 45 + } 46 + 47 + // GetPDSEndpoint returns the Personal Data Server (PDS) URL for a given DID or handle. 48 + func (r *Resolver) GetPDSEndpoint(ctx context.Context, ident string) (string, error) { 49 + ident = strings.TrimPrefix(ident, "@") 50 + 51 + atID, err := syntax.ParseAtIdentifier(ident) 52 + if err != nil { 53 + return "", fmt.Errorf("invalid identifier '%s': %w", ident, err) 54 + } 55 + 56 + id, err := r.dir.Lookup(ctx, atID) 57 + if err != nil { 58 + return "", fmt.Errorf("failed to resolve identity '%s': %w", ident, err) 59 + } 60 + 61 + if id == nil { 62 + return "", fmt.Errorf("identity '%s' not found", ident) 63 + } 64 + 65 + pds := id.PDSEndpoint() 66 + if pds == "" { 67 + return "", fmt.Errorf("no PDS endpoint found for identity '%s'", ident) 68 + } 69 + 70 + return pds, nil 71 + }
+130
internal/session/session.go
··· 1 + package session 2 + 3 + import ( 4 + "crypto/hmac" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "net/http" 11 + "strings" 12 + "time" 13 + 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + ) 16 + 17 + // Session represents the authenticated user data stored in the cookie. 18 + type Session struct { 19 + DID string `json:"did"` 20 + Handle string `json:"handle"` 21 + ExpiresAt int64 `json:"exp"` 22 + } 23 + 24 + // Manager handles cookie signing and verification. 25 + type Manager struct { 26 + Secret []byte 27 + CookieName string 28 + } 29 + 30 + // NewManager creates a new session manager with the given secret. 31 + func NewManager(secret string) *Manager { 32 + if len(secret) < 32 { 33 + // Warn or error if secret is too short? For now, we assume user configures it properly. 34 + } 35 + return &Manager{ 36 + Secret: []byte(secret), 37 + CookieName: "atproto_session", 38 + } 39 + } 40 + 41 + // sign creates a signature for the given data. 42 + func (m *Manager) sign(data []byte) string { 43 + h := hmac.New(sha256.New, m.Secret) 44 + h.Write(data) 45 + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) 46 + } 47 + 48 + // CreateCookie generates a signed http.Cookie for the session. 49 + func (m *Manager) CreateCookie(did syntax.DID, handle string, duration time.Duration, domain string) (*http.Cookie, error) { 50 + exp := time.Now().Add(duration).Unix() 51 + sess := Session{ 52 + DID: did.String(), 53 + Handle: handle, 54 + ExpiresAt: exp, 55 + } 56 + 57 + data, err := json.Marshal(sess) 58 + if err != nil { 59 + return nil, fmt.Errorf("failed to marshal session: %w", err) 60 + } 61 + 62 + encoded := base64.RawURLEncoding.EncodeToString(data) 63 + signature := m.sign([]byte(encoded)) 64 + value := fmt.Sprintf("%s.%s", encoded, signature) 65 + 66 + cookie := &http.Cookie{ 67 + Name: m.CookieName, 68 + Value: value, 69 + Path: "/", 70 + Domain: domain, 71 + Expires: time.Unix(exp, 0), 72 + Secure: true, 73 + HttpOnly: true, 74 + SameSite: http.SameSiteLaxMode, 75 + } 76 + 77 + return cookie, nil 78 + } 79 + 80 + // VerifyCookie extracts and validates the session from the request. 81 + func (m *Manager) VerifyCookie(r *http.Request) (*Session, error) { 82 + cookie, err := r.Cookie(m.CookieName) 83 + if err != nil { 84 + return nil, err 85 + } 86 + 87 + parts := strings.Split(cookie.Value, ".") 88 + if len(parts) != 2 { 89 + return nil, errors.New("invalid cookie format") 90 + } 91 + 92 + encodedData := parts[0] 93 + signature := parts[1] 94 + 95 + expectedSig := m.sign([]byte(encodedData)) 96 + if !hmac.Equal([]byte(signature), []byte(expectedSig)) { 97 + return nil, errors.New("invalid cookie signature") 98 + } 99 + 100 + data, err := base64.RawURLEncoding.DecodeString(encodedData) 101 + if err != nil { 102 + return nil, fmt.Errorf("failed to decode cookie data: %w", err) 103 + } 104 + 105 + var sess Session 106 + if err := json.Unmarshal(data, &sess); err != nil { 107 + return nil, fmt.Errorf("failed to unmarshal session: %w", err) 108 + } 109 + 110 + if time.Now().Unix() > sess.ExpiresAt { 111 + return nil, errors.New("session expired") 112 + } 113 + 114 + return &sess, nil 115 + } 116 + 117 + // ClearCookie returns a cookie that clears the session. 118 + func (m *Manager) ClearCookie(domain string) *http.Cookie { 119 + return &http.Cookie{ 120 + Name: m.CookieName, 121 + Value: "", 122 + Path: "/", 123 + Domain: domain, 124 + Expires: time.Unix(0, 0), 125 + MaxAge: -1, 126 + Secure: true, 127 + HttpOnly: true, 128 + SameSite: http.SameSiteLaxMode, 129 + } 130 + }
+149 -13
portal.go
··· 1 1 package caddyatprotoauth 2 2 3 3 import ( 4 + "encoding/json" 4 5 "fmt" 5 6 "net/http" 7 + "time" 6 8 7 9 "github.com/caddyserver/caddy/v2" 8 10 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" 9 11 "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" 10 12 "github.com/caddyserver/caddy/v2/modules/caddyhttp" 13 + "github.com/vvill/caddy-atproto-auth/internal/oauth" 14 + "github.com/vvill/caddy-atproto-auth/internal/session" 15 + "go.uber.org/zap" 11 16 ) 12 17 13 18 func init() { ··· 17 22 18 23 // Portal is the centralized authentication portal for Path B (Auth Hub). 19 24 type Portal struct { 20 - Name string `json:"name,omitempty"` 25 + Name string `json:"name,omitempty"` 26 + Domain string `json:"domain,omitempty"` // Public domain of the portal (e.g. auth.example.com) 27 + 28 + // Dependencies 29 + app *App 30 + oauth *oauth.Manager 31 + sessions *session.Manager 32 + logger *zap.Logger 21 33 } 22 34 23 35 // CaddyModule returns the Caddy module information. ··· 30 42 31 43 // Provision sets up the module. 32 44 func (p *Portal) Provision(ctx caddy.Context) error { 45 + p.logger = ctx.Logger() 46 + 47 + // 1. Get Global App 48 + app, err := ctx.App("atproto") 49 + if err != nil { 50 + return fmt.Errorf("getting atproto app: %w", err) 51 + } 52 + p.app = app.(*App) 53 + 54 + // 2. Initialize Session Manager 55 + if p.app.CookieSecret == "" { 56 + return fmt.Errorf("global atproto cookie_secret is required") 57 + } 58 + p.sessions = session.NewManager(p.app.CookieSecret) 59 + 60 + // 3. Initialize OAuth Manager 61 + // We need the domain to construct ClientID and CallbackURL. 62 + // If domain is missing, we might defer initialization? No, Manager needs it. 63 + // User must configure 'domain' in Caddyfile for now. 64 + if p.Domain == "" { 65 + return fmt.Errorf("atproto_portal requires 'domain' to be set (e.g. auth.example.com)") 66 + } 67 + 68 + clientID := fmt.Sprintf("https://%s/.well-known/oauth-client-metadata.json", p.Domain) 69 + callbackURL := fmt.Sprintf("https://%s/callback", p.Domain) 70 + 71 + mgr, err := oauth.NewManager(p.app.Store, clientID, callbackURL) 72 + if err != nil { 73 + return fmt.Errorf("failed to init oauth manager: %w", err) 74 + } 75 + p.oauth = mgr 76 + 33 77 return nil 34 78 } 35 79 36 80 // Validate checks that the configuration is valid. 37 81 func (p *Portal) Validate() error { 38 82 if p.Name == "" { 39 - p.Name = "Authentication Portal" // Provide a default 83 + p.Name = "Authentication Portal" 40 84 } 41 85 return nil 42 86 } ··· 51 95 return d.ArgErr() 52 96 } 53 97 p.Name = d.Val() 98 + case "domain": 99 + if !d.NextArg() { 100 + return d.ArgErr() 101 + } 102 + p.Domain = d.Val() 54 103 default: 55 104 return d.Errf("unrecognized subdirective '%s'", d.Val()) 56 105 } ··· 67 116 68 117 // ServeHTTP implements caddyhttp.MiddlewareHandler. 69 118 func (p *Portal) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error { 70 - // Central Auth Hub Logic: 71 - // - Serves /.well-known/oauth-client-metadata.json 72 - // - Serves /callback from PDS 73 - // - Serves a login page with the configured 'Name' if not authenticated 74 - // - Initiates PDS resolution and PAR redirect 75 - 76 - // Example naive bypass for now: 77 - w.WriteHeader(http.StatusOK) 78 - w.Write([]byte(fmt.Sprintf("Welcome to %s", p.Name))) 79 - 80 - return nil 119 + // 1. Metadata Endpoint 120 + if r.URL.Path == "/.well-known/oauth-client-metadata.json" { 121 + meta, err := p.oauth.GetClientMetadata() 122 + if err != nil { 123 + p.logger.Error("failed to get client metadata", zap.Error(err)) 124 + http.Error(w, "Internal Server Error", http.StatusInternalServerError) 125 + return nil 126 + } 127 + w.Header().Set("Content-Type", "application/json") 128 + json.NewEncoder(w).Encode(meta) 129 + return nil 130 + } 131 + 132 + // 2. Callback Endpoint 133 + if r.URL.Path == "/callback" { 134 + // Process callback 135 + ctx := r.Context() 136 + query := r.URL.Query() 137 + 138 + sessionData, err := p.oauth.ProcessCallback(ctx, query) 139 + if err != nil { 140 + p.logger.Error("oauth callback failed", zap.Error(err)) 141 + http.Error(w, fmt.Sprintf("Authentication failed: %v", err), http.StatusBadRequest) 142 + return nil 143 + } 144 + 145 + // Create Session Cookie 146 + // Use root domain for Auth Hub? Or specific? 147 + // For now, use the portal's domain. 148 + cookie, err := p.sessions.CreateCookie( 149 + sessionData.AccountDID, 150 + "user", // We don't have handle in ClientSessionData yet? Indigo might resolve it. 151 + // Actually ClientSessionData only has AccountDID. 152 + // We might need to resolve handle separately or store it in state? 153 + // For now, placeholder handle. 154 + 24*7*time.Hour, 155 + p.Domain, 156 + ) 157 + if err != nil { 158 + p.logger.Error("failed to create session cookie", zap.Error(err)) 159 + http.Error(w, "Internal Error", http.StatusInternalServerError) 160 + return nil 161 + } 162 + 163 + http.SetCookie(w, cookie) 164 + 165 + // Redirect to home or saved location 166 + http.Redirect(w, r, "/", http.StatusFound) 167 + return nil 168 + } 169 + 170 + // 3. Login Start (Form Action) 171 + if r.URL.Path == "/login" && r.Method == "POST" { 172 + handle := r.FormValue("handle") 173 + if handle == "" { 174 + http.Error(w, "Handle required", http.StatusBadRequest) 175 + return nil 176 + } 177 + 178 + // Start Auth Flow 179 + redirectURI, err := p.oauth.StartAuthFlow(r.Context(), handle) 180 + if err != nil { 181 + p.logger.Error("failed to start auth flow", zap.Error(err)) 182 + http.Error(w, fmt.Sprintf("Failed to resolve identity: %v", err), http.StatusBadRequest) 183 + return nil 184 + } 185 + 186 + http.Redirect(w, r, redirectURI, http.StatusFound) 187 + return nil 188 + } 189 + 190 + // 4. Default: Login Page 191 + if r.URL.Path == "/" || r.URL.Path == "/login" { 192 + w.Header().Set("Content-Type", "text/html") 193 + fmt.Fprintf(w, ` 194 + <html> 195 + <body> 196 + <h1>%s</h1> 197 + <form action="/login" method="POST"> 198 + <label>Bluesky Handle:</label> 199 + <input type="text" name="handle" placeholder="@user.bsky.social" required> 200 + <button type="submit">Log In</button> 201 + </form> 202 + </body> 203 + </html> 204 + `, p.Name) 205 + return nil 206 + } 207 + 208 + return next.ServeHTTP(w, r) 81 209 } 210 + 211 + // Interface guards 212 + var ( 213 + _ caddy.Provisioner = (*Portal)(nil) 214 + _ caddy.Validator = (*Portal)(nil) 215 + _ caddyhttp.MiddlewareHandler = (*Portal)(nil) 216 + _ caddyfile.Unmarshaler = (*Portal)(nil) 217 + )