package oauth import ( "context" "encoding/json" "fmt" "net/http" "net/url" atoauth "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/syntax" ) // DevStartAuthFlow is a dev-mode alternative to ClientApp.StartAuthFlow that // relaxes the HTTPS and port restrictions on OAuth URLs. The upstream indigo // library's Resolver requires all auth server URLs to be https:// with no // explicit port, which prevents local development against http://localhost:PORT. // // This reimplements the StartAuthFlow logic: identity lookup -> fetch protected // resource metadata -> fetch auth server metadata -> send PAR request, but // fetches and parses the well-known documents directly without URL validation. func (o *OAuth) DevStartAuthFlow(ctx context.Context, identifier string) (string, error) { app := o.ClientApp atid, err := syntax.ParseAtIdentifier(identifier) if err != nil { return "", fmt.Errorf("not a valid account identifier (%s): %w", identifier, err) } ident, err := app.Dir.Lookup(ctx, *atid) if err != nil { return "", fmt.Errorf("failed to resolve username (%s): %w", identifier, err) } accountDID := ident.DID host := ident.PDSEndpoint() if host == "" { return "", fmt.Errorf("identity does not link to an atproto host (PDS)") } logger := o.Logger.With("did", ident.DID, "handle", ident.Handle, "host", host) logger.Debug("dev: resolving auth server metadata (relaxed URL validation)") // Fetch protected resource metadata to find the auth server URL. authserverURL, err := devResolveAuthServerURL(ctx, app.Resolver.Client, host) if err != nil { return "", fmt.Errorf("dev: resolving auth server: %w", err) } // Fetch auth server metadata without HTTPS validation. authserverMeta, err := devResolveAuthServerMetadata(ctx, app.Resolver.Client, authserverURL) if err != nil { return "", fmt.Errorf("dev: fetching auth server metadata: %w", err) } info, err := app.SendAuthRequest(ctx, authserverMeta, app.Config.Scopes, identifier) if err != nil { return "", fmt.Errorf("auth request failed: %w", err) } info.AccountDID = &accountDID app.Store.SaveAuthRequestInfo(ctx, *info) params := url.Values{} params.Set("client_id", app.Config.ClientID) params.Set("request_uri", info.RequestURI) redirectURL := fmt.Sprintf("%s?%s", authserverMeta.AuthorizationEndpoint, params.Encode()) return redirectURL, nil } // devResolveAuthServerURL fetches /.well-known/oauth-protected-resource from // the PDS host without requiring HTTPS or rejecting port numbers. func devResolveAuthServerURL(ctx context.Context, client *http.Client, hostURL string) (string, error) { u, err := url.Parse(hostURL) if err != nil { return "", err } docURL := fmt.Sprintf("%s://%s/.well-known/oauth-protected-resource", u.Scheme, u.Host) req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil) if err != nil { return "", err } resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("fetching protected resource document: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("HTTP error fetching protected resource document: %d", resp.StatusCode) } var body atoauth.ProtectedResourceMetadata if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return "", fmt.Errorf("invalid protected resource document: %w", err) } if len(body.AuthorizationServers) < 1 { return "", fmt.Errorf("no auth server URL in protected resource document") } return body.AuthorizationServers[0], nil } // devResolveAuthServerMetadata fetches /.well-known/oauth-authorization-server // from the auth server without requiring HTTPS or rejecting port numbers. // It also skips the Validate() call which enforces HTTPS on the issuer. func devResolveAuthServerMetadata(ctx context.Context, client *http.Client, serverURL string) (*atoauth.AuthServerMetadata, error) { u, err := url.Parse(serverURL) if err != nil { return nil, err } docURL := fmt.Sprintf("%s://%s/.well-known/oauth-authorization-server", u.Scheme, u.Host) req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil) if err != nil { return nil, err } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("fetching auth server metadata: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP error fetching auth server metadata: %d", resp.StatusCode) } var body atoauth.AuthServerMetadata if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return nil, fmt.Errorf("invalid auth server metadata document: %w", err) } // Skip body.Validate(serverURL) - it requires https:// and no port. // Do minimal sanity checks instead. if body.Issuer == "" { return nil, fmt.Errorf("empty issuer in auth server metadata") } if body.PushedAuthorizationRequestEndpoint == "" { return nil, fmt.Errorf("missing pushed_authorization_request_endpoint") } if body.AuthorizationEndpoint == "" { return nil, fmt.Errorf("missing authorization_endpoint") } if body.TokenEndpoint == "" { return nil, fmt.Errorf("missing token_endpoint") } return &body, nil }