···11package caddyatprotoauth
2233import (
44+ "encoding/json"
45 "fmt"
56 "net/http"
77+ "time"
6879 "github.com/caddyserver/caddy/v2"
810 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
911 "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
1012 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
1313+ "github.com/vvill/caddy-atproto-auth/internal/oauth"
1414+ "github.com/vvill/caddy-atproto-auth/internal/resolver"
1515+ "github.com/vvill/caddy-atproto-auth/internal/session"
1116)
12171318func init() {
···1823// Gate acts as a middleware that guards endpoints
1924// and validates the session cookie.
2025type Gate struct {
2121- Allow []string `json:"allow,omitempty"`
2626+ Allow []string `json:"allow,omitempty"`
2727+ Domain string `json:"domain,omitempty"` // Public domain for standalone mode (e.g. app.example.com)
2828+2929+ // Dependencies
3030+ app *App
3131+ resolver *resolver.Resolver
3232+ sessions *session.Manager
3333+ oauth *oauth.Manager
2234}
23352436// CaddyModule returns the Caddy module information.
···31433244// Provision sets up the module.
3345func (g *Gate) Provision(ctx caddy.Context) error {
3434- // Initialize identities and cache resolved handles here.
4646+ // 1. Get Global App
4747+ app, err := ctx.App("atproto")
4848+ if err != nil {
4949+ return fmt.Errorf("getting atproto app: %w", err)
5050+ }
5151+ g.app = app.(*App)
5252+5353+ // 2. Initialize Session Manager (using global secret)
5454+ if g.app.CookieSecret == "" {
5555+ return fmt.Errorf("global atproto cookie_secret is required")
5656+ }
5757+ g.sessions = session.NewManager(g.app.CookieSecret)
5858+5959+ // 3. Initialize Identity Resolver
6060+ g.resolver = resolver.New()
6161+6262+ // 4. Initialize OAuth Manager (if domain set for standalone mode)
6363+ if g.Domain != "" {
6464+ clientID := fmt.Sprintf("https://%s/.well-known/oauth-client-metadata.json", g.Domain)
6565+ callbackURL := fmt.Sprintf("https://%s/callback", g.Domain)
6666+6767+ mgr, err := oauth.NewManager(g.app.Store, clientID, callbackURL)
6868+ if err != nil {
6969+ return fmt.Errorf("failed to init oauth manager: %w", err)
7070+ }
7171+ g.oauth = mgr
7272+ }
7373+3574 return nil
3675}
3776···5089 switch d.Val() {
5190 case "allow":
5291 g.Allow = append(g.Allow, d.RemainingArgs()...)
9292+ case "domain":
9393+ if !d.NextArg() {
9494+ return d.ArgErr()
9595+ }
9696+ g.Domain = d.Val()
5397 default:
5498 return d.Errf("unrecognized subdirective '%s'", d.Val())
5599 }
···6711168112// ServeHTTP implements caddyhttp.MiddlewareHandler.
69113func (g *Gate) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
114114+ // If in Standalone Mode, handle OAuth paths
115115+ if g.oauth != nil {
116116+ if r.URL.Path == "/.well-known/oauth-client-metadata.json" {
117117+ meta, err := g.oauth.GetClientMetadata()
118118+ if err != nil {
119119+ return caddyhttp.Error(http.StatusInternalServerError, err)
120120+ }
121121+ w.Header().Set("Content-Type", "application/json")
122122+ return json.NewEncoder(w).Encode(meta)
123123+ }
124124+ if r.URL.Path == "/callback" {
125125+ // Process callback
126126+ sessionData, err := g.oauth.ProcessCallback(r.Context(), r.URL.Query())
127127+ if err != nil {
128128+ return caddyhttp.Error(http.StatusBadRequest, err)
129129+ }
130130+131131+ // Create Session Cookie
132132+ cookie, err := g.sessions.CreateCookie(
133133+ sessionData.AccountDID,
134134+ "user", // Placeholder handle
135135+ 24*7*time.Hour,
136136+ g.Domain,
137137+ )
138138+ if err != nil {
139139+ return caddyhttp.Error(http.StatusInternalServerError, err)
140140+ }
141141+142142+ http.SetCookie(w, cookie)
143143+ http.Redirect(w, r, "/", http.StatusFound)
144144+ return nil
145145+ }
146146+ }
147147+70148 // 1. Verify stateless cookie here
149149+ sess, err := g.sessions.VerifyCookie(r)
150150+ if err == nil {
151151+ // Session valid!
152152+ // Check authorization against allowlist
153153+ allowed := false
154154+ for _, allow := range g.Allow {
155155+ if allow == sess.DID || allow == sess.Handle {
156156+ allowed = true
157157+ break
158158+ }
159159+ }
160160+161161+ if allowed {
162162+ // Inject headers
163163+ r.Header.Set("X-Atproto-Did", sess.DID)
164164+ r.Header.Set("X-Atproto-Handle", sess.Handle)
165165+ return next.ServeHTTP(w, r)
166166+ }
167167+168168+ // Authenticated but not authorized
169169+ return caddyhttp.Error(http.StatusForbidden, fmt.Errorf("user not authorized"))
170170+ }
171171+71172 // 2. If invalid/missing, initiate redirect to PDS or Auth Hub
7272- // 3. If valid, set headers (X-Atproto-Did) and proceed
7373-7474- // Example: inject header for downstream (to be implemented correctly)
7575- // r.Header.Set("X-Atproto-Did", "did:plc:xxx")
173173+ // If standalone mode (g.oauth != nil), we can initiate flow here?
174174+ // But which identity? We need to prompt user for handle.
175175+ // So we should redirect to a login page or show a simple form.
176176+ // For simplicity, let's just 401 and tell user to go to /login (which we handle if standalone?)
177177+ // Wait, we didn't add /login handler to Gate yet.
178178+ // If standalone, Gate should act as Portal.
179179+ // Let's implement a simple /login handler in Gate if oauth is enabled.
180180+181181+ if g.oauth != nil {
182182+ if r.URL.Path == "/login" {
183183+ if r.Method == "POST" {
184184+ handle := r.FormValue("handle")
185185+ redirectURI, err := g.oauth.StartAuthFlow(r.Context(), handle)
186186+ if err != nil {
187187+ return caddyhttp.Error(http.StatusBadRequest, err)
188188+ }
189189+ http.Redirect(w, r, redirectURI, http.StatusFound)
190190+ return nil
191191+ }
192192+ // Show login form
193193+ w.Header().Set("Content-Type", "text/html")
194194+ fmt.Fprintf(w, `
195195+ <html><body>
196196+ <h1>Login</h1>
197197+ <form method="POST" action="/login">
198198+ <input name="handle" placeholder="@user.bsky.social" required>
199199+ <button>Login</button>
200200+ </form>
201201+ </body></html>
202202+ `)
203203+ return nil
204204+ }
205205+ // Redirect to /login
206206+ http.Redirect(w, r, "/login", http.StatusFound)
207207+ return nil
208208+ }
762097777- return next.ServeHTTP(w, r)
210210+ // If NOT standalone (Auth Hub mode), we should redirect to the central Auth Portal.
211211+ // We don't know where it is unless configured.
212212+ // For now, return 401.
213213+ return caddyhttp.Error(http.StatusUnauthorized, fmt.Errorf("unauthorized"))
78214}
215215+216216+// Interface guards
217217+var (
218218+ _ caddy.Provisioner = (*Gate)(nil)
219219+ _ caddy.Validator = (*Gate)(nil)
220220+ _ caddyhttp.MiddlewareHandler = (*Gate)(nil)
221221+ _ caddyfile.Unmarshaler = (*Gate)(nil)
222222+)
+91-1
global.go
···11package caddyatprotoauth
2233import (
44+ "fmt"
55+46 "github.com/caddyserver/caddy/v2"
77+ "github.com/caddyserver/caddy/v2/caddyconfig"
88+ "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
99+ "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
1010+1111+ "github.com/vvill/caddy-atproto-auth/internal/db"
1212+ "github.com/vvill/caddy-atproto-auth/internal/oauth"
513)
614715func init() {
816 caddy.RegisterModule(App{})
1717+ httpcaddyfile.RegisterGlobalOption("atproto", parseGlobalAtproto)
918}
10191120// App configures the global atproto integration.
1221type App struct {
1322 StoragePath string `json:"storage_path,omitempty"`
1423 CookieSecret string `json:"cookie_secret,omitempty"`
2424+2525+ // Internal state
2626+ Store *db.Store `json:"-"`
2727+ OAuthManager *oauth.Manager `json:"-"`
1528}
16291730// CaddyModule returns the Caddy module information.
···2235 }
2336}
24373838+// Provision sets up the global app state.
3939+func (a *App) Provision(ctx caddy.Context) error {
4040+ // Defaults
4141+ if a.StoragePath == "" {
4242+ a.StoragePath = "atproto.db" // Relative to workdir or specific path
4343+ }
4444+ // Resolve relative path against Caddy's storage or workdir if needed.
4545+ // For simplicity, we assume absolute or relative to CWD.
4646+4747+ // Initialize DB
4848+ store, err := db.NewStore(a.StoragePath)
4949+ if err != nil {
5050+ return fmt.Errorf("failed to initialize atproto storage: %w", err)
5151+ }
5252+ a.Store = store
5353+5454+ // Initialize OAuth Manager (requires client ID and callback URL to be fully configured,
5555+ // but those might be per-portal or global. The spec says "acts as an OAuth Client".
5656+ // If the plugin acts as a *single* client for many subdomains, we need global config for client ID.
5757+ // But spec says: "Path A: The Self-Contained Route" and "Path B: The Auth Hub".
5858+ // This implies potentially different client IDs for different sites OR one central hub.
5959+ // For now, let's defer OAuthManager creation to the Portal or Gate if it's per-route,
6060+ // OR we need to add ClientID/CallbackURL to the global config if it's shared.
6161+ //
6262+ // Looking at the spec:
6363+ // "The module acts as an OAuth Client"
6464+ // "Global Configuration: storage_path, cookie_secret"
6565+ //
6666+ // It seems the App module holds the *Storage* and *Keys*.
6767+ // The *Portal* (or Gate) defines the "Client" identity (metadata, callback).
6868+ // However, `oauth.NewManager` takes a `db.Store`. So the App owns the Store.
6969+ // The Portal will instantiate the Manager using the App's Store.
7070+7171+ return nil
7272+}
7373+2574// Start starts the application.
2675func (a *App) Start() error {
2727- // Initialize global database and settings here
2876 return nil
2977}
30783179// Stop stops the application.
3280func (a *App) Stop() error {
8181+ if a.Store != nil {
8282+ return a.Store.Close()
8383+ }
3384 return nil
3485}
8686+8787+// Interface guards
8888+var (
8989+ _ caddy.App = (*App)(nil)
9090+ _ caddy.Provisioner = (*App)(nil)
9191+)
9292+9393+// parseGlobalAtproto parses the global 'atproto' Caddyfile option.
9494+// Format:
9595+//
9696+// atproto {
9797+// storage_path /path/to/db
9898+// cookie_secret <secret>
9999+// }
100100+func parseGlobalAtproto(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
101101+ app := &App{}
102102+ for d.Next() {
103103+ for d.NextBlock(0) {
104104+ switch d.Val() {
105105+ case "storage_path":
106106+ if !d.NextArg() {
107107+ return nil, d.ArgErr()
108108+ }
109109+ app.StoragePath = d.Val()
110110+ case "cookie_secret":
111111+ if !d.NextArg() {
112112+ return nil, d.ArgErr()
113113+ }
114114+ app.CookieSecret = d.Val()
115115+ default:
116116+ return nil, d.Errf("unrecognized subdirective '%s'", d.Val())
117117+ }
118118+ }
119119+ }
120120+ return httpcaddyfile.App{
121121+ Name: "atproto",
122122+ Value: caddyconfig.JSON(app, nil),
123123+ }, nil
124124+}