Skip to content

fix(legacy): use the detected CA bundle for the login token request - #138

Merged
bojanz merged 5 commits into
mainfrom
fix/login-ca-bundle
Aug 7, 2026
Merged

fix(legacy): use the detected CA bundle for the login token request#138
bojanz merged 5 commits into
mainfrom
fix/login-ca-bundle

Conversation

@pjcdawkins

@pjcdawkins pjcdawkins commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Related to #110. This fixes one inconsistency in the login path, but not the whole problem reported there — see "Scope" below.

Problem

Behind a TLS-inspecting proxy, with a CA bundle configured in SSL_CERT_FILE, every request works except the last step of upsun login:

[RequestException]
cURL error 60: SSL certificate problem: unable to get local issuer certificate

Cause

The authorization code exchange in auth:browser-login built its own Guzzle client with verify => true. Guzzle then enables peer verification without setting CURLOPT_CAINFO, so curl falls back to its default CA source. Every other request in the CLI passes an explicit path from Composer\CaBundle, which honors SSL_CERT_FILE and SSL_CERT_DIR.

That default is not a neutral one. The wrapper starts PHP with -n and -d openssl.cafile=<cache>/cacert.pem, and PHP's curl extension takes its default CURLOPT_CAINFO from openssl.cafile (falling back to curl.cainfo), ignoring SSL_CERT_FILE. So this one request always verified against the bundled Mozilla CA list, whatever the user configured.

The local OAuth listener was not involved: it only redirects the browser and writes a file, and makes no outbound requests.

Changes

  • Use Api::getExternalHttpClient() for the token request. It applies the detected CA bundle, the configured proxy, the user agent and the api.skip_ssl option. It logs the request line and response status at -vvv and never message bodies, so tokens stay out of the output.
  • Pass -n to the local web server when this process parsed no php.ini either. Otherwise the static PHP binary loads a system php.ini, which produced Module "curl" is already loaded warnings for the reporter and could stop the server from starting at all.
  • Honor no_proxy. Guzzle reads the proxy environment variables itself, but only when the proxy request option is absent, and the CLI always sets it. So the no-proxy list was being dropped for every API request, and would have been dropped for the token request once it moved to the shared client.

Scope

This helps users who have already pointed SSL_CERT_FILE at a CA bundle. It does not help users whose additional root certificate is installed in the operating system's trust store, which is the usual arrangement:

  • On Windows, curl in the embedded PHP is built against Schannel. Any CA file switches it to an exclusive root store (lib/vtls/schannel.c: CAfile implies use_manual_cred_validation; schannel_verify.c builds the chain engine with hExclusiveRoot), so pinning openssl.cafile means the Windows certificate store is never consulted.
  • On macOS, curl falls back to /etc/ssl/cert.pem, which does not include roots added to the keychain.
  • Composer\CaBundle cannot close this gap: it discovers bundle paths and otherwise returns its own bundled Mozilla list, with no knowledge of either OS store.

Meanwhile the Go half of the same binary uses the platform verifier on Windows and ignores SSL_CERT_FILE, so the two halves can disagree about the same certificate.

That needs a separate fix — most likely making the CA bundle the wrapper pins include the OS trust store, plus a documented option for pointing both layers at a bundle explicitly. It is worth confirming on a Windows machine first: the Schannel behavior above is read from curl's source, not observed.

Verification

New unit tests cover the client used for the token request and the parsing of no_proxy. Using the embedded PHP binary with openssl.cafile pointed at an unrelated CA and a working bundle in SSL_CERT_FILE, the old client reproduces the reported cURL error 60 while the new one succeeds; the listener still redirects correctly when started with -n; and with a dead proxy configured, a request goes direct when its host is in no_proxy and fails when it is not.

Written by Claude Code.

pjcdawkins and others added 3 commits July 29, 2026 21:02
The authorization code exchange in auth:browser-login built its own
Guzzle client with `verify => true`. Guzzle then enables peer
verification without setting CURLOPT_CAINFO, so curl falls back to its
default CA source. Every other request in the CLI passes an explicit
path from Composer\CaBundle, which honors SSL_CERT_FILE and
SSL_CERT_DIR.

Behind a TLS-inspecting proxy that made login fail at the last step with
"cURL error 60: SSL certificate problem", even though the rest of the
CLI worked with the corporate root CA in SSL_CERT_FILE. On Windows the
fallback is always wrong: the wrapper runs PHP with -n and
-d openssl.cafile pointing at the bundled Mozilla CA list, and PHP's
curl extension takes its default CAINFO from that setting, so this
request could only ever trust the bundled list.

Use Api::getExternalHttpClient() instead, which applies the detected CA
bundle, the configured proxy, the user agent and the api.skip_ssl
option. It logs the request line and status at -vvv, never message
bodies, so tokens stay out of the output.

Reported in #110

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The local OAuth listener server is started with the same PHP binary as
the CLI, but without -n, so it parses any php.ini it can find even when
the CLI itself was started without one. Under the Go wrapper that means
the static PHP binary loads a system php.ini, which produced warnings
like 'Module "curl" is already loaded' for one user, and could stop the
server from starting at all with a php.ini that enables extensions this
build cannot load.

Pass -n to the server when this process parsed no php.ini either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Guzzle reads http_proxy, https_proxy and no_proxy itself, but only when
the proxy request option is absent. The CLI always sets that option from
Config::getProxies(), so the no-proxy list was silently dropped for
every API request.

This also keeps the login token request behaving as it did before it
started using the shared HTTP client, which until then was left to
Guzzle's own environment handling.

Both cases of the variable name are accepted, since the effect is only
ever to send a request directly instead of through a proxy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 29, 2026 21:01
@upsun-dispatch

upsun-dispatch Bot commented Jul 29, 2026

Copy link
Copy Markdown

📋 PR Summary

Fixes the OAuth authorization-code exchange in auth:browser-login so it uses the shared Api::getExternalHttpClient() instead of a locally-built Guzzle client. That client applies the detected CA bundle (honoring SSL_CERT_FILE), the configured proxy, and api.skip_ssl, resolving a "cURL error 60" failure behind a TLS-inspecting proxy. It also adds -n to the local listener server when the parent process parsed no php.ini, and teaches the shared proxy config to honor no_proxy via a new Config::getNoProxy().

Changes
Layer / File(s) Summary
login token request
legacy/src/Command/Auth/BrowserLoginCommand.php Uses the shared external HTTP client for the token exchange so the request honors the detected CA bundle and proxy; adds -n to the local listener server when this process parsed no php.ini.
proxy / CA config
legacy/src/Service/Api.php guzzleProxyConfig() now adds a no key from Config::getNoProxy() so the no_proxy list is passed to Guzzle (which otherwise ignores it when a proxy option is present); docblock updated accordingly.
legacy/src/Service/Config.php New getNoProxy() parses the no_proxy/NO_PROXY environment variable into a trimmed, non-empty host list.
tests
legacy/tests/Command/Auth/BrowserLoginCommandTest.php New test asserting the token request goes through the shared client with the expected URL and basic-auth options.
legacy/tests/ConfigTest.php New test covering getNoProxy() parsing of no_proxy/NO_PROXY, including trailing/empty entries.

@upsun-dispatch upsun-dispatch Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📋 Upsun Dispatch Review: full · 5 files reviewed · no issues found

⚠️ correctness reviewer unavailable — coverage may be partial

Review details

Commit: Commit f208375
Model: claude-opus-4-8
Panel: security · correctness · robustness · design

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes upsun login failures behind TLS-inspecting proxies by ensuring the OAuth token exchange uses the legacy CLI’s shared external HTTP client (so it respects the detected CA bundle, proxy settings, and api.skip_ssl). It also aligns the spawned local OAuth listener PHP process with the parent process’s php.ini parsing behavior and restores no_proxy support when the CLI explicitly configures Guzzle proxies.

Changes:

  • Route the OAuth2 token request in auth:browser-login through Api::getExternalHttpClient() instead of creating a standalone Guzzle client.
  • Add -n when spawning the local PHP web server if the current process did not load/scan any php.ini.
  • Add Config::getNoProxy() and pass no_proxy hosts through to Guzzle proxy configuration, with new unit tests.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
legacy/src/Command/Auth/BrowserLoginCommand.php Uses shared external HTTP client for token exchange; adjusts local PHP server args to optionally include -n.
legacy/src/Service/Api.php Extends Guzzle proxy config to include no_proxy hosts and updates return type annotation.
legacy/src/Service/Config.php Adds parsing of no_proxy/NO_PROXY into a normalized host list.
legacy/tests/ConfigTest.php Adds unit test coverage for Config::getNoProxy().
legacy/tests/Command/Auth/BrowserLoginCommandTest.php Adds unit test ensuring token exchange uses the shared external HTTP client.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread legacy/tests/ConfigTest.php
Comment thread legacy/tests/Command/Auth/BrowserLoginCommandTest.php Outdated
The no_proxy test asserted an empty list without first clearing the
variables, so it failed where they are already set, and it cleared them
afterwards instead of restoring them. It now saves and restores both.

The login test also records the request options, so that it covers the
client credentials still being sent.

Written by Claude Code.
@bojanz
bojanz self-requested a review August 7, 2026 16:41
@bojanz
bojanz merged commit f0f4e87 into main Aug 7, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants