Caddy module to require at-proto authentication and restrict routes to DIDs
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}