Skip to content

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

Open
pjcdawkins wants to merge 4 commits into
mainfrom
fix/login-ca-bundle
Open

fix(legacy): use the detected CA bundle for the login token request#138
pjcdawkins wants to merge 4 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

This incremental push updates two test files in the legacy PHP CLI. BrowserLoginCommandTest now asserts that the token request is sent with the OAuth client credentials in the auth request option, and its send() mock callback accepts the optional $options argument. ConfigTest::testGetNoProxy now saves and restores the pre-existing no_proxy/NO_PROXY environment values and explicitly unsets both before asserting the empty-list baseline.

Changes
Layer / File(s) Summary
test fixes
legacy/tests/Command/Auth/BrowserLoginCommandTest.php Widens the mocked ClientInterface::send() callback to accept array $options and captures it, then asserts the request carried the expected auth option.
legacy/tests/ConfigTest.php Saves the original no_proxy/NO_PROXY values, unsets both before the empty-list assertion, and restores the originals in the finally block.

@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 on lines +132 to +148
public function testGetNoProxy(): void
{
$config = new Config([], $this->configFile);
try {
$this->assertEquals([], $config->getNoProxy());

putenv('no_proxy=example.com, .example.net,');
$this->assertEquals(['example.com', '.example.net'], $config->getNoProxy());
putenv('no_proxy');

putenv('NO_PROXY=example.org');
$this->assertEquals(['example.org'], $config->getNoProxy());
} finally {
putenv('no_proxy');
putenv('NO_PROXY');
}
}
Comment on lines +38 to +41
->willReturnCallback(function (RequestInterface $request) use (&$sentRequest): Response {
$sentRequest = $request;
return new Response(200, [], '{"access_token": "test-token", "token_type": "bearer"}');
});
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.
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.

2 participants