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.

at main 5.7 kB View raw
1package oauth 2 3import ( 4 "context" 5 "fmt" 6 "net" 7 "net/http" 8 "net/url" 9 "strings" 10 "time" 11 12 "github.com/bluesky-social/indigo/atproto/atcrypto" 13 indigoOauth "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 "github.com/bluesky-social/indigo/atproto/identity" 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 17 "tangled.org/vvill.dev/caddy-atproto-auth/internal/db" 18) 19 20// Manager wraps the bluesky oauth client app to handle the lifecycle. 21type Manager struct { 22 App *indigoOauth.ClientApp 23 pk atcrypto.PrivateKey 24 kid string 25} 26 27// NewManager initializes the OAuth manager with a custom HTTP client 28// that allows connections to the specified private CIDRs. 29func NewManager(store *db.Store, clientID, callbackURL string, allowedCIDRs []net.IPNet) (*Manager, error) { 30 pk, kid, err := store.GetClientKey(context.Background()) 31 if err != nil { 32 return nil, fmt.Errorf("failed to get client key: %w", err) 33 } 34 35 config := &indigoOauth.ClientConfig{ 36 ClientID: clientID, 37 CallbackURL: callbackURL, 38 Scopes: []string{"atproto"}, 39 UserAgent: "caddy-atproto-auth/1.0", 40 PrivateKey: pk, 41 KeyID: &kid, 42 } 43 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 } 56 57 return &Manager{ 58 App: app, 59 pk: pk, 60 kid: kid, 61 }, nil 62} 63 64func 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 112func 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) 121} 122 123// GetClientMetadata builds the client metadata to serve at /.well-known/oauth-client-metadata.json 124func (m *Manager) GetClientMetadata() (*indigoOauth.ClientMetadata, error) { 125 pub, err := m.pk.PublicKey() 126 if err != nil { 127 return nil, fmt.Errorf("failed to extract public key: %w", err) 128 } 129 130 jwk, err := pub.JWK() 131 if err != nil { 132 return nil, fmt.Errorf("failed to extract JWK: %w", err) 133 } 134 jwk.KeyID = &m.kid 135 136 appType := "web" 137 alg := "ES256" 138 139 meta := &indigoOauth.ClientMetadata{ 140 ClientID: m.App.Config.ClientID, 141 ApplicationType: &appType, 142 GrantTypes: []string{"authorization_code", "refresh_token"}, 143 Scope: strings.Join(m.App.Config.Scopes, " "), 144 ResponseTypes: []string{"code"}, 145 RedirectURIs: []string{m.App.Config.CallbackURL}, 146 TokenEndpointAuthMethod: "private_key_jwt", 147 TokenEndpointAuthSigningAlg: &alg, 148 DPoPBoundAccessTokens: true, 149 JWKS: &indigoOauth.JWKS{ 150 Keys: []atcrypto.JWK{*jwk}, 151 }, 152 } 153 154 return meta, nil 155} 156 157// StartAuthFlow begins the OAuth flow for a given identifier (handle or DID) 158func (m *Manager) StartAuthFlow(ctx context.Context, identifier string) (string, error) { 159 redirectURI, err := m.App.StartAuthFlow(ctx, identifier) 160 if err != nil { 161 return "", fmt.Errorf("failed to start auth flow: %w", err) 162 } 163 return redirectURI, nil 164} 165 166// ResumeSession attempts to load and refresh a session for the given DID and sessionID 167func (m *Manager) ResumeSession(ctx context.Context, didStr string, sessionID string) (*indigoOauth.ClientSession, error) { 168 did, err := syntax.ParseDID(didStr) 169 if err != nil { 170 return nil, fmt.Errorf("invalid DID: %w", err) 171 } 172 173 return m.App.ResumeSession(ctx, did, sessionID) 174} 175 176// Logout revokes the session for the given DID 177func (m *Manager) Logout(ctx context.Context, didStr string, sessionID string) error { 178 did, err := syntax.ParseDID(didStr) 179 if err != nil { 180 return fmt.Errorf("invalid DID: %w", err) 181 } 182 183 return m.App.Logout(ctx, did, sessionID) 184} 185 186// ProcessCallback exchanges the authorization code for an access token 187func (m *Manager) ProcessCallback(ctx context.Context, query url.Values) (*indigoOauth.ClientSessionData, string, error) { 188 sess, err := m.App.ProcessCallback(ctx, query) 189 if err != nil { 190 return nil, "", fmt.Errorf("failed to process callback: %w", err) 191 } 192 193 // Resolve the handle from the DID 194 ident, err := m.App.Dir.LookupDID(ctx, sess.AccountDID) 195 handle := "" 196 if err == nil && ident != nil { 197 handle = ident.Handle.String() 198 } 199 200 return sess, handle, nil 201}