A community based topic aggregation platform built on atproto
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}