···
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"
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.
21
+
type DevAuthResolver struct {
24
+
PDSURL string // For resolving handles via local PDS
25
+
handleResolver *DevHandleResolver
28
+
// ProtectedResourceMetadata matches the OAuth protected resource metadata document format
29
+
type ProtectedResourceMetadata struct {
30
+
Resource string `json:"resource"`
31
+
AuthorizationServers []string `json:"authorization_servers"`
34
+
// NewDevAuthResolver creates a resolver that accepts localhost HTTP URLs
35
+
func NewDevAuthResolver(pdsURL string, allowPrivateIPs bool) *DevAuthResolver {
36
+
resolver := &DevAuthResolver{
37
+
Client: NewSSRFSafeHTTPClient(allowPrivateIPs),
38
+
UserAgent: "Coves/1.0",
41
+
// Create handle resolver for resolving handles via local PDS
43
+
resolver.handleResolver = NewDevHandleResolver(pdsURL, allowPrivateIPs)
48
+
// ResolveAuthServerURL resolves a PDS URL to an auth server URL.
49
+
// Unlike indigo's standard resolver, this allows HTTP and ports for localhost.
50
+
func (r *DevAuthResolver) ResolveAuthServerURL(ctx context.Context, hostURL string) (string, error) {
51
+
u, err := url.Parse(hostURL)
56
+
// For localhost, allow HTTP and port numbers
57
+
isLocalhost := u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1"
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)
65
+
// Build the protected resource document URL
68
+
// For localhost, preserve the port and use HTTP
71
+
port = "3001" // Default PDS port
73
+
docURL = fmt.Sprintf("http://%s:%s/.well-known/oauth-protected-resource", u.Hostname(), port)
75
+
docURL = fmt.Sprintf("https://%s/.well-known/oauth-protected-resource", u.Hostname())
78
+
// Fetch the protected resource document
79
+
req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil)
83
+
if r.UserAgent != "" {
84
+
req.Header.Set("User-Agent", r.UserAgent)
87
+
resp, err := r.Client.Do(req)
89
+
return "", fmt.Errorf("fetching protected resource document: %w", err)
91
+
defer resp.Body.Close()
93
+
if resp.StatusCode != http.StatusOK {
94
+
return "", fmt.Errorf("HTTP error fetching protected resource document: %d", resp.StatusCode)
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)
102
+
if len(body.AuthorizationServers) < 1 {
103
+
return "", fmt.Errorf("no auth server URL in protected resource document")
106
+
authURL := body.AuthorizationServers[0]
108
+
// Validate the auth server URL (with localhost exception)
109
+
au, err := url.Parse(authURL)
111
+
return "", fmt.Errorf("invalid auth server URL: %w", err)
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)
121
+
return authURL, nil
124
+
// ResolveAuthServerMetadataDev fetches OAuth server metadata from a given auth server URL.
125
+
// Unlike indigo's resolver, this allows HTTP and ports for localhost.
126
+
func (r *DevAuthResolver) ResolveAuthServerMetadataDev(ctx context.Context, serverURL string) (*oauthlib.AuthServerMetadata, error) {
127
+
u, err := url.Parse(serverURL)
132
+
// Build metadata URL - preserve port for localhost
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())
140
+
metaURL = fmt.Sprintf("https://%s/.well-known/oauth-authorization-server", u.Hostname())
143
+
slog.Debug("dev mode: fetching auth server metadata", "url", metaURL)
145
+
req, err := http.NewRequestWithContext(ctx, "GET", metaURL, nil)
149
+
if r.UserAgent != "" {
150
+
req.Header.Set("User-Agent", r.UserAgent)
153
+
resp, err := r.Client.Do(req)
155
+
return nil, fmt.Errorf("fetching auth server metadata: %w", err)
157
+
defer resp.Body.Close()
159
+
if resp.StatusCode != http.StatusOK {
160
+
return nil, fmt.Errorf("HTTP error fetching auth server metadata: %d", resp.StatusCode)
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)
168
+
// Skip validation for localhost (indigo's Validate checks HTTPS)
170
+
if err := metadata.Validate(serverURL); err != nil {
171
+
return nil, fmt.Errorf("invalid auth server metadata: %w", err)
175
+
return &metadata, nil
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.
182
+
func (r *DevAuthResolver) StartDevAuthFlow(ctx context.Context, client *OAuthClient, identifier string, dir identity.Directory) (string, error) {
183
+
var accountDID syntax.DID
184
+
var pdsEndpoint string
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)
191
+
return "", fmt.Errorf("not a valid DID (%s): %w", identifier, err)
193
+
ident, err := dir.Lookup(ctx, *atid)
195
+
return "", fmt.Errorf("failed to resolve DID (%s): %w", identifier, err)
197
+
accountDID = ident.DID
198
+
pdsEndpoint = ident.PDSEndpoint()
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)")
205
+
// Resolve handle to DID via local PDS
206
+
did, err := r.handleResolver.ResolveHandle(ctx, identifier)
208
+
return "", fmt.Errorf("failed to resolve handle via PDS (%s): %w", identifier, err)
211
+
return "", fmt.Errorf("handle not found: %s", identifier)
214
+
slog.Info("dev mode: resolved handle via local PDS", "handle", identifier, "did", did)
217
+
parsedDID, err := syntax.ParseDID(did)
219
+
return "", fmt.Errorf("invalid DID from PDS (%s): %w", did, err)
221
+
accountDID = parsedDID
223
+
// Now look up the DID document via PLC to get PDS endpoint
224
+
atid, err := syntax.ParseAtIdentifier(did)
226
+
return "", fmt.Errorf("not a valid DID (%s): %w", did, err)
228
+
ident, err := dir.Lookup(ctx, *atid)
230
+
return "", fmt.Errorf("failed to resolve DID document (%s): %w", did, err)
232
+
pdsEndpoint = ident.PDSEndpoint()
235
+
if pdsEndpoint == "" {
236
+
return "", fmt.Errorf("identity does not link to an atproto host (PDS)")
239
+
slog.Debug("dev mode: resolving auth server",
241
+
"pds", pdsEndpoint)
243
+
// Resolve auth server URL (allowing HTTP for localhost)
244
+
authServerURL, err := r.ResolveAuthServerURL(ctx, pdsEndpoint)
246
+
return "", fmt.Errorf("resolving auth server: %w", err)
249
+
slog.Info("dev mode: resolved auth server", "url", authServerURL)
251
+
// Fetch auth server metadata using our dev-friendly resolver
252
+
authMeta, err := r.ResolveAuthServerMetadataDev(ctx, authServerURL)
254
+
return "", fmt.Errorf("fetching auth server metadata: %w", err)
257
+
slog.Debug("dev mode: got auth server metadata",
258
+
"issuer", authMeta.Issuer,
259
+
"authorization_endpoint", authMeta.AuthorizationEndpoint,
260
+
"token_endpoint", authMeta.TokenEndpoint)
262
+
// Send auth request (PAR) using indigo's method
263
+
info, err := client.ClientApp.SendAuthRequest(ctx, authMeta, client.Config.Scopes, identifier)
265
+
return "", fmt.Errorf("auth request failed: %w", err)
268
+
// Set the account DID
269
+
info.AccountDID = &accountDID
271
+
// Persist auth request info
272
+
client.ClientApp.Store.SaveAuthRequestInfo(ctx, *info)
274
+
// Build redirect URL
275
+
params := url.Values{}
276
+
params.Set("client_id", client.ClientApp.Config.ClientID)
277
+
params.Set("request_uri", info.RequestURI)
279
+
authEndpoint := authMeta.AuthorizationEndpoint
280
+
redirectURL := fmt.Sprintf("%s?%s", authEndpoint, params.Encode())
282
+
slog.Info("dev mode: OAuth redirect URL built", "url_prefix", authEndpoint)
284
+
return redirectURL, nil