A community based topic aggregation platform built on atproto
at main 5.8 kB view raw
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}