A community based topic aggregation platform built on atproto
at main 9.2 kB view raw
1//go:build dev 2 3package oauth 4 5import ( 6 "context" 7 "encoding/json" 8 "fmt" 9 "log/slog" 10 "net/http" 11 "net/url" 12 "strings" 13 14 oauthlib "github.com/bluesky-social/indigo/atproto/auth/oauth" 15 "github.com/bluesky-social/indigo/atproto/identity" 16 "github.com/bluesky-social/indigo/atproto/syntax" 17) 18 19// DevAuthResolver is a custom OAuth resolver that allows HTTP localhost URLs for development. 20// The standard indigo OAuth resolver requires HTTPS and no port numbers, which breaks local testing. 21type DevAuthResolver struct { 22 Client *http.Client 23 UserAgent string 24 PDSURL string // For resolving handles via local PDS 25 handleResolver *DevHandleResolver 26} 27 28// ProtectedResourceMetadata matches the OAuth protected resource metadata document format 29type ProtectedResourceMetadata struct { 30 Resource string `json:"resource"` 31 AuthorizationServers []string `json:"authorization_servers"` 32} 33 34// NewDevAuthResolver creates a resolver that accepts localhost HTTP URLs 35func NewDevAuthResolver(pdsURL string, allowPrivateIPs bool) *DevAuthResolver { 36 resolver := &DevAuthResolver{ 37 Client: NewSSRFSafeHTTPClient(allowPrivateIPs), 38 UserAgent: "Coves/1.0", 39 PDSURL: pdsURL, 40 } 41 // Create handle resolver for resolving handles via local PDS 42 if pdsURL != "" { 43 resolver.handleResolver = NewDevHandleResolver(pdsURL, allowPrivateIPs) 44 } 45 return resolver 46} 47 48// ResolveAuthServerURL resolves a PDS URL to an auth server URL. 49// Unlike indigo's standard resolver, this allows HTTP and ports for localhost. 50func (r *DevAuthResolver) ResolveAuthServerURL(ctx context.Context, hostURL string) (string, error) { 51 u, err := url.Parse(hostURL) 52 if err != nil { 53 return "", err 54 } 55 56 // For localhost, allow HTTP and port numbers 57 isLocalhost := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" 58 if !isLocalhost { 59 // For non-localhost, enforce HTTPS and no port (standard rules) 60 if u.Scheme != "https" || u.Port() != "" { 61 return "", fmt.Errorf("not a valid public host URL: %s", hostURL) 62 } 63 } 64 65 // Build the protected resource document URL 66 var docURL string 67 if isLocalhost { 68 // For localhost, preserve the port and use HTTP 69 port := u.Port() 70 if port == "" { 71 port = "3001" // Default PDS port 72 } 73 docURL = fmt.Sprintf("http://%s:%s/.well-known/oauth-protected-resource", u.Hostname(), port) 74 } else { 75 docURL = fmt.Sprintf("https://%s/.well-known/oauth-protected-resource", u.Hostname()) 76 } 77 78 // Fetch the protected resource document 79 req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil) 80 if err != nil { 81 return "", err 82 } 83 if r.UserAgent != "" { 84 req.Header.Set("User-Agent", r.UserAgent) 85 } 86 87 resp, err := r.Client.Do(req) 88 if err != nil { 89 return "", fmt.Errorf("fetching protected resource document: %w", err) 90 } 91 defer resp.Body.Close() 92 93 if resp.StatusCode != http.StatusOK { 94 return "", fmt.Errorf("HTTP error fetching protected resource document: %d", resp.StatusCode) 95 } 96 97 var body ProtectedResourceMetadata 98 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { 99 return "", fmt.Errorf("invalid protected resource document: %w", err) 100 } 101 102 if len(body.AuthorizationServers) < 1 { 103 return "", fmt.Errorf("no auth server URL in protected resource document") 104 } 105 106 authURL := body.AuthorizationServers[0] 107 108 // Validate the auth server URL (with localhost exception) 109 au, err := url.Parse(authURL) 110 if err != nil { 111 return "", fmt.Errorf("invalid auth server URL: %w", err) 112 } 113 114 authIsLocalhost := au.Hostname() == "localhost" || au.Hostname() == "127.0.0.1" 115 if !authIsLocalhost { 116 if au.Scheme != "https" || au.Port() != "" { 117 return "", fmt.Errorf("invalid auth server URL: %s", authURL) 118 } 119 } 120 121 return authURL, nil 122} 123 124// ResolveAuthServerMetadataDev fetches OAuth server metadata from a given auth server URL. 125// Unlike indigo's resolver, this allows HTTP and ports for localhost. 126func (r *DevAuthResolver) ResolveAuthServerMetadataDev(ctx context.Context, serverURL string) (*oauthlib.AuthServerMetadata, error) { 127 u, err := url.Parse(serverURL) 128 if err != nil { 129 return nil, err 130 } 131 132 // Build metadata URL - preserve port for localhost 133 var metaURL string 134 isLocalhost := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" 135 if isLocalhost && u.Port() != "" { 136 metaURL = fmt.Sprintf("%s://%s:%s/.well-known/oauth-authorization-server", u.Scheme, u.Hostname(), u.Port()) 137 } else if isLocalhost { 138 metaURL = fmt.Sprintf("%s://%s/.well-known/oauth-authorization-server", u.Scheme, u.Hostname()) 139 } else { 140 metaURL = fmt.Sprintf("https://%s/.well-known/oauth-authorization-server", u.Hostname()) 141 } 142 143 slog.Debug("dev mode: fetching auth server metadata", "url", metaURL) 144 145 req, err := http.NewRequestWithContext(ctx, "GET", metaURL, nil) 146 if err != nil { 147 return nil, err 148 } 149 if r.UserAgent != "" { 150 req.Header.Set("User-Agent", r.UserAgent) 151 } 152 153 resp, err := r.Client.Do(req) 154 if err != nil { 155 return nil, fmt.Errorf("fetching auth server metadata: %w", err) 156 } 157 defer resp.Body.Close() 158 159 if resp.StatusCode != http.StatusOK { 160 return nil, fmt.Errorf("HTTP error fetching auth server metadata: %d", resp.StatusCode) 161 } 162 163 var metadata oauthlib.AuthServerMetadata 164 if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { 165 return nil, fmt.Errorf("invalid auth server metadata: %w", err) 166 } 167 168 // Skip validation for localhost (indigo's Validate checks HTTPS) 169 if !isLocalhost { 170 if err := metadata.Validate(serverURL); err != nil { 171 return nil, fmt.Errorf("invalid auth server metadata: %w", err) 172 } 173 } 174 175 return &metadata, nil 176} 177 178// StartDevAuthFlow performs OAuth flow for localhost development. 179// This bypasses indigo's HTTPS validation for the auth server URL. 180// It resolves the identity, gets the PDS endpoint, fetches auth server metadata, 181// and returns a redirect URL for the user to approve. 182func (r *DevAuthResolver) StartDevAuthFlow(ctx context.Context, client *OAuthClient, identifier string, dir identity.Directory) (string, error) { 183 var accountDID syntax.DID 184 var pdsEndpoint string 185 186 // Check if identifier is a handle or DID 187 if strings.HasPrefix(identifier, "did:") { 188 // It's a DID - look up via directory (PLC) 189 atid, err := syntax.ParseAtIdentifier(identifier) 190 if err != nil { 191 return "", fmt.Errorf("not a valid DID (%s): %w", identifier, err) 192 } 193 ident, err := dir.Lookup(ctx, *atid) 194 if err != nil { 195 return "", fmt.Errorf("failed to resolve DID (%s): %w", identifier, err) 196 } 197 accountDID = ident.DID 198 pdsEndpoint = ident.PDSEndpoint() 199 } else { 200 // It's a handle - resolve via local PDS first 201 if r.handleResolver == nil { 202 return "", fmt.Errorf("handle resolution not configured (PDS URL not set)") 203 } 204 205 // Resolve handle to DID via local PDS 206 did, err := r.handleResolver.ResolveHandle(ctx, identifier) 207 if err != nil { 208 return "", fmt.Errorf("failed to resolve handle via PDS (%s): %w", identifier, err) 209 } 210 if did == "" { 211 return "", fmt.Errorf("handle not found: %s", identifier) 212 } 213 214 slog.Info("dev mode: resolved handle via local PDS", "handle", identifier, "did", did) 215 216 // Parse the DID 217 parsedDID, err := syntax.ParseDID(did) 218 if err != nil { 219 return "", fmt.Errorf("invalid DID from PDS (%s): %w", did, err) 220 } 221 accountDID = parsedDID 222 223 // Now look up the DID document via PLC to get PDS endpoint 224 atid, err := syntax.ParseAtIdentifier(did) 225 if err != nil { 226 return "", fmt.Errorf("not a valid DID (%s): %w", did, err) 227 } 228 ident, err := dir.Lookup(ctx, *atid) 229 if err != nil { 230 return "", fmt.Errorf("failed to resolve DID document (%s): %w", did, err) 231 } 232 pdsEndpoint = ident.PDSEndpoint() 233 } 234 235 if pdsEndpoint == "" { 236 return "", fmt.Errorf("identity does not link to an atproto host (PDS)") 237 } 238 239 slog.Debug("dev mode: resolving auth server", 240 "did", accountDID, 241 "pds", pdsEndpoint) 242 243 // Resolve auth server URL (allowing HTTP for localhost) 244 authServerURL, err := r.ResolveAuthServerURL(ctx, pdsEndpoint) 245 if err != nil { 246 return "", fmt.Errorf("resolving auth server: %w", err) 247 } 248 249 slog.Info("dev mode: resolved auth server", "url", authServerURL) 250 251 // Fetch auth server metadata using our dev-friendly resolver 252 authMeta, err := r.ResolveAuthServerMetadataDev(ctx, authServerURL) 253 if err != nil { 254 return "", fmt.Errorf("fetching auth server metadata: %w", err) 255 } 256 257 slog.Debug("dev mode: got auth server metadata", 258 "issuer", authMeta.Issuer, 259 "authorization_endpoint", authMeta.AuthorizationEndpoint, 260 "token_endpoint", authMeta.TokenEndpoint) 261 262 // Send auth request (PAR) using indigo's method 263 info, err := client.ClientApp.SendAuthRequest(ctx, authMeta, client.Config.Scopes, identifier) 264 if err != nil { 265 return "", fmt.Errorf("auth request failed: %w", err) 266 } 267 268 // Set the account DID 269 info.AccountDID = &accountDID 270 271 // Persist auth request info 272 client.ClientApp.Store.SaveAuthRequestInfo(ctx, *info) 273 274 // Build redirect URL 275 params := url.Values{} 276 params.Set("client_id", client.ClientApp.Config.ClientID) 277 params.Set("request_uri", info.RequestURI) 278 279 authEndpoint := authMeta.AuthorizationEndpoint 280 redirectURL := fmt.Sprintf("%s?%s", authEndpoint, params.Encode()) 281 282 slog.Info("dev mode: OAuth redirect URL built", "url_prefix", authEndpoint) 283 284 return redirectURL, nil 285}