A community based topic aggregation platform built on atproto
1package oauth
2
3import (
4 "encoding/base64"
5 "fmt"
6 "log/slog"
7 "net/url"
8 "time"
9
10 "github.com/bluesky-social/indigo/atproto/auth/oauth"
11 "github.com/bluesky-social/indigo/atproto/identity"
12)
13
14// OAuthClient wraps indigo's OAuth ClientApp with Coves-specific configuration
15type OAuthClient struct {
16 ClientApp *oauth.ClientApp
17 Config *OAuthConfig
18 SealSecret []byte // For sealing mobile tokens
19}
20
21// OAuthConfig holds Coves OAuth client configuration
22type OAuthConfig struct {
23 PublicURL string
24 SealSecret string
25 PLCURL string
26 PDSURL string // For dev mode: resolve handles via local PDS
27 Scopes []string
28 SessionTTL time.Duration
29 SealedTokenTTL time.Duration
30 DevMode bool
31 AllowPrivateIPs bool
32}
33
34// NewOAuthClient creates a new OAuth client for Coves
35func NewOAuthClient(config *OAuthConfig, store oauth.ClientAuthStore) (*OAuthClient, error) {
36 if config == nil {
37 return nil, fmt.Errorf("config is required")
38 }
39
40 // Validate seal secret
41 var sealSecret []byte
42 if config.SealSecret != "" {
43 decoded, err := base64.StdEncoding.DecodeString(config.SealSecret)
44 if err != nil {
45 return nil, fmt.Errorf("failed to decode seal secret: %w", err)
46 }
47 if len(decoded) != 32 {
48 return nil, fmt.Errorf("seal secret must be 32 bytes, got %d", len(decoded))
49 }
50 sealSecret = decoded
51 }
52
53 // Validate scopes
54 if len(config.Scopes) == 0 {
55 return nil, fmt.Errorf("scopes are required")
56 }
57 hasAtproto := false
58 for _, scope := range config.Scopes {
59 if scope == "atproto" {
60 hasAtproto = true
61 break
62 }
63 }
64 if !hasAtproto {
65 return nil, fmt.Errorf("scopes must include 'atproto'")
66 }
67
68 // Set default TTL values if not specified
69 // Per atproto OAuth spec:
70 // - Public clients: 2-week (14 day) maximum session lifetime
71 // - Confidential clients: 180-day maximum session lifetime
72 if config.SessionTTL == 0 {
73 config.SessionTTL = 7 * 24 * time.Hour // 7 days default
74 }
75 if config.SealedTokenTTL == 0 {
76 config.SealedTokenTTL = 14 * 24 * time.Hour // 14 days (public client limit)
77 }
78
79 // Create indigo client config
80 var clientConfig oauth.ClientConfig
81 if config.DevMode {
82 // Dev mode: loopback with HTTP
83 // IMPORTANT: Use 127.0.0.1 instead of localhost per RFC 8252 - PDS rejects localhost
84 // The callback URL must match the APPVIEW_PUBLIC_URL from .env.dev
85 callbackURL := config.PublicURL + "/oauth/callback"
86 clientConfig = oauth.NewLocalhostConfig(callbackURL, config.Scopes)
87 slog.Info("dev mode: OAuth client configured",
88 "callback_url", callbackURL,
89 "client_id", clientConfig.ClientID)
90 } else {
91 // Production mode: public OAuth client with HTTPS
92 // client_id must be the URL of the client metadata document per atproto OAuth spec
93 clientID := config.PublicURL + "/oauth/client-metadata.json"
94 callbackURL := config.PublicURL + "/oauth/callback"
95 clientConfig = oauth.NewPublicConfig(clientID, callbackURL, config.Scopes)
96 }
97
98 // Set user agent
99 clientConfig.UserAgent = "Coves/1.0"
100
101 // Create the indigo OAuth ClientApp
102 clientApp := oauth.NewClientApp(&clientConfig, store)
103
104 // Override the default HTTP client with our SSRF-safe client
105 // This protects against SSRF attacks via malicious PDS URLs, DID documents, and JWKS URIs
106 clientApp.Client = NewSSRFSafeHTTPClient(config.AllowPrivateIPs)
107
108 // Override the directory if a custom PLC URL is configured
109 // This is necessary for local development with a local PLC directory
110 if config.PLCURL != "" {
111 // Use SSRF-safe HTTP client for PLC directory requests
112 httpClient := NewSSRFSafeHTTPClient(config.AllowPrivateIPs)
113 baseDir := &identity.BaseDirectory{
114 PLCURL: config.PLCURL,
115 HTTPClient: *httpClient,
116 UserAgent: "Coves/1.0",
117 }
118 // Wrap in cache directory for better performance
119 // Use pointer since CacheDirectory methods have pointer receivers
120 cacheDir := identity.NewCacheDirectory(baseDir, 100_000, time.Hour*24, time.Minute*2, time.Minute*5)
121 clientApp.Dir = &cacheDir
122 // Log the PLC URL being used for OAuth directory resolution
123 fmt.Printf("🔐 OAuth client directory configured with PLC URL: %s (AllowPrivateIPs: %v)\n", config.PLCURL, config.AllowPrivateIPs)
124 } else {
125 fmt.Println("⚠️ OAuth client using DEFAULT PLC directory (production plc.directory)")
126 }
127
128 return &OAuthClient{
129 ClientApp: clientApp,
130 Config: config,
131 SealSecret: sealSecret,
132 }, nil
133}
134
135// ClientMetadata returns the OAuth client metadata document
136func (c *OAuthClient) ClientMetadata() oauth.ClientMetadata {
137 metadata := c.ClientApp.Config.ClientMetadata()
138
139 // Add additional metadata for Coves
140 metadata.ClientName = strPtr("Coves")
141 if !c.Config.DevMode {
142 metadata.ClientURI = strPtr(c.Config.PublicURL)
143 }
144
145 return metadata
146}
147
148// strPtr is a helper to get a pointer to a string
149func strPtr(s string) *string {
150 return &s
151}
152
153// ValidateCallbackURL validates that a callback URL matches the expected callback URL
154func (c *OAuthClient) ValidateCallbackURL(callbackURL string) error {
155 expectedCallback := c.ClientApp.Config.CallbackURL
156
157 // Parse both URLs
158 expected, err := url.Parse(expectedCallback)
159 if err != nil {
160 return fmt.Errorf("invalid expected callback URL: %w", err)
161 }
162
163 actual, err := url.Parse(callbackURL)
164 if err != nil {
165 return fmt.Errorf("invalid callback URL: %w", err)
166 }
167
168 // Compare scheme, host, and path (ignore query params)
169 if expected.Scheme != actual.Scheme {
170 return fmt.Errorf("callback URL scheme mismatch: expected %s, got %s", expected.Scheme, actual.Scheme)
171 }
172 if expected.Host != actual.Host {
173 return fmt.Errorf("callback URL host mismatch: expected %s, got %s", expected.Host, actual.Host)
174 }
175 if expected.Path != actual.Path {
176 return fmt.Errorf("callback URL path mismatch: expected %s, got %s", expected.Path, actual.Path)
177 }
178
179 return nil
180}