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