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

Configure Feed

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

config to allow PDSs on local network

+185 -10
+5
README.md
··· 70 70 { 71 71 # Optional 72 72 atproto { 73 + # Allow PDSs to resolve to local IPs (within provided CIDR ranges) 74 + # You'll need this if you run your own PDS on this computer or network. 75 + # Default: None 76 + allow_private_cidrs 127.0.0.1/8 ::1/128 192.168.0.0/16 77 + 73 78 # Path to the SQLite database. 74 79 # Default: atproto.db 75 80 storage_path /var/lib/caddy/atproto.db
+7 -3
gate.go
··· 84 84 parsedURL, err := url.Parse(g.PortalURL) 85 85 if err == nil { 86 86 clientID := fmt.Sprintf("%s://%s/.well-known/oauth-client-metadata.json", parsedURL.Scheme, parsedURL.Host) 87 - mgr, err := oauth.NewManager(g.app.Store, clientID, "") 87 + mgr, err := oauth.NewManager(g.app.Store, clientID, "", g.app.AllowPrivateCIDRs) 88 88 if err != nil { 89 89 return fmt.Errorf("failed to init oauth manager for refresh: %w", err) 90 90 } ··· 93 93 } 94 94 95 95 // Pre-resolve allowed handles to DIDs 96 - g.resolver = resolver.New() 96 + if len(g.app.AllowPrivateCIDRs) > 0 { 97 + g.resolver = resolver.NewWithAllowedCIDRs(g.app.AllowPrivateCIDRs) 98 + } else { 99 + g.resolver = resolver.New() 100 + } 97 101 98 102 g.resolvedDIDs = make([]string, 0, len(g.Allow)) 99 103 ctxResolver := context.Background() // Use background context for boot-time resolution ··· 210 214 scheme := getRequestScheme(r) 211 215 212 216 clientID := fmt.Sprintf("%s://%s/.well-known/oauth-client-metadata.json", scheme, host) 213 - mgr, err := oauth.NewManager(g.app.Store, clientID, "") 217 + mgr, err := oauth.NewManager(g.app.Store, clientID, "", g.app.AllowPrivateCIDRs) 214 218 if err != nil { 215 219 return nil, err 216 220 }
+14 -4
global.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "net" 5 6 "path/filepath" 6 7 "strconv" 7 8 "time" ··· 21 22 22 23 // App configures the global atproto integration. 23 24 type App struct { 24 - StoragePath string `json:"storage_path,omitempty"` 25 - CookieSecret string `json:"cookie_secret,omitempty"` 26 - SessionDurationStr string `json:"session_duration,omitempty"` 27 - OAuthManagerCacheSize int `json:"oauth_manager_cache_size,omitempty"` 25 + StoragePath string `json:"storage_path,omitempty"` 26 + CookieSecret string `json:"cookie_secret,omitempty"` 27 + SessionDurationStr string `json:"session_duration,omitempty"` 28 + OAuthManagerCacheSize int `json:"oauth_manager_cache_size,omitempty"` 29 + AllowPrivateCIDRs []net.IPNet `json:"allow_private_cidrs,omitempty"` 28 30 29 31 // Internal state 30 32 Store *db.Store `json:"-"` ··· 135 137 return nil, d.Errf("invalid oauth_manager_cache_size: %v", err) 136 138 } 137 139 app.OAuthManagerCacheSize = val 140 + case "allow_private_cidrs": 141 + for _, c := range d.RemainingArgs() { 142 + _, ipnet, err := net.ParseCIDR(c) 143 + if err != nil || ipnet == nil { 144 + return nil, d.ArgErr() 145 + } 146 + app.AllowPrivateCIDRs = append(app.AllowPrivateCIDRs, *ipnet) 147 + } 138 148 default: 139 149 return nil, d.Errf("unrecognized subdirective '%s'", d.Val()) 140 150 }
+77 -2
internal/oauth/manager.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "net" 7 + "net/http" 6 8 "net/url" 7 9 "strings" 10 + "time" 8 11 9 12 "github.com/bluesky-social/indigo/atproto/atcrypto" 10 13 indigoOauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 + "github.com/bluesky-social/indigo/atproto/identity" 11 15 "github.com/bluesky-social/indigo/atproto/syntax" 12 16 13 17 "tangled.org/vvill.dev/caddy-atproto-auth/internal/db" ··· 20 24 kid string 21 25 } 22 26 23 - // NewManager initializes the OAuth manager. 24 - func NewManager(store *db.Store, clientID, callbackURL string) (*Manager, error) { 27 + // NewManager initializes the OAuth manager with a custom HTTP client 28 + // that allows connections to the specified private CIDRs. 29 + func NewManager(store *db.Store, clientID, callbackURL string, allowedCIDRs []net.IPNet) (*Manager, error) { 25 30 pk, kid, err := store.GetClientKey(context.Background()) 26 31 if err != nil { 27 32 return nil, fmt.Errorf("failed to get client key: %w", err) ··· 37 42 } 38 43 39 44 app := indigoOauth.NewClientApp(config, store) 45 + 46 + if len(allowedCIDRs) > 0 { 47 + httpClient := createCustomHTTPClient(allowedCIDRs) 48 + app.Client = &httpClient 49 + app.Resolver.Client = &httpClient 50 + 51 + base := identity.BaseDirectory{ 52 + HTTPClient: httpClient, 53 + } 54 + app.Dir = &base 55 + } 40 56 41 57 return &Manager{ 42 58 App: app, 43 59 pk: pk, 44 60 kid: kid, 45 61 }, nil 62 + } 63 + 64 + func createCustomHTTPClient(allowedCIDRs []net.IPNet) http.Client { 65 + transport := http.DefaultTransport.(*http.Transport).Clone() 66 + originalDial := transport.DialContext 67 + 68 + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { 69 + host, _, err := net.SplitHostPort(addr) 70 + if err != nil { 71 + host = addr 72 + } 73 + 74 + ip := net.ParseIP(host) 75 + if ip == nil { 76 + addrs, err := net.DefaultResolver.LookupIP(ctx, "ip", host) 77 + if err != nil { 78 + return nil, fmt.Errorf("failed to resolve %s: %w", host, err) 79 + } 80 + for _, a := range addrs { 81 + if isPublicIP(a) { 82 + return originalDial(ctx, network, addr) 83 + } 84 + for _, cidr := range allowedCIDRs { 85 + if cidr.Contains(a) { 86 + return originalDial(ctx, network, addr) 87 + } 88 + } 89 + } 90 + return nil, fmt.Errorf("resolved IP for %s is not public and not in allowed private CIDRs", host) 91 + } 92 + 93 + if isPublicIP(ip) { 94 + return originalDial(ctx, network, addr) 95 + } 96 + 97 + for _, cidr := range allowedCIDRs { 98 + if cidr.Contains(ip) { 99 + return originalDial(ctx, network, addr) 100 + } 101 + } 102 + 103 + return nil, fmt.Errorf("address %s is not public and not in allowed private CIDRs", addr) 104 + } 105 + 106 + return http.Client{ 107 + Transport: transport, 108 + Timeout: 10 * time.Second, 109 + } 110 + } 111 + 112 + func isPublicIP(ip net.IP) bool { 113 + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() { 114 + return false 115 + } 116 + if ip4 := ip.To4(); ip4 != nil { 117 + return (ip4[0] != 10) && (ip4[0] != 172 || ip4[1] < 16 || ip4[1] > 31) && (ip4[0] != 192 || ip4[1] != 168) 118 + } 119 + _, ipv6private, _ := net.ParseCIDR("fc00::/7") 120 + return ipv6private == nil || !ipv6private.Contains(ip) 46 121 } 47 122 48 123 // GetClientMetadata builds the client metadata to serve at /.well-known/oauth-client-metadata.json
+80
internal/resolver/resolver.go
··· 3 3 import ( 4 4 "context" 5 5 "fmt" 6 + "net" 7 + "net/http" 6 8 "strings" 9 + "time" 7 10 8 11 "github.com/bluesky-social/indigo/atproto/identity" 9 12 "github.com/bluesky-social/indigo/atproto/syntax" ··· 20 23 return &Resolver{ 21 24 dir: identity.DefaultDirectory(), 22 25 } 26 + } 27 + 28 + // NewWithAllowedCIDRs initializes a new identity resolver with a custom HTTP client 29 + // that allows connections to the specified private CIDRs. 30 + func NewWithAllowedCIDRs(allowedCIDRs []net.IPNet) *Resolver { 31 + base := identity.BaseDirectory{ 32 + HTTPClient: createCustomHTTPClient(allowedCIDRs), 33 + } 34 + 35 + cached := identity.NewCacheDirectory(&base, 250000, time.Hour*24, time.Minute*2, time.Minute*5) 36 + 37 + return &Resolver{ 38 + dir: cached, 39 + } 40 + } 41 + 42 + func createCustomHTTPClient(allowedCIDRs []net.IPNet) http.Client { 43 + if len(allowedCIDRs) == 0 { 44 + return http.Client{} 45 + } 46 + 47 + transport := http.DefaultTransport.(*http.Transport).Clone() 48 + originalDial := transport.DialContext 49 + 50 + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { 51 + host, _, err := net.SplitHostPort(addr) 52 + if err != nil { 53 + host = addr 54 + } 55 + 56 + ip := net.ParseIP(host) 57 + if ip == nil { 58 + addrs, err := net.DefaultResolver.LookupIP(ctx, "ip", host) 59 + if err != nil { 60 + return nil, fmt.Errorf("failed to resolve %s: %w", host, err) 61 + } 62 + for _, a := range addrs { 63 + if isPublicIP(a) { 64 + return originalDial(ctx, network, addr) 65 + } 66 + for _, cidr := range allowedCIDRs { 67 + if cidr.Contains(a) { 68 + return originalDial(ctx, network, addr) 69 + } 70 + } 71 + } 72 + return nil, fmt.Errorf("resolved IP for %s is not public and not in allowed private CIDRs", host) 73 + } 74 + 75 + if isPublicIP(ip) { 76 + return originalDial(ctx, network, addr) 77 + } 78 + 79 + for _, cidr := range allowedCIDRs { 80 + if cidr.Contains(ip) { 81 + return originalDial(ctx, network, addr) 82 + } 83 + } 84 + 85 + return nil, fmt.Errorf("address %s is not public and not in allowed private CIDRs", addr) 86 + } 87 + 88 + return http.Client{ 89 + Transport: transport, 90 + Timeout: 10 * time.Second, 91 + } 92 + } 93 + 94 + func isPublicIP(ip net.IP) bool { 95 + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsUnspecified() { 96 + return false 97 + } 98 + if ip4 := ip.To4(); ip4 != nil { 99 + return (ip4[0] != 10) && (ip4[0] != 172 || ip4[1] < 16 || ip4[1] > 31) && (ip4[0] != 192 || ip4[1] != 168) 100 + } 101 + _, ipv6private, _ := net.ParseCIDR("fc00::/7") 102 + return ipv6private == nil || !ipv6private.Contains(ip) 23 103 } 24 104 25 105 // ResolveIdentifier converts either a handle (e.g., @user.com) or a DID into
+2 -1
portal.go
··· 122 122 clientID := fmt.Sprintf("%s://%s/.well-known/oauth-client-metadata.json", scheme, host) 123 123 callbackURL := fmt.Sprintf("%s://%s%s/callback", scheme, host, p.PathPrefix) 124 124 125 - mgr, err := oauth.NewManager(p.app.Store, clientID, callbackURL) 125 + mgr, err := oauth.NewManager(p.app.Store, clientID, callbackURL, p.app.AllowPrivateCIDRs) 126 126 if err != nil { 127 127 return nil, err 128 128 } ··· 335 335 // Start Auth Flow 336 336 redirectURI, err := oauthMgr.StartAuthFlow(r.Context(), handle) 337 337 if err != nil { 338 + p.logger.Error("failed to start auth flow", zap.Error(err), zap.String("handle", handle)) 338 339 // Render error on login page 339 340 p.setSecurityHeaders(w) 340 341 w.Header().Set("Content-Type", "text/html; charset=utf-8")