diff --git a/.env.example b/.env.example index 9d35496..7a5dc3b 100644 --- a/.env.example +++ b/.env.example @@ -10,9 +10,9 @@ CA_CLIENT_SECRET=your-client-secret-here # CA_SCOPE="openid profile aws.cognito.signin.user.admin" # URI de redirecionamento OAuth2 -# Sem TLS (padrão, se Conta Azul aceitar localhost): -# CA_REDIRECT_URI=http://localhost:9876/callback -# Com TLS via mkcert (necessário: a Conta Azul exige HTTPS e domínio, e recusa localhost): +# O default já é o valor que o portal da Conta Azul aceita (HTTPS + domínio; +# localhost em http:// é recusado). "ca auth login" emite o certificado TLS +# em ~/.config/conta-azul-cli/certs/ e instala a CA no trust store do usuário. # CA_REDIRECT_URI=https://conta-azul-cli.ddev.site:9876/callback # # NÃO troque o domínio: apesar do nome, isto NÃO depende de DDEV — *.ddev.site é @@ -20,11 +20,9 @@ CA_CLIENT_SECRET=your-client-secret-here # neutro (conta-azul-cli.localtest.me, mesma propriedade de DNS) e o portal da # Conta Azul respondeu erro interno de servidor ao cadastrar. Com ddev.site aceita. -# Certificado TLS local para o servidor de callback -# Gere com: mkcert -cert-file .certs/cert.pem -key-file .certs/key.pem conta-azul-cli.ddev.site -# Resolve para 127.0.0.1 via DNS público, sem /etc/hosts e sem container -# CA_CALLBACK_CERT=/path/to/.certs/cert.pem -# CA_CALLBACK_KEY=/path/to/.certs/key.pem +# Override do certificado TLS do callback. Sem isto, o CLI gera o par sozinho. +# CA_CALLBACK_CERT=/path/to/cert.pem +# CA_CALLBACK_KEY=/path/to/key.pem # Endpoint de autorização (padrão: {CA_AUTH_BASE_URL}/oauth2/authorize) # O default serve produção e sandbox — normalmente não há o que mexer aqui. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ce38f5..d20792c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ no [contrato de saída](docs/guia/contrato-de-saida.md). ## [Unreleased] +## [0.19.3] - 2026-08-26 + +### Fixed + +- **`auth login` deixou de depender de `.certs/` num checkout local.** O + Homebrew instala o PHAR, não o repositório, então numa máquina nova + `brew install` não bastava para reautenticar: o callback HTTPS procurava + certificados que só existiam ao lado de `bin/`. O comando agora emite uma + CA e um certificado em `~/.config/conta-azul-cli/certs/` e instala a CA no + trust store do usuário. O default compilado de `CA_REDIRECT_URI` passa a + ser `https://conta-azul-cli.ddev.site:9876/callback`, que é o valor que o + portal da Conta Azul aceita. `CA_CALLBACK_CERT` / `CA_CALLBACK_KEY` + continuam como override; caminhos apontando para um checkout que não + existe nesta máquina são ignorados. + ## [0.19.2] - 2026-08-26 ### Fixed @@ -1021,7 +1036,8 @@ Primeira versão tagueada. - Pacote renomeado de `contaazul-cli/cli` para `heitoralthmann/conta-azul-cli`, com aviso de não-oficialidade adicionado ao README. -[Unreleased]: https://github.com/heitoralthmann/conta-azul-cli/compare/v0.19.2...HEAD +[Unreleased]: https://github.com/heitoralthmann/conta-azul-cli/compare/v0.19.3...HEAD +[0.19.3]: https://github.com/heitoralthmann/conta-azul-cli/compare/v0.19.2...v0.19.3 [0.19.2]: https://github.com/heitoralthmann/conta-azul-cli/compare/v0.19.1...v0.19.2 [0.19.1]: https://github.com/heitoralthmann/conta-azul-cli/compare/v0.19.0...v0.19.1 [0.19.0]: https://github.com/heitoralthmann/conta-azul-cli/compare/v0.18.0...v0.19.0 diff --git a/README.md b/README.md index f9ffe33..154a761 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ O `.env` e o `tokens.json` nascem `0600`, em diretório `0700` — em Unix. **No O arquivo de ambiente é procurado nesta ordem, e **o primeiro que existir vence, sem mesclagem**: `CA_CLI_ENV_FILE` → `/.env` → `~/.config/conta-azul-cli/.env`. `ca config path` responde qual está valendo e por quê. A precedência geral é: **flag de CLI → variável de ambiente → arquivo → default compilado**. Em produção, use variáveis de ambiente. `.env` e `tokens.json` **nunca** devem ser versionados. -O provedor da Conta Azul **recusa `redirect_uri` em `http://localhost`**: exige HTTPS e um domínio real. A receita completa — incluindo por que o domínio precisa ser `*.ddev.site` e por que `CA_AUTHORIZE_URL` e `CA_TOKEN_URL` andam em par — está em [Configuração](docs/guia/configuracao.md). +O provedor da Conta Azul **recusa `redirect_uri` em `http://localhost`**: exige HTTPS e um domínio real. O default compilado já é `https://conta-azul-cli.ddev.site:9876/callback`, e `ca auth login` emite o certificado TLS em `~/.config/conta-azul-cli/certs/` — sem checkout e sem `mkcert`. A receita completa — incluindo por que o domínio precisa ser `*.ddev.site` e por que `CA_AUTHORIZE_URL` e `CA_TOKEN_URL` andam em par — está em [Configuração](docs/guia/configuracao.md). ## Autenticação diff --git a/VERSION b/VERSION index 61e6e92..b72b05e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.19.2 +0.19.3 diff --git a/composer-dependency-analyser.php b/composer-dependency-analyser.php index 6bd7154..d667338 100644 --- a/composer-dependency-analyser.php +++ b/composer-dependency-analyser.php @@ -17,9 +17,10 @@ // ci.yml's Windows extensions comment). $config->ignoreErrorsOnExtension('ext-posix', [ErrorType::SHADOW_DEPENDENCY]); -// ext-mbstring and ext-openssl are required by Symfony components internally -// (Console formatting, HTTPS transport) without this codebase calling their -// functions directly, so static usage scanning can't see the need. -$config->ignoreErrorsOnExtensions(['ext-mbstring', 'ext-openssl'], [ErrorType::UNUSED_DEPENDENCY]); +// ext-mbstring is required by Symfony components internally (Console +// formatting) without this codebase calling its functions directly, so +// static usage scanning can't see the need. ext-openssl is called from +// LocalCertificateAuthority for the login callback certs. +$config->ignoreErrorsOnExtensions(['ext-mbstring'], [ErrorType::UNUSED_DEPENDENCY]); return $config; diff --git a/docs/_data/commands/auth.yaml b/docs/_data/commands/auth.yaml index 2c4533c..ed6363c 100644 --- a/docs/_data/commands/auth.yaml +++ b/docs/_data/commands/auth.yaml @@ -5,7 +5,7 @@ sections: - 'auth login' status: verified body: | - Sem parâmetros. Imprime uma URL, sobe um listener HTTPS local na porta **9876** e aguarda o redirect. Tokens vão para `~/.config/conta-azul-cli/tokens.json` com permissão `0600`. + Sem parâmetros. Imprime uma URL, sobe um listener HTTPS local na porta **9876** e aguarda o redirect. Na primeira vez emite o certificado em `~/.config/conta-azul-cli/certs/` e instala a CA no trust store do usuário. Tokens vão para `~/.config/conta-azul-cli/tokens.json` com permissão `0600`. O refresh é automático e invisível — não existe comando para isso. Renovação preventiva a menos de 60 s da expiração, e reativa uma vez em caso de `401`. - diff --git a/docs/commands.json b/docs/commands.json index c407c67..238864e 100644 --- a/docs/commands.json +++ b/docs/commands.json @@ -2273,5 +2273,5 @@ "version" ], "output_default": "toon", - "version": "0.19.2" + "version": "0.19.3" } diff --git a/docs/desenvolvimento/especificacao.md b/docs/desenvolvimento/especificacao.md index 758b5b1..f2041d2 100644 --- a/docs/desenvolvimento/especificacao.md +++ b/docs/desenvolvimento/especificacao.md @@ -195,13 +195,13 @@ CLI em PHP/Symfony que expõe endpoints das famílias Financeiro (Finanças, Bai ### 11.1. Fluxo inicial — `ca auth login` 1. CLI gera `state` aleatório. -2. CLI inicia listener HTTP local em `http://localhost:9876/callback` (porta fixa, pré-registrada no app na Conta Azul). +2. CLI inicia listener HTTPS local em `https://conta-azul-cli.ddev.site:9876/callback` (porta fixa, pré-registrada no app na Conta Azul). O certificado TLS é emitido em `~/.config/conta-azul-cli/certs/` na primeira vez, e a CA entra no trust store do usuário. 3. CLI imprime a URL de autorização e pede ao usuário que abra em um navegador. 4. Usuário completa login no IdP (AWS Cognito, por trás da Conta Azul). 5. O redirect captura `code`; CLI valida `state` e troca por tokens via `POST https://auth.contaazul.com/oauth2/token` com `Authorization: Basic base64(client_id:client_secret)`. 6. CLI persiste tokens; listener encerra; processo sai com `0`. -**Justificativa.** Loopback local é o padrão estabelecido para OAuth em CLIs e funciona em qualquer máquina dev com navegador. PHP tem suporte nativo via `stream_socket_server`, dispensando dependências adicionais. +**Justificativa.** Loopback local é o padrão estabelecido para OAuth em CLIs e funciona em qualquer máquina com navegador. O provedor recusa `http://localhost` e exige HTTPS com domínio; `*.ddev.site` já resolve para `127.0.0.1` via DNS público. PHP gera o par TLS com a extensão `openssl` (já requisito) e instala a CA no trust store do usuário, então um `brew install` numa máquina nova basta para reautenticar — sem checkout, sem `.certs/`, sem `mkcert`. **Trade-off aceito.** Porta fixa (`9876`). Se já estiver ocupada, o login falha com mensagem clara em pt-BR. Aceitável. @@ -273,7 +273,7 @@ Operador roda `ca auth login` uma vez em máquina dev, depois copia o refresh to |---|---|---| | `CA_CLIENT_ID` | sim | client_id do app Conta Azul | | `CA_CLIENT_SECRET` | sim | client_secret do app | -| `CA_REDIRECT_URI` | não | default `http://localhost:9876/callback` | +| `CA_REDIRECT_URI` | não | default `https://conta-azul-cli.ddev.site:9876/callback` | | `CA_API_BASE_URL` | não | default `https://api-v2.contaazul.com` | | `CA_AUTH_BASE_URL` | não | default `https://auth.contaazul.com` | | `CA_CLI_TOKEN_PATH` | não | default `~/.config/conta-azul-cli/tokens.json` | diff --git a/docs/guia/autenticacao.md b/docs/guia/autenticacao.md index 764c901..f8cd305 100644 --- a/docs/guia/autenticacao.md +++ b/docs/guia/autenticacao.md @@ -4,7 +4,7 @@ ca auth login ``` -O comando imprime uma URL, sobe um listener local na porta **9876** e aguarda o redirect. Abra a URL no navegador, complete o login, e os tokens são gravados em `~/.config/conta-azul-cli/tokens.json` com permissão `0600`. +O comando imprime uma URL, sobe um listener HTTPS local na porta **9876** e aguarda o redirect. Na primeira vez, emite um certificado em `~/.config/conta-azul-cli/certs/` e instala a CA no trust store do usuário — no macOS isso pode pedir a senha da conta, uma vez. Abra a URL no navegador, complete o login, e os tokens são gravados em `~/.config/conta-azul-cli/tokens.json` com permissão `0600`. O arquivo guarda o **refresh token**, que é a credencial de longa duração. O `0600` vale em Unix; no Windows o PHP não escreve bits de permissão e a proteção fica por conta das ACLs do perfil do usuário — o detalhe está em [Permissões dos arquivos](configuracao.md#permissoes). diff --git a/docs/guia/configuracao.md b/docs/guia/configuracao.md index dff9cd9..ab20894 100644 --- a/docs/guia/configuracao.md +++ b/docs/guia/configuracao.md @@ -55,7 +55,7 @@ cp .env.example .env |---|---|---| | `CA_CLIENT_ID` | sim | — | | `CA_CLIENT_SECRET` | sim | — | -| `CA_REDIRECT_URI` | não | `http://localhost:9876/callback` | +| `CA_REDIRECT_URI` | não | `https://conta-azul-cli.ddev.site:9876/callback` | | `CA_SCOPE` | não | omitido da requisição | | `CA_CALLBACK_CERT` | não | — | | `CA_CALLBACK_KEY` | não | — | @@ -74,14 +74,15 @@ cp .env.example .env ## Permissões dos arquivos { #permissoes } -O CLI grava dois arquivos que carregam credencial: +O CLI grava arquivos que carregam credencial: | Arquivo | O que guarda | |---|---| | `~/.config/conta-azul-cli/.env` | `CA_CLIENT_SECRET` — e o `CA_BOOTSTRAP_REFRESH_TOKEN`, se você o definir ali | | `~/.config/conta-azul-cli/tokens.json` | O access token e o **refresh token** do OAuth, a credencial de longa duração | +| `~/.config/conta-azul-cli/certs/` | A CA e a chave privada do certificado TLS do callback, gerados por `ca auth login` | -Nos dois casos o CLI cria o diretório com `0700` e o arquivo com `0600`, e **reaplica a permissão a cada escrita**, não só na criação. Em Unix isso significa o que promete: nenhum outro usuário da máquina lê esses arquivos. +Em todos esses caminhos o CLI cria o diretório com `0700` e o arquivo com `0600`, e **reaplica a permissão a cada escrita**, não só na criação. Em Unix isso significa o que promete: nenhum outro usuário da máquina lê esses arquivos. > **No Windows essa garantia não existe.** O `chmod` do PHP naquela plataforma só liga e desliga o atributo de somente-leitura — ele não escreve bits de modo POSIX, porque o sistema de arquivos não os tem. O código chama `chmod` em todas as plataformas e está correto; o que não existe no Windows é o efeito. Quem protege os arquivos ali são as ACLs do próprio perfil do usuário (`C:\Users\`), que por padrão já barram os demais usuários locais. > @@ -118,23 +119,16 @@ Na prática, só mexa nessas variáveis se a Conta Azul mudar os endpoints — e ## Callback OAuth com HTTPS -O provedor da Conta Azul **recusa `redirect_uri` em `http://localhost`**: exige HTTPS e um domínio real. A saída é usar `mkcert` com um domínio que já resolve para `127.0.0.1` via DNS público — `*.ddev.site` — sem mexer em `/etc/hosts`. +O provedor da Conta Azul **recusa `redirect_uri` em `http://localhost`**: exige HTTPS e um domínio real. O default compilado já é esse valor: -> **Não altere esse domínio.** O nome sugere uma dependência de DDEV que **não existe**: o projeto não usa DDEV, e `*.ddev.site` é apenas um wildcard DNS público apontando para `127.0.0.1`. A escolha é imposta pelo provedor — já tentamos trocar por um nome mais neutro e não funcionou. Ao registrar a aplicação com `conta-azul-cli.localtest.me`, que tem exatamente a mesma propriedade de DNS, o portal da Conta Azul respondeu **erro interno de servidor** e recusou o cadastro; com `ddev.site` aceitou. O critério de validação de domínio deles não é documentado, então vale o valor que funciona. - -```bash -brew install mkcert -mkcert -install -mkdir -p .certs -mkcert -cert-file .certs/cert.pem -key-file .certs/key.pem conta-azul-cli.ddev.site +``` +https://conta-azul-cli.ddev.site:9876/callback ``` -E no `.env`: +`ca auth login` emite sozinho o certificado TLS em `~/.config/conta-azul-cli/certs/` e instala a CA no trust store do usuário (no macOS, o login keychain — pode pedir a senha da conta uma vez). Não depende de um checkout, de `.certs/` na raiz do repositório, nem de `mkcert`. É o que torna `brew install` + `ca auth login` suficiente numa máquina nova. -```bash -CA_REDIRECT_URI=https://conta-azul-cli.ddev.site:9876/callback -CA_CALLBACK_CERT=/caminho/absoluto/.certs/cert.pem -CA_CALLBACK_KEY=/caminho/absoluto/.certs/key.pem -``` +> **Não altere esse domínio.** O nome sugere uma dependência de DDEV que **não existe**: o projeto não usa DDEV, e `*.ddev.site` é apenas um wildcard DNS público apontando para `127.0.0.1`. A escolha é imposta pelo provedor — já tentamos trocar por um nome mais neutro e não funcionou. Ao registrar a aplicação com `conta-azul-cli.localtest.me`, que tem exatamente a mesma propriedade de DNS, o portal da Conta Azul respondeu **erro interno de servidor** e recusou o cadastro; com `ddev.site` aceitou. O critério de validação de domínio deles não é documentado, então vale o valor que funciona. + +Registre exatamente esse `redirect_uri` no painel do app na Conta Azul. -Registre exatamente esse `redirect_uri` no painel do app na Conta Azul. Quando `CA_CALLBACK_CERT` e `CA_CALLBACK_KEY` estão presentes, o servidor de callback abre um socket TLS; sem elas, ele cai no modo `http://` simples. +`CA_CALLBACK_CERT` e `CA_CALLBACK_KEY` continuam valendo como override: se os dois arquivos existirem, o CLI usa-os e não mexe no trust store. Caminhos copiados de um checkout antigo que não existem nesta máquina são ignorados, e o CLI cai no par gerado. Sem as duas variáveis, o servidor de callback abre um socket TLS com o certificado gerado; um `CA_REDIRECT_URI` em `http://` (não o default) cai no modo `http://` simples. diff --git a/docs/guia/instalacao.md b/docs/guia/instalacao.md index 5d7728a..358e151 100644 --- a/docs/guia/instalacao.md +++ b/docs/guia/instalacao.md @@ -231,9 +231,11 @@ ca auth login ``` O `ca auth login` só completa depois de o `redirect_uri` estar registrado no -painel do app — e o provedor da Conta Azul **recusa `http://localhost`**, o -default compilado. A receita com `mkcert`, e o detalhe de cada variável, estão -em [Configuração](configuracao.md). +painel do app — o default compilado já é +`https://conta-azul-cli.ddev.site:9876/callback`, que é o valor que o provedor +aceita. O próprio login emite o certificado TLS e instala a CA no trust store +do usuário; não é preciso clonar o repositório nem rodar `mkcert`. O detalhe +de cada variável está em [Configuração](configuracao.md). ## Clone + Composer diff --git a/docs/guia/solucao-de-problemas.md b/docs/guia/solucao-de-problemas.md index 9e94236..aa7f0a3 100644 --- a/docs/guia/solucao-de-problemas.md +++ b/docs/guia/solucao-de-problemas.md @@ -12,4 +12,4 @@ Não é um erro do CLI: a autenticação funcionou e a chamada chegou à API. A **Erro ao subir o servidor de callback** — a porta 9876 está ocupada. Libere-a; ela é fixa porque precisa bater com o `redirect_uri` registrado no app. -**Navegador acusa certificado inválido no callback** — rode `mkcert -install` para instalar a CA local no trust store do sistema. +**Navegador acusa certificado inválido no callback** — a CA local ainda não está no trust store. Rode `ca auth login` de novo e aceite o diálogo do sistema; se isso não aparecer, confie manualmente em `~/.config/conta-azul-cli/certs/ca.pem` (no macOS, Keychain Access; no Windows, `certutil -user -addstore Root`). diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 30d3298..e28c26b 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -89,7 +89,7 @@ O arquivo é reinterpretado aqui em vez de lido de volta por `getenv()`: a essa ## `auth login` ✅ { #auth-login } -Sem parâmetros. Imprime uma URL, sobe um listener HTTPS local na porta **9876** e aguarda o redirect. Tokens vão para `~/.config/conta-azul-cli/tokens.json` com permissão `0600`. +Sem parâmetros. Imprime uma URL, sobe um listener HTTPS local na porta **9876** e aguarda o redirect. Na primeira vez emite o certificado em `~/.config/conta-azul-cli/certs/` e instala a CA no trust store do usuário. Tokens vão para `~/.config/conta-azul-cli/tokens.json` com permissão `0600`. O refresh é automático e invisível — não existe comando para isso. Renovação preventiva a menos de 60 s da expiração, e reativa uma vez em caso de `401`. diff --git a/docs/referencia/auth.md b/docs/referencia/auth.md index a09b08d..b7fe01e 100644 --- a/docs/referencia/auth.md +++ b/docs/referencia/auth.md @@ -5,7 +5,7 @@ ## `auth login` ✅ { #auth-login } -Sem parâmetros. Imprime uma URL, sobe um listener HTTPS local na porta **9876** e aguarda o redirect. Tokens vão para `~/.config/conta-azul-cli/tokens.json` com permissão `0600`. +Sem parâmetros. Imprime uma URL, sobe um listener HTTPS local na porta **9876** e aguarda o redirect. Na primeira vez emite o certificado em `~/.config/conta-azul-cli/certs/` e instala a CA no trust store do usuário. Tokens vão para `~/.config/conta-azul-cli/tokens.json` com permissão `0600`. O refresh é automático e invisível — não existe comando para isso. Renovação preventiva a menos de 60 s da expiração, e reativa uma vez em caso de `401`. diff --git a/src/Auth/CallbackCertificateProvisioner.php b/src/Auth/CallbackCertificateProvisioner.php new file mode 100644 index 0000000..53a57f1 --- /dev/null +++ b/src/Auth/CallbackCertificateProvisioner.php @@ -0,0 +1,114 @@ +usesTls($config)) { + return new CallbackTlsMaterial(null, null); + } + + $explicit = $this->explicitMaterial($config); + if ($explicit !== null) { + return $explicit; + } + + $material = (new LocalCertificateAuthority($this->directory()))->issue($this->redirectHost($config)); + if ($material->caFile !== null) { + $this->trustStore->ensureTrusted($material->caFile); + } + + return $material; + } + + /** Operator-supplied paths that actually exist; missing checkout files are ignored. */ + private function explicitMaterial(Configuration $config): CallbackTlsMaterial|null { + $cert = $config->callbackCertFile; + $key = $config->callbackKeyFile; + if ($cert === null || $key === null) { + return null; + } + + if (! is_readable($cert) || ! is_readable($key)) { + return null; + } + + return new CallbackTlsMaterial($cert, $key); + } + + private function usesTls(Configuration $config): bool { + return parse_url($config->redirectUri, PHP_URL_SCHEME) === 'https'; + } + + private function redirectHost(Configuration $config): string { + $host = parse_url($config->redirectUri, PHP_URL_HOST); + if (! is_string($host) || $host === '') { + throw new CliException( + ErrorKind::ClientError, + false, + 'CA_REDIRECT_URI não contém um host válido: ' . $config->redirectUri, + ); + } + + return $host; + } + + private function directory(): string { + if ($this->directory !== null) { + return $this->directory; + } + + $home = HomeDirectory::resolve(); + if ($home === null) { + throw new CliException( + ErrorKind::ClientError, + false, + 'Não foi possível determinar o diretório home do usuário. ' + . 'Defina CA_CALLBACK_CERT e CA_CALLBACK_KEY com caminhos absolutos.', + ); + } + + return $home . ConfigFileLocator::USER_DIRECTORY . '/certs'; + } +} diff --git a/src/Auth/CallbackTlsMaterial.php b/src/Auth/CallbackTlsMaterial.php new file mode 100644 index 0000000..8cb6641 --- /dev/null +++ b/src/Auth/CallbackTlsMaterial.php @@ -0,0 +1,33 @@ +certFile !== null && $this->keyFile !== null; + } +} diff --git a/src/Auth/LocalCertificateAuthority.php b/src/Auth/LocalCertificateAuthority.php new file mode 100644 index 0000000..e642110 --- /dev/null +++ b/src/Auth/LocalCertificateAuthority.php @@ -0,0 +1,384 @@ +assertHost($host); + $this->ensureDirectory(); + $this->ensureCa(); + $this->ensureLeaf($host); + + return new CallbackTlsMaterial( + $this->leafCertificatePath(), + $this->leafKeyPath(), + $this->caCertificatePath(), + ); + } + + /** Rejects a host that cannot be interpolated into an OpenSSL config. */ + private function assertHost(string $host): void { + if (preg_match('/^[A-Za-z0-9.-]+$/', $host) !== 1) { + throw new CliException( + ErrorKind::ClientError, + false, + 'CA_REDIRECT_URI contém um host que não pode ser usado no certificado TLS: ' . $host, + ); + } + } + + /** Creates the certs directory with the same 0700 policy as tokens. */ + private function ensureDirectory(): void { + if (is_dir($this->directory)) { + return; + } + + if (! mkdir($this->directory, 0700, true) && ! is_dir($this->directory)) { + throw new CliException( + ErrorKind::ClientError, + false, + 'Não foi possível criar o diretório ' . $this->directory . '.', + ); + } + + chmod($this->directory, 0700); + } + + /** Issues the CA once; later logins reuse it so the trust prompt happens once. */ + private function ensureCa(): void { + if (is_readable($this->caCertificatePath()) && is_readable($this->caKeyPath())) { + return; + } + + $config = $this->writeConfig(self::CA_CN, null); + try { + $key = $this->newKey($config); + $csr = $this->newCsr(self::CA_CN, $key, $config); + $cert = openssl_csr_sign( + $csr, + null, + $key, + self::CA_DAYS, + [ + 'config' => $config, + 'digest_alg' => 'sha256', + 'x509_extensions' => 'v3_ca', + ], + random_int(1, 2_147_483_647), + ); + if ($cert === false) { + throw $this->opensslFailure('emitir a autoridade certificadora local'); + } + + $this->exportCertificate($cert, $this->caCertificatePath()); + $this->exportKey($key, $this->caKeyPath(), $config); + } finally { + unlink($config); + } + } + + /** Issues or replaces the leaf when it is missing, expired, or for another host. */ + private function ensureLeaf(string $host): void { + if ($this->leafCovers($host)) { + return; + } + + $caPem = file_get_contents($this->caCertificatePath()); + $caKeyPem = file_get_contents($this->caKeyPath()); + if ($caPem === false || $caKeyPem === false) { + throw new CliException( + ErrorKind::ClientError, + false, + 'Não foi possível ler a autoridade certificadora local em ' . $this->directory . '.', + ); + } + + $caCert = openssl_x509_read($caPem); + $caKey = openssl_pkey_get_private($caKeyPem); + if ($caCert === false || $caKey === false) { + throw new CliException( + ErrorKind::ClientError, + false, + 'Não foi possível carregar a autoridade certificadora local em ' . $this->directory . '.', + ); + } + + $config = $this->writeConfig($host, $host); + try { + $key = $this->newKey($config); + $csr = $this->newCsr($host, $key, $config); + $cert = openssl_csr_sign( + $csr, + $caCert, + $caKey, + self::LEAF_DAYS, + [ + 'config' => $config, + 'digest_alg' => 'sha256', + 'x509_extensions' => 'v3_req', + ], + random_int(1, 2_147_483_647), + ); + if ($cert === false) { + throw $this->opensslFailure('emitir o certificado TLS do callback'); + } + + $this->exportCertificate($cert, $this->leafCertificatePath()); + $this->exportKey($key, $this->leafKeyPath(), $config); + } finally { + unlink($config); + } + } + + /** Whether the on-disk leaf is still valid and names this host in its SAN. */ + private function leafCovers(string $host): bool { + if (! is_readable($this->leafCertificatePath()) || ! is_readable($this->leafKeyPath())) { + return false; + } + + $pem = file_get_contents($this->leafCertificatePath()); + if ($pem === false) { + return false; + } + + $parsed = openssl_x509_parse($pem); + if ($parsed === false || ($parsed['validTo_time_t'] ?? 0) < time() + 86_400) { + return false; + } + + $extensions = $parsed['extensions'] ?? null; + if (! is_array($extensions)) { + return false; + } + + $san = $extensions['subjectAltName'] ?? ''; + if (! is_string($san)) { + return false; + } + + foreach (explode(',', $san) as $entry) { + if (strcasecmp(trim($entry), 'DNS:' . $host) === 0) { + return true; + } + } + + return false; + } + + /** @return non-empty-string */ + private function writeConfig(string $commonName, string|null $sanHost): string { + $altNames = 'DNS.1 = localhost'; + if ($sanHost !== null) { + $altNames = 'DNS.1 = ' . $sanHost; + } + + $body = << $config, + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ], + ); + if ($key === false) { + throw $this->opensslFailure('gerar uma chave privada'); + } + + return $key; + } + + private function newCsr( + string $commonName, + OpenSSLAsymmetricKey $key, + string $config, + ): OpenSSLCertificateSigningRequest { + $csr = openssl_csr_new( + ['commonName' => $commonName], + $key, + [ + 'config' => $config, + 'digest_alg' => 'sha256', + 'req_extensions' => 'v3_req', + ], + ); + if (! $csr instanceof OpenSSLCertificateSigningRequest) { + throw $this->opensslFailure('gerar o pedido de certificado'); + } + + return $csr; + } + + private function exportCertificate(OpenSSLCertificate $certificate, string $path): void { + $pem = ''; + if (! openssl_x509_export($certificate, $pem) || ! is_string($pem) || $pem === '') { + throw $this->opensslFailure('exportar o certificado'); + } + + $this->writePrivateFile($path, $pem); + } + + private function exportKey(OpenSSLAsymmetricKey $key, string $path, string $config): void { + $pem = ''; + $exported = openssl_pkey_export( + $key, + $pem, + null, + ['config' => $config, 'encrypt_key' => false], + ); + if (! $exported || ! is_string($pem) || $pem === '') { + throw $this->opensslFailure('exportar a chave privada'); + } + + $this->writePrivateFile($path, $pem); + } + + private function writePrivateFile(string $path, string $contents): void { + if (file_put_contents($path, $contents, LOCK_EX) === false) { + throw new CliException( + ErrorKind::ClientError, + false, + 'Não foi possível escrever ' . $path . '.', + ); + } + + chmod($path, 0600); + } + + private function opensslFailure(string $action): CliException { + $parts = []; + while (true) { + $error = openssl_error_string(); + if ($error === false) { + break; + } + + $parts[] = $error; + } + + $detail = $parts === [] ? 'erro OpenSSL desconhecido' : implode('; ', $parts); + + return new CliException( + ErrorKind::ClientError, + false, + 'Não foi possível ' . $action . ': ' . $detail, + ); + } + + private function caCertificatePath(): string { + return $this->directory . '/ca.pem'; + } + + private function caKeyPath(): string { + return $this->directory . '/ca-key.pem'; + } + + private function leafCertificatePath(): string { + return $this->directory . '/cert.pem'; + } + + private function leafKeyPath(): string { + return $this->directory . '/key.pem'; + } +} diff --git a/src/Auth/SystemTrustStoreInstaller.php b/src/Auth/SystemTrustStoreInstaller.php new file mode 100644 index 0000000..61a2dc2 --- /dev/null +++ b/src/Auth/SystemTrustStoreInstaller.php @@ -0,0 +1,174 @@ +): int)|null $runCommand Optional command runner; defaults to proc_open. + */ + public function __construct( + private readonly string $osFamily = PHP_OS_FAMILY, + private readonly mixed $runCommand = null, + ) { + } + + /** {@inheritDoc} */ + public function ensureTrusted(string $caCertificatePath): void { + if ($this->isTrusted($caCertificatePath)) { + return; + } + + $this->install($caCertificatePath); + + if ($this->isTrusted($caCertificatePath)) { + return; + } + + throw new CliException( + ErrorKind::ClientError, + false, + 'Não foi possível instalar a autoridade certificadora local no trust store do sistema. ' + . 'O navegador recusará o callback HTTPS até você confiar neste arquivo e rodar ' + . '"ca auth login" de novo: ' . $caCertificatePath, + ); + } + + /** Whether the CA is already accepted for TLS server authentication. */ + private function isTrusted(string $caCertificatePath): bool { + $command = $this->verifyCommand($caCertificatePath); + if ($command === null) { + return false; + } + + return $this->run($command) === 0; + } + + /** Adds the CA to the user trust store, prompting the OS as needed. */ + private function install(string $caCertificatePath): void { + $command = $this->installCommand($caCertificatePath); + if ($command === null) { + throw new CliException( + ErrorKind::ClientError, + false, + 'Não há um comando conhecido para instalar a CA local nesta plataforma. ' + . 'Confie manualmente neste arquivo e rode "ca auth login" de novo: ' + . $caCertificatePath, + ); + } + + $this->run($command); + } + + /** @return list|null */ + private function verifyCommand(string $caCertificatePath): array|null { + return match ($this->osFamily) { + 'Darwin' => ['/usr/bin/security', 'verify-cert', '-c', $caCertificatePath], + 'Windows' => ['certutil', '-user', '-verify', $caCertificatePath], + 'Linux' => $this->nssCommand(['-L', '-n', 'conta-azul-cli local CA']), + default => null, + }; + } + + /** @return list|null */ + private function installCommand(string $caCertificatePath): array|null { + return match ($this->osFamily) { + 'Darwin' => $this->darwinInstallCommand($caCertificatePath), + 'Windows' => ['certutil', '-user', '-addstore', 'Root', $caCertificatePath], + 'Linux' => $this->nssCommand( + ['-A', '-t', 'C,,', '-n', 'conta-azul-cli local CA', '-i', $caCertificatePath], + ), + default => null, + }; + } + + /** @return list|null */ + private function darwinInstallCommand(string $caCertificatePath): array|null { + $home = getenv('HOME'); + if (! is_string($home) || $home === '') { + return null; + } + + return [ + '/usr/bin/security', + 'add-trusted-cert', + '-r', + 'trustRoot', + '-k', + $home . '/Library/Keychains/login.keychain-db', + $caCertificatePath, + ]; + } + + /** + * @param list $arguments + * + * @return list|null + */ + private function nssCommand(array $arguments): array|null { + $home = getenv('HOME'); + if (! is_string($home) || $home === '') { + return null; + } + + return ['certutil', '-d', 'sql:' . $home . '/.pki/nssdb', ...$arguments]; + } + + /** @param list $command */ + private function run(array $command): int { + $runner = $this->runCommand; + if (is_callable($runner)) { + return (int) $runner($command); + } + + return $this->procOpen($command); + } + + /** @param list $command */ + private function procOpen(array $command): int { + $pipes = []; + $process = proc_open( + $command, + [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], + $pipes, + ); + if (! is_resource($process)) { + return 1; + } + + fclose($pipes[0]); + stream_get_contents($pipes[1]); + stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + $status = proc_close($process); + + return $status === -1 ? 1 : $status; + } +} diff --git a/src/Auth/TrustStoreInstaller.php b/src/Auth/TrustStoreInstaller.php new file mode 100644 index 0000000..136ecb8 --- /dev/null +++ b/src/Auth/TrustStoreInstaller.php @@ -0,0 +1,17 @@ +formatterSelector); $periodoPadrao = new PeriodoPadrao(); - $tokenStore = new TokenStore($config); - $oauthClient = new OAuthClient($httpClient, $config); - $authManager = new AuthManager($tokenStore, $oauthClient, $config); - $callbackServer = new CallbackServer( - timeoutSeconds: $config->callbackTimeout, - certFile: $config->callbackCertFile, - keyFile: $config->callbackKeyFile, - ); + $tokenStore = new TokenStore($config); + $oauthClient = new OAuthClient($httpClient, $config); + $authManager = new AuthManager($tokenStore, $oauthClient, $config); + $certificates = new CallbackCertificateProvisioner(); $financeiroClient = new FinanceiroClient($config, $authManager, $this->logger, $redactor, $httpClient); $pessoasClient = new PessoasClient($config, $authManager, $this->logger, $redactor, $httpClient); @@ -100,7 +96,7 @@ public function build(): ApplicationComponents { return new ApplicationComponents( $this->logger, [ - new AuthCommandModule($authManager, $callbackServer, $errorEnvelope), + new AuthCommandModule($authManager, $certificates, $config, $errorEnvelope), new FinanceiroCommandModule( $financeiroClient, $errorEnvelope, diff --git a/src/Command/Auth/LoginCommand.php b/src/Command/Auth/LoginCommand.php index 42ca12c..2ad1bee 100644 --- a/src/Command/Auth/LoginCommand.php +++ b/src/Command/Auth/LoginCommand.php @@ -5,8 +5,10 @@ namespace ContaAzulCli\Command\Auth; use ContaAzulCli\Auth\AuthManager; +use ContaAzulCli\Auth\CallbackCertificateProvisioner; use ContaAzulCli\Auth\CallbackServer; use ContaAzulCli\Command\Support\CommandExecutor; +use ContaAzulCli\Config\Configuration; use ContaAzulCli\Output\ErrorEnvelope; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -21,7 +23,8 @@ final class LoginCommand extends Command /** Creates the command and its authentication collaborators. */ public function __construct( private readonly AuthManager $authManager, - private readonly CallbackServer $callbackServer, + private readonly CallbackCertificateProvisioner $certificates, + private readonly Configuration $config, private readonly ErrorEnvelope $errorEnvelope, CommandExecutor|null $commandExecutor = null, ) { @@ -34,13 +37,20 @@ public function __construct( public function __invoke(OutputInterface $output): int { return $this->commandExecutor->execute( function () use ($output): void { + // Certificates before the URL: the first login may prompt to trust + // the local CA, and the operator should not race the browser past that. + $tls = $this->certificates->ensure($this->config); $authUrl = $this->authManager->startLoginFlow(); $output->writeln("Abra este URL no seu navegador:\n"); $output->writeln($authUrl); $output->writeln("\nAguardando callback OAuth na porta 9876..."); $state = $this->authManager->getPendingState() ?? ''; - $code = $this->callbackServer->waitForCallback($state); + $code = (new CallbackServer( + timeoutSeconds: $this->config->callbackTimeout, + certFile: $tls->certFile, + keyFile: $tls->keyFile, + ))->waitForCallback($state); $this->authManager->completeLoginFlow($code); $output->writeln("\nAutenticado com sucesso!"); diff --git a/src/Command/Module/AuthCommandModule.php b/src/Command/Module/AuthCommandModule.php index efd173b..52c489f 100644 --- a/src/Command/Module/AuthCommandModule.php +++ b/src/Command/Module/AuthCommandModule.php @@ -5,10 +5,11 @@ namespace ContaAzulCli\Command\Module; use ContaAzulCli\Auth\AuthManager; -use ContaAzulCli\Auth\CallbackServer; +use ContaAzulCli\Auth\CallbackCertificateProvisioner; use ContaAzulCli\Command\Auth\LoginCommand; use ContaAzulCli\Command\Auth\LogoutCommand; use ContaAzulCli\Command\CommandModuleInterface; +use ContaAzulCli\Config\Configuration; use ContaAzulCli\Output\ErrorEnvelope; use Symfony\Component\Console\Command\Command; @@ -18,7 +19,8 @@ final class AuthCommandModule implements CommandModuleInterface /** Connects authentication services used by login and logout commands. */ public function __construct( private readonly AuthManager $authManager, - private readonly CallbackServer $callbackServer, + private readonly CallbackCertificateProvisioner $certificates, + private readonly Configuration $config, private readonly ErrorEnvelope $errorEnvelope, ) { } @@ -26,7 +28,7 @@ public function __construct( /** @return list */ public function commands(): array { return [ - new LoginCommand($this->authManager, $this->callbackServer, $this->errorEnvelope), + new LoginCommand($this->authManager, $this->certificates, $this->config, $this->errorEnvelope), new LogoutCommand($this->authManager), ]; } diff --git a/src/Config/ConfigFileLocator.php b/src/Config/ConfigFileLocator.php index 4994c18..5afab1b 100644 --- a/src/Config/ConfigFileLocator.php +++ b/src/Config/ConfigFileLocator.php @@ -27,8 +27,8 @@ final class ConfigFileLocator /** Explicit per-invocation escape hatch, evaluated before anything else. */ public const string OVERRIDE_VARIABLE = 'CA_CLI_ENV_FILE'; - /** Directory, under the user's home, that already holds tokens and the log. */ - private const string USER_DIRECTORY = '/.config/conta-azul-cli'; + /** Directory, under the user's home, that already holds tokens, certs, and the log. */ + public const string USER_DIRECTORY = '/.config/conta-azul-cli'; /** * Binds the locator to a project root and a PHAR context. @@ -87,15 +87,26 @@ public function locate(): ConfigFileResolution { return new ConfigFileResolution($resolved, $candidates); } + /** + * Returns the user-level directory that holds `.env`, tokens, and certs. + * + * Null means the platform gave us no home directory at all. + */ + public function userDirectory(): string|null { + $home = HomeDirectory::resolve(); + + return $home === null ? null : $home . self::USER_DIRECTORY; + } + /** * Returns the user-level file, which `ca config init` always targets. * * Null means the platform gave us no home directory at all. */ public function userFile(): string|null { - $home = HomeDirectory::resolve(); + $directory = $this->userDirectory(); - return $home === null ? null : $home . self::USER_DIRECTORY . '/.env'; + return $directory === null ? null : $directory . '/.env'; } /** diff --git a/src/Config/ConfigKeys.php b/src/Config/ConfigKeys.php index 8060ce4..5081262 100644 --- a/src/Config/ConfigKeys.php +++ b/src/Config/ConfigKeys.php @@ -35,7 +35,7 @@ final class ConfigKeys 'CA_CLIENT_ID' => null, 'CA_CLIENT_SECRET' => null, 'CA_CLI_TOKEN_PATH' => '~/.config/conta-azul-cli/tokens.json', - 'CA_REDIRECT_URI' => 'http://localhost:9876/callback', + 'CA_REDIRECT_URI' => 'https://conta-azul-cli.ddev.site:9876/callback', 'CA_SCOPE' => null, 'CA_TOKEN_URL' => '{CA_AUTH_BASE_URL}/oauth2/token', ]; diff --git a/src/Config/EnvFileTemplate.php b/src/Config/EnvFileTemplate.php index cf57984..271e95a 100644 --- a/src/Config/EnvFileTemplate.php +++ b/src/Config/EnvFileTemplate.php @@ -39,9 +39,11 @@ final class EnvFileTemplate # CA_SCOPE='openid profile aws.cognito.signin.user.admin' # URI de redirecionamento OAuth2, igual à cadastrada no portal. - # CA_REDIRECT_URI=http://localhost:9876/callback + # O default (HTTPS em conta-azul-cli.ddev.site) é o que o portal aceita. + # CA_REDIRECT_URI=https://conta-azul-cli.ddev.site:9876/callback - # Certificado TLS local do servidor de callback, quando o portal exigir HTTPS. + # Override do certificado TLS do callback. Sem isto, "ca auth login" + # gera e instala um em ~/.config/conta-azul-cli/certs/. # CA_CALLBACK_CERT=/caminho/para/cert.pem # CA_CALLBACK_KEY=/caminho/para/key.pem diff --git a/src/Config/EnvironmentConfigurationLoader.php b/src/Config/EnvironmentConfigurationLoader.php index 9a5916b..715fca6 100644 --- a/src/Config/EnvironmentConfigurationLoader.php +++ b/src/Config/EnvironmentConfigurationLoader.php @@ -65,7 +65,10 @@ public function read(): array { 'callbackTimeout' => $this->callbackTimeout(), 'clientId' => $this->requireEnv('CA_CLIENT_ID'), 'clientSecret' => $this->requireEnv('CA_CLIENT_SECRET'), - 'redirectUri' => $this->getEnv('CA_REDIRECT_URI', 'http://localhost:9876/callback'), + 'redirectUri' => $this->getEnv( + 'CA_REDIRECT_URI', + 'https://conta-azul-cli.ddev.site:9876/callback', + ), 'scope' => $this->nullableEnv('CA_SCOPE'), 'tokenPath' => $this->expandHome($this->getEnv( 'CA_CLI_TOKEN_PATH', diff --git a/tests/Unit/Auth/CallbackCertificateProvisionerTest.php b/tests/Unit/Auth/CallbackCertificateProvisionerTest.php new file mode 100644 index 0000000..dc34251 --- /dev/null +++ b/tests/Unit/Auth/CallbackCertificateProvisionerTest.php @@ -0,0 +1,129 @@ +removeTemporaryDirectories(); + } + + public function testHttpRedirectSkipsTlsAndDoesNotWriteFiles(): void { + $directory = $this->makeTemporaryDirectory('ca-cli-certs'); + $trustStore = $this->recordingTrustStore(); + $provisioner = new CallbackCertificateProvisioner($directory, $trustStore); + + $material = $provisioner->ensure($this->config(['redirectUri' => 'http://localhost:9876/callback'])); + + self::assertFalse($material->usesTls()); + self::assertNull($material->certFile); + self::assertSame([], $trustStore->paths); + self::assertFileDoesNotExist($directory . '/cert.pem'); + } + + public function testHttpsRedirectIssuesCertsAndTrustsTheCa(): void { + $directory = $this->makeTemporaryDirectory('ca-cli-certs'); + $trustStore = $this->recordingTrustStore(); + $provisioner = new CallbackCertificateProvisioner($directory, $trustStore); + + $material = $provisioner->ensure($this->config()); + + self::assertTrue($material->usesTls()); + self::assertSame($directory . '/cert.pem', $material->certFile); + self::assertSame($directory . '/key.pem', $material->keyFile); + self::assertSame([$directory . '/ca.pem'], $trustStore->paths); + } + + public function testExplicitReadableCertsWinAndSkipTheLocalCa(): void { + $directory = $this->makeTemporaryDirectory('ca-cli-certs'); + $cert = $directory . '/custom-cert.pem'; + $key = $directory . '/custom-key.pem'; + file_put_contents($cert, 'placeholder-cert'); + file_put_contents($key, 'placeholder-key'); + + $autoDir = $this->makeTemporaryDirectory('ca-cli-auto'); + $trustStore = $this->recordingTrustStore(); + $provisioner = new CallbackCertificateProvisioner($autoDir, $trustStore); + + $material = $provisioner->ensure($this->config([ + 'callbackCertFile' => $cert, + 'callbackKeyFile' => $key, + ])); + + self::assertSame($cert, $material->certFile); + self::assertSame($key, $material->keyFile); + self::assertNull($material->caFile); + self::assertSame([], $trustStore->paths); + self::assertFileDoesNotExist($autoDir . '/cert.pem'); + } + + /** + * A copied `.env` pointing at a checkout Homebrew never installed must not + * block login: missing override paths fall through to generated certs. + */ + public function testMissingExplicitPathsFallThroughToGeneratedCerts(): void { + $directory = $this->makeTemporaryDirectory('ca-cli-certs'); + $trustStore = $this->recordingTrustStore(); + $provisioner = new CallbackCertificateProvisioner($directory, $trustStore); + + $material = $provisioner->ensure($this->config([ + 'callbackCertFile' => $directory . '/missing-cert.pem', + 'callbackKeyFile' => $directory . '/missing-key.pem', + ])); + + self::assertSame($directory . '/cert.pem', $material->certFile); + self::assertSame([$directory . '/ca.pem'], $trustStore->paths); + } + + /** + * @param array{ + * callbackCertFile?: ?string, + * callbackKeyFile?: ?string, + * redirectUri?: string + * } $overrides + */ + private function config(array $overrides = []): Configuration { + return Configuration::fromValues( + [ + 'apiBaseUrl' => 'https://api.example.test', + 'authBaseUrl' => 'https://auth.example.test', + 'authorizeUrl' => 'https://auth.example.test/authorize', + 'bootstrapRefreshToken' => null, + 'callbackCertFile' => $overrides['callbackCertFile'] ?? null, + 'callbackKeyFile' => $overrides['callbackKeyFile'] ?? null, + 'callbackTimeout' => 30, + 'clientId' => 'client', + 'clientSecret' => 'secret', + 'redirectUri' => $overrides['redirectUri'] ?? 'https://conta-azul-cli.ddev.site:9876/callback', + 'scope' => null, + 'tokenPath' => sys_get_temp_dir() . '/tokens.json', + 'tokenUrl' => 'https://auth.example.test/token', + ], + ); + } + + /** @return TrustStoreInstaller&object{paths: list} */ + private function recordingTrustStore(): TrustStoreInstaller { + return new class implements TrustStoreInstaller { + /** @var list */ + public array $paths = []; + + public function ensureTrusted(string $caCertificatePath): void { + $this->paths[] = $caCertificatePath; + } + }; + } +} diff --git a/tests/Unit/Auth/LocalCertificateAuthorityTest.php b/tests/Unit/Auth/LocalCertificateAuthorityTest.php new file mode 100644 index 0000000..f7b3827 --- /dev/null +++ b/tests/Unit/Auth/LocalCertificateAuthorityTest.php @@ -0,0 +1,185 @@ + */ + private array $processes = []; + + protected function tearDown(): void { + foreach ($this->processes as $process) { + // phpcs:ignore Generic.PHP.NoSilencedErrors -- process may already have exited. + @proc_terminate($process); + // phpcs:ignore Generic.PHP.NoSilencedErrors + @proc_close($process); + } + + $this->processes = []; + $this->removeTemporaryDirectories(); + } + + public function testIssuesALeafWhoseSanMatchesTheHost(): void { + $directory = $this->makeTemporaryDirectory('ca-cli-certs'); + $material = (new LocalCertificateAuthority($directory))->issue('conta-azul-cli.ddev.site'); + + self::assertNotNull($material->certFile); + self::assertNotNull($material->keyFile); + self::assertNotNull($material->caFile); + self::assertFileExists($material->certFile); + self::assertFileExists($material->keyFile); + self::assertFileExists($material->caFile); + + $parsed = openssl_x509_parse((string) file_get_contents($material->certFile)); + self::assertIsArray($parsed); + self::assertStringContainsString( + 'DNS:conta-azul-cli.ddev.site', + (string) ($parsed['extensions']['subjectAltName'] ?? ''), + ); + } + + public function testReusesAnExistingLeafForTheSameHost(): void { + $directory = $this->makeTemporaryDirectory('ca-cli-certs'); + $authority = new LocalCertificateAuthority($directory); + $first = $authority->issue('conta-azul-cli.ddev.site'); + $firstLeaf = (string) file_get_contents((string) $first->certFile); + $firstCa = (string) file_get_contents((string) $first->caFile); + + $second = $authority->issue('conta-azul-cli.ddev.site'); + + self::assertSame($firstLeaf, file_get_contents((string) $second->certFile)); + self::assertSame($firstCa, file_get_contents((string) $second->caFile)); + } + + public function testReissuesTheLeafWhenTheHostChangesButKeepsTheCa(): void { + $directory = $this->makeTemporaryDirectory('ca-cli-certs'); + $authority = new LocalCertificateAuthority($directory); + $first = $authority->issue('conta-azul-cli.ddev.site'); + $firstCa = (string) file_get_contents((string) $first->caFile); + $firstLeaf = (string) file_get_contents((string) $first->certFile); + + $second = $authority->issue('other.ddev.site'); + $secondLeaf = (string) file_get_contents((string) $second->certFile); + $parsed = openssl_x509_parse($secondLeaf); + + self::assertNotSame($firstLeaf, $secondLeaf); + self::assertSame($firstCa, file_get_contents((string) $second->caFile)); + self::assertIsArray($parsed); + self::assertStringContainsString('DNS:other.ddev.site', (string) ($parsed['extensions']['subjectAltName'] ?? '')); + } + + public function testRejectsAHostThatCannotGoIntoAnOpensslConfig(): void { + $directory = $this->makeTemporaryDirectory('ca-cli-certs'); + + $this->expectException(CliException::class); + $this->expectExceptionMessageMatches('/host que não pode ser usado/'); + + (new LocalCertificateAuthority($directory))->issue("bad\nhost"); + } + + public function testPrivateKeysAreNotReadableByOtherUsers(): void { + self::requirePosixPermissions(); + + $directory = $this->makeTemporaryDirectory('ca-cli-certs'); + $material = (new LocalCertificateAuthority($directory))->issue('conta-azul-cli.ddev.site'); + + self::assertPermissions('700', $directory); + self::assertPermissions('600', (string) $material->keyFile); + self::assertPermissions('600', (string) $material->caFile); + } + + /** + * The generated pair has to be something PHP's TLS listener will actually + * present: a SAN mismatch or a CA:TRUE leaf would pass openssl_x509_parse + * and still fail the browser redirect. + */ + public function testGeneratedPairServesATlsCallback(): void { + $directory = $this->makeTemporaryDirectory('ca-cli-certs'); + $material = (new LocalCertificateAuthority($directory))->issue('conta-azul-cli.ddev.site'); + $port = $this->freePort(); + $this->spawnTlsClient( + $port, + (string) $material->caFile, + "GET /callback?code=from-tls&state=st-tls HTTP/1.1\r\nHost: conta-azul-cli.ddev.site\r\n\r\n", + ); + + $server = new CallbackServer( + port: $port, + timeoutSeconds: 15, + certFile: $material->certFile, + keyFile: $material->keyFile, + ); + + self::assertSame('from-tls', $server->waitForCallback('st-tls')); + } + + private function freePort(): int { + $probe = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); + self::assertIsResource($probe, 'não foi possível reservar uma porta livre: ' . $errstr); + $name = stream_socket_get_name($probe, false); + fclose($probe); + self::assertIsString($name); + + return (int) substr($name, (int) strrpos($name, ':') + 1); + } + + private function spawnTlsClient(int $port, string $caFile, string $payload): void { + $script = sprintf( + '$ctx = stream_context_create(["ssl" => [' + . '"cafile" => %s, "verify_peer" => true, "verify_peer_name" => true,' + . ' "peer_name" => "conta-azul-cli.ddev.site", "allow_self_signed" => false,' + . ']]);' + . '$sock = false; $deadline = microtime(true) + 10;' + . 'while ($sock === false && microtime(true) < $deadline) {' + . ' $sock = @stream_socket_client("ssl://127.0.0.1:%d", $e, $s, 1,' + . ' STREAM_CLIENT_CONNECT, $ctx);' + . ' if ($sock === false) { usleep(50000); }' + . '}' + . 'if ($sock === false) { fwrite(STDERR, $s ?? "connect failed"); exit(1); }' + . 'fwrite($sock, %s); sleep(2);', + // Not a debugging leftover: this generates the PHP literal embedded in + // the subprocess script below, and var_export() is the standard tool + // for that. + // phpcs:ignore ContaAzulCli.Functions.DebuggingFunctions + var_export($caFile, true), + $port, + // phpcs:ignore ContaAzulCli.Functions.DebuggingFunctions + var_export($payload, true), + ); + + $process = proc_open( + [PHP_BINARY, '-r', $script], + [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], + $pipes, + ); + self::assertIsResource($process); + $this->processes[] = $process; + } +} diff --git a/tests/Unit/Auth/SystemTrustStoreInstallerTest.php b/tests/Unit/Auth/SystemTrustStoreInstallerTest.php new file mode 100644 index 0000000..f7b18c4 --- /dev/null +++ b/tests/Unit/Auth/SystemTrustStoreInstallerTest.php @@ -0,0 +1,114 @@ +originalHome = getenv('HOME'); + putenv('HOME=/Users/operator'); + } + + protected function tearDown(): void { + putenv($this->originalHome === false ? 'HOME' : 'HOME=' . $this->originalHome); + } + + public function testSkipsInstallWhenTheCaIsAlreadyTrusted(): void { + $ran = []; + $installer = new SystemTrustStoreInstaller( + 'Darwin', + static function (array $command) use (&$ran): int { + $ran[] = $command; + + return 0; + }, + ); + + $installer->ensureTrusted('/tmp/ca.pem'); + + self::assertCount(1, $ran); + self::assertSame(['/usr/bin/security', 'verify-cert', '-c', '/tmp/ca.pem'], $ran[0]); + } + + public function testInstallsOnDarwinWhenVerifyFailsThenSucceeds(): void { + $ran = []; + $installer = new SystemTrustStoreInstaller( + 'Darwin', + static function (array $command) use (&$ran): int { + $ran[] = $command; + + return $command[1] === 'verify-cert' && $ran === [['/usr/bin/security', 'verify-cert', '-c', '/tmp/ca.pem']] + ? 1 + : 0; + }, + ); + + $installer->ensureTrusted('/tmp/ca.pem'); + + self::assertSame( + [ + ['/usr/bin/security', 'verify-cert', '-c', '/tmp/ca.pem'], + [ + '/usr/bin/security', + 'add-trusted-cert', + '-r', + 'trustRoot', + '-k', + '/Users/operator/Library/Keychains/login.keychain-db', + '/tmp/ca.pem', + ], + ['/usr/bin/security', 'verify-cert', '-c', '/tmp/ca.pem'], + ], + $ran, + ); + } + + public function testUsesCertutilOnWindows(): void { + $ran = []; + $installer = new SystemTrustStoreInstaller( + 'Windows', + static function (array $command) use (&$ran): int { + $ran[] = $command; + + return $command[2] === '-verify' && $command === $ran[0] && count($ran) === 1 ? 1 : 0; + }, + ); + + $installer->ensureTrusted('C:\\ca.pem'); + + self::assertSame('certutil', $ran[0][0]); + self::assertSame('-verify', $ran[0][2]); + self::assertSame('-addstore', $ran[1][2]); + self::assertSame('Root', $ran[1][3]); + } + + public function testUnknownPlatformExplainsHowToTrustTheCaByHand(): void { + $installer = new SystemTrustStoreInstaller('NetBSD', static fn (array $command): int => 0); + + $this->expectException(CliException::class); + $this->expectExceptionMessageMatches('/Confie manualmente/'); + + $installer->ensureTrusted('/tmp/ca.pem'); + } + + public function testThrowsWhenInstallDoesNotMakeTheCaTrusted(): void { + $installer = new SystemTrustStoreInstaller('Darwin', static fn (array $command): int => 1); + + $this->expectException(CliException::class); + $this->expectExceptionMessageMatches('/Não foi possível instalar a autoridade certificadora/'); + + $installer->ensureTrusted('/tmp/ca.pem'); + } +} diff --git a/tests/Unit/Config/ConfigFileLocatorTest.php b/tests/Unit/Config/ConfigFileLocatorTest.php index 1ceb057..14907b2 100644 --- a/tests/Unit/Config/ConfigFileLocatorTest.php +++ b/tests/Unit/Config/ConfigFileLocatorTest.php @@ -180,6 +180,10 @@ public function testResolvesToNullWhenNoCandidateExists(): void { public function testUserFileIsAlwaysTheUserDirectoryOne(): void { $this->writeEnv($this->projectRoot . '/.env'); + self::assertSame( + $this->home . '/.config/conta-azul-cli', + (new ConfigFileLocator($this->projectRoot, null))->userDirectory(), + ); self::assertSame( $this->home . '/.config/conta-azul-cli/.env', (new ConfigFileLocator($this->projectRoot, null))->userFile(), diff --git a/tests/Unit/Config/ConfigurationTest.php b/tests/Unit/Config/ConfigurationTest.php index 5b5ecf5..fa4fee3 100644 --- a/tests/Unit/Config/ConfigurationTest.php +++ b/tests/Unit/Config/ConfigurationTest.php @@ -86,6 +86,15 @@ public function testDefaultAuthBaseUrl(): void { self::assertSame('https://auth.contaazul.com', $config->authBaseUrl); } + public function testRedirectUriDefaultsToTheHttpsCallbackHost(): void { + putenv('CA_CLIENT_ID=id'); + putenv('CA_CLIENT_SECRET=secret'); + + $config = new Configuration(); + + self::assertSame('https://conta-azul-cli.ddev.site:9876/callback', $config->redirectUri); + } + public function testCallbackTimeoutDefaultsToFiveMinutes(): void { putenv('CA_CLIENT_ID=id'); putenv('CA_CLIENT_SECRET=secret');