From d69c1808d9b33753f0fa9742728565512feaef57 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 6 Sep 2026 00:53:10 +0200 Subject: [PATCH 01/27] feat(auth): sign in via device authorization and serve git credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `archgate login` now runs an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate identity provider instead of GitHub, and stores the resulting access and refresh tokens rather than a single long-lived plugin token. Access tokens are short-lived, which the old storage model cannot carry: plugin repositories are cloned over HTTPS and git reads the stored credential itself, so a token written once would stop working within the hour. `archgate credential` closes that gap as a git credential helper — git asks it on each request and it answers with a cached access token, renewing from the refresh token only once the cached one has lapsed. This mirrors how `gh auth git-credential` serves github.com. Login registers the helper for `plugins.archgate.dev` alone, writing an empty helper entry before its own so that helpers inherited from a wider config scope are reset and archgate is the only one consulted for that host. Every other remote keeps whatever helper it already had, and `archgate login logout` removes both entries. Tokens are filed in the OS credential manager under `auth.archgate.dev`, not the plugins host: the store reaches the keychain through `git credential`, so keeping them under the host archgate is the helper for would make every read re-enter this process. Tokens issued before this change stay under the plugins host and are still returned unchanged, so existing sessions keep working until the user signs in again. The GitHub device flow, the token claim call and the signup prompts are removed along with their tests; identity and registration now belong to the identity provider. Note for reviewers: any account that can sign in to the identity provider now obtains plugin access. The signup step previously gated that, so if the beta allowlist is still wanted it needs an explicit check on the server rather than in the CLI. Signed-off-by: Rhuan Barreto --- .../docs/nb/reference/cli/credential.mdx | 78 +++ .../content/docs/nb/reference/cli/login.mdx | 32 +- .../docs/pt-br/reference/cli/credential.mdx | 78 +++ .../docs/pt-br/reference/cli/login.mdx | 34 +- .../content/docs/reference/cli/credential.mdx | 78 +++ docs/src/content/docs/reference/cli/login.mdx | 36 +- src/cli.ts | 2 + src/commands/credential.ts | 86 +++ src/commands/init.ts | 5 +- src/commands/login.ts | 4 +- src/helpers/auth.ts | 241 -------- src/helpers/credential-store.ts | 161 ++++-- src/helpers/git-credential-config.ts | 76 +++ src/helpers/git-credential-protocol.ts | 40 ++ src/helpers/init-project.ts | 16 - src/helpers/login-flow.ts | 186 +------ src/helpers/logto-auth.ts | 298 ++++++++++ src/helpers/signup.ts | 76 --- tests/commands/credential.test.ts | 107 ++++ tests/helpers/auth.test.ts | 355 ------------ tests/helpers/credential-store.test.ts | 46 +- tests/helpers/git-credential-config.test.ts | 108 ++++ tests/helpers/git-credential-protocol.test.ts | 69 +++ tests/helpers/login-flow.test.ts | 518 +++--------------- tests/helpers/logto-auth.test.ts | 196 +++++++ tests/helpers/signup.test.ts | 141 ----- 26 files changed, 1516 insertions(+), 1551 deletions(-) create mode 100644 docs/src/content/docs/nb/reference/cli/credential.mdx create mode 100644 docs/src/content/docs/pt-br/reference/cli/credential.mdx create mode 100644 docs/src/content/docs/reference/cli/credential.mdx create mode 100644 src/commands/credential.ts delete mode 100644 src/helpers/auth.ts create mode 100644 src/helpers/git-credential-config.ts create mode 100644 src/helpers/git-credential-protocol.ts create mode 100644 src/helpers/logto-auth.ts delete mode 100644 src/helpers/signup.ts create mode 100644 tests/commands/credential.test.ts delete mode 100644 tests/helpers/auth.test.ts create mode 100644 tests/helpers/git-credential-config.test.ts create mode 100644 tests/helpers/git-credential-protocol.test.ts create mode 100644 tests/helpers/logto-auth.test.ts delete mode 100644 tests/helpers/signup.test.ts diff --git a/docs/src/content/docs/nb/reference/cli/credential.mdx b/docs/src/content/docs/nb/reference/cli/credential.mdx new file mode 100644 index 00000000..9f613775 --- /dev/null +++ b/docs/src/content/docs/nb/reference/cli/credential.mdx @@ -0,0 +1,78 @@ +--- +title: archgate credential +description: "Git credential helper som leverer tilgangstokener for Archgates plugin-repositorier." +--- + +Leverer git et gyldig tilgangstoken for Archgates plugin-repositorier. Du kjører normalt ikke denne kommandoen selv -- git kaller den, og [`archgate login`](/reference/cli/login/) registrerer den for deg. + +```bash +archgate credential get +``` + +Plugin-repositorier klones over HTTPS fra `plugins.archgate.dev`, og git autentiserer disse forespørslene selv i stedet for å gå gjennom CLI-en. Tilgangstokener er kortlivede, så et token som skrives én gang til legitimasjonsbehandleren ville slutte å virke innen en time. Denne helperen tetter det hullet: git spør den om legitimasjon ved hver forespørsel, og den svarer med et mellomlagret tilgangstoken som først fornyes fra det lagrede refresh-tokenet når det har utløpt. + +Kommandoen snakker gits [credential helper-protokoll](https://git-scm.com/docs/git-credential) over standard inn og ut. Den svarer bare for `plugins.archgate.dev` og er taus for alle andre verter, slik at legitimasjon for de øvrige remotene dine fortsetter å bruke den helperen du allerede har konfigurert. + +## Underkommandoer + +| Underkommando | Beskrivelse | +| --------------------------- | ------------------------------------------------------------ | +| `archgate credential get` | Skriver ut legitimasjon for et plugin-repositorium | +| `archgate credential store` | Tar imot legitimasjon fra git (no-op) | +| `archgate credential erase` | Fjerner lagret legitimasjon når git melder at den ble avvist | + +`store` er bevisst en no-op: tokener kommer fra `archgate login`, så git har ingenting å lære CLI-en. + +## Konfigurasjon + +`archgate login` skriver følgende til den globale git-konfigurasjonen din: + +```ini +[credential "https://plugins.archgate.dev"] + helper = + helper = !archgate credential +``` + +Den tomme første oppføringen nullstiller enhver helper som er arvet fra et bredere konfigurasjonsnivå, slik at `archgate` er den eneste helperen git spør for denne verten. `archgate login logout` fjerner begge oppføringene. + +## Eksempler + +Se hva git får tilbake: + +```bash +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` +protocol=https +host=plugins.archgate.dev +username=dittnavn +password=eyJhbGciOiJFUzM4NCIsInR5cCI6ImF0K2p3dCJ9... +``` + +Når du er logget ut, skriver kommandoen ingenting og avslutter uten feil, noe som forteller git at den skal gå videre til de andre helperne sine: + +```bash +archgate login logout +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` + +``` + +## Feilsøking + +### `git clone` spør etter brukernavn og passord + +Helperen er ikke registrert. Kjør `archgate login` på nytt, eller sjekk konfigurasjonen direkte: + +```bash +git config --get-all credential.https://plugins.archgate.dev.helper +``` + +Utdataene skal vise en tom linje etterfulgt av `!archgate credential`. Hvis ikke, klarte ikke CLI-en å skrive den globale git-konfigurasjonen din -- bekreft at `git config --global --list` virker. + +### Helperen er registrert, men legitimasjonen avvises fortsatt + +Bekreft at du er logget inn med `archgate login status`. Hvis refresh-tokenet er utløpt eller tilbakekalt, klarer ikke `archgate credential get` å fornye tilgangstokenet og forblir taust; `archgate login` gjenoppretter det. diff --git a/docs/src/content/docs/nb/reference/cli/login.mdx b/docs/src/content/docs/nb/reference/cli/login.mdx index 03635b08..a45d0d1d 100644 --- a/docs/src/content/docs/nb/reference/cli/login.mdx +++ b/docs/src/content/docs/nb/reference/cli/login.mdx @@ -1,17 +1,19 @@ --- title: archgate login -description: "Autentiser med GitHub for å få tilgang til Archgate editor-plugins." +description: "Logg inn for å få tilgang til Archgate editor-plugins." --- -Autentiser med GitHub for å få tilgang til Archgate editor-plugins. Hvis du ikke er registrert ennå, håndterer CLI-en registrering automatisk -- den ber om e-postadressen din, foretrukket editor (Claude Code, Cursor, VS Code, GitHub Copilot eller opencode) og bruksområde, og registrerer deg før innloggingen fullføres. +Logg inn for å få tilgang til Archgate editor-plugins. ```bash archgate login ``` -Starter en GitHub Device Flow (OAuth). CLI-en viser en engangskode og en URL. Åpne URL-en i nettleseren din, skriv inn koden og autoriser Archgate GitHub OAuth-appen. Når autorisasjonen er fullført, veksler CLI-en GitHub-identiteten din mot et Archgate plugin-token og lagrer det sikkert i operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager eller Linux libsecret) via `git credential approve`. Ingen legitimasjon skrives til disk som klartekstfiler. +Starter en OAuth 2.0 Device Authorization Grant (RFC 8628) mot Archgates identitetsleverandør. CLI-en viser en engangskode og en URL. Åpne URL-en i nettleseren din, skriv inn koden og velg hvordan du vil logge inn. Når autorisasjonen er fullført, lagrer CLI-en tokenene i operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager eller Linux libsecret) via `git credential approve`. Ingen legitimasjon skrives til disk som klartekstfiler. -Hvis GitHub-kontoen din ikke er registrert ennå, ber CLI-en om e-postadressen din, foretrukket editor og bruksområde, og registrerer deg automatisk. +Tilgangstokener er kortlivede. CLI-en beholder et refresh-token ved siden av dem og fornyer tilgangstokenet automatisk, så det holder å logge inn én gang. + +Innlogging registrerer også `archgate credential` som en git credential helper for `plugins.archgate.dev`, som er det som lar `git clone` av et plugin-repositorium autentisere uten å spørre. Se [`archgate credential`](/reference/cli/credential/). Legitimasjon kreves for å installere editor-plugins via `archgate init --install-plugin`. Selve CLI-en (check, init osv.) fungerer uten innlogging. @@ -37,29 +39,13 @@ archgate login ``` ``` -By signing up, you agree to the Archgate Terms of Service: +By signing in, you agree to the Archgate Terms of Service: https://archgate.dev/terms-of-service -info: Authenticating with GitHub... - -Open https://github.com/login/device in your browser -and enter the code: ABCD-1234 +Open https://auth.archgate.dev/device in your browser +and enter the code: HZML-HXLB Waiting for authorization... -info: GitHub user: yourname -info: Claiming archgate plugin token... - -Your GitHub account yourname is not yet registered. -Let's sign you up now. - -? Email address: you@example.com -? Which editor will you use with archgate? Claude Code -? How do you plan to use archgate? Enforcing ADRs in our monorepo -? I agree to be contacted by the Archgate team to provide feedback during the beta period. Yes - -info: Submitting signup request... -info: Claiming archgate plugin token... - info: Authenticated as yourname. Plugin access is now available. Run `archgate init` to set up a project with the archgate plugin. ``` diff --git a/docs/src/content/docs/pt-br/reference/cli/credential.mdx b/docs/src/content/docs/pt-br/reference/cli/credential.mdx new file mode 100644 index 00000000..f3f09f00 --- /dev/null +++ b/docs/src/content/docs/pt-br/reference/cli/credential.mdx @@ -0,0 +1,78 @@ +--- +title: archgate credential +description: "Credential helper do git que fornece tokens de acesso para os repositórios de plugins do Archgate." +--- + +Fornece ao git um token de acesso válido para os repositórios de plugins do Archgate. Normalmente você não executa este comando -- o git o invoca, e o [`archgate login`](/reference/cli/login/) o registra para você. + +```bash +archgate credential get +``` + +Os repositórios de plugins são clonados via HTTPS a partir de `plugins.archgate.dev`, e o próprio git autentica essas requisições em vez de passar pela CLI. Os tokens de acesso têm vida curta, então um token gravado uma única vez no gerenciador de credenciais deixaria de funcionar em menos de uma hora. Este helper resolve isso: o git pede credenciais a cada requisição, e ele responde com um token de acesso em cache, renovando-o a partir do refresh token armazenado somente depois que expira. + +O comando fala o [protocolo de credential helper do git](https://git-scm.com/docs/git-credential) via entrada e saída padrão. Ele responde apenas para `plugins.archgate.dev` e permanece silencioso para qualquer outro host, então as credenciais dos seus demais remotes continuam usando o helper que você já configurou. + +## Subcomandos + +| Subcomando | Descrição | +| --------------------------- | ------------------------------------------------------------- | +| `archgate credential get` | Exibe as credenciais de um repositório de plugin | +| `archgate credential store` | Aceita uma credencial do git (no-op) | +| `archgate credential erase` | Remove as credenciais quando o git as reporta como rejeitadas | + +`store` é intencionalmente um no-op: os tokens vêm do `archgate login`, então não há nada que o git possa ensinar à CLI. + +## Configuração + +O `archgate login` grava o seguinte na sua configuração global do git: + +```ini +[credential "https://plugins.archgate.dev"] + helper = + helper = !archgate credential +``` + +A primeira entrada vazia redefine qualquer helper herdado de um escopo de configuração mais amplo, de modo que o `archgate` seja o único helper consultado para este host. O `archgate login logout` remove as duas entradas. + +## Exemplos + +Inspecione o que o git recebe: + +```bash +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` +protocol=https +host=plugins.archgate.dev +username=seunome +password=eyJhbGciOiJFUzM4NCIsInR5cCI6ImF0K2p3dCJ9... +``` + +Quando você não está autenticado, o comando não exibe nada e encerra com sucesso, o que faz o git seguir para os outros helpers: + +```bash +archgate login logout +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` + +``` + +## Solução de problemas + +### O `git clone` pede usuário e senha + +O helper não está registrado. Execute `archgate login` novamente ou verifique a configuração diretamente: + +```bash +git config --get-all credential.https://plugins.archgate.dev.helper +``` + +A saída deve listar uma linha vazia seguida de `!archgate credential`. Se não listar, a CLI não conseguiu gravar sua configuração global do git -- confirme que `git config --global --list` funciona. + +### O helper está registrado, mas as credenciais continuam sendo rejeitadas + +Confirme que você está autenticado com `archgate login status`. Se o refresh token expirou ou foi revogado, o `archgate credential get` não consegue renovar o token de acesso e permanece silencioso; o `archgate login` o restabelece. diff --git a/docs/src/content/docs/pt-br/reference/cli/login.mdx b/docs/src/content/docs/pt-br/reference/cli/login.mdx index 3a7c067e..b4eb3eac 100644 --- a/docs/src/content/docs/pt-br/reference/cli/login.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/login.mdx @@ -1,22 +1,24 @@ --- title: archgate login -description: "Autentique-se com o GitHub para acessar os plugins de editor do Archgate." +description: "Entre para acessar os plugins de editor do Archgate." --- -Autentique-se com o GitHub para acessar os plugins de editor do Archgate. Se você ainda não possui cadastro, a CLI cuida do registro automaticamente -- solicita seu email, preferência de editor (Claude Code, Cursor, VS Code, GitHub Copilot ou opencode) e caso de uso, e então realiza o cadastro antes de concluir o login. +Entre para acessar os plugins de editor do Archgate. ```bash archgate login ``` -Inicia um GitHub Device Flow (OAuth). A CLI exibe um código de uso único e uma URL. Abra a URL no seu navegador, insira o código e autorize o Archgate GitHub OAuth App. Após a autorização, a CLI troca sua identidade do GitHub por um token de plugin do Archgate e armazena-o de forma segura no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager ou libsecret no Linux) via `git credential approve`. Nenhuma credencial é gravada em disco como arquivo de texto. +Inicia um OAuth 2.0 Device Authorization Grant (RFC 8628) no provedor de identidade do Archgate. A CLI exibe um código de uso único e uma URL. Abra a URL no seu navegador, insira o código e escolha como deseja entrar. Após a autorização, a CLI armazena os tokens resultantes no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager ou libsecret no Linux) via `git credential approve`. Nenhuma credencial é gravada em disco como arquivo de texto. -Se sua conta do GitHub ainda não estiver cadastrada, a CLI solicita seu email, editor preferido e caso de uso, e realiza o cadastro automaticamente. +Os tokens de acesso têm vida curta. A CLI mantém um refresh token junto deles e renova o token de acesso automaticamente, então basta entrar uma vez. + +Entrar também registra `archgate credential` como um credential helper do git para `plugins.archgate.dev`, que é o que permite ao `git clone` de um repositório de plugin autenticar sem solicitar credenciais. Veja [`archgate credential`](/reference/cli/credential/). As credenciais são necessárias para instalar plugins de editor via `archgate init --install-plugin`. A CLI em si (check, init, etc.) funciona sem login. :::tip[Plugin em beta] -Os plugins de editor estão atualmente em beta. Execute `archgate login` para se cadastrar e autenticar. +Os plugins de editor estão atualmente em beta. Execute `archgate login` para entrar. ::: ## Subcomandos @@ -37,29 +39,13 @@ archgate login ``` ``` -By signing up, you agree to the Archgate Terms of Service: +By signing in, you agree to the Archgate Terms of Service: https://archgate.dev/terms-of-service -info: Authenticating with GitHub... - -Open https://github.com/login/device in your browser -and enter the code: ABCD-1234 +Open https://auth.archgate.dev/device in your browser +and enter the code: HZML-HXLB Waiting for authorization... -info: GitHub user: yourname -info: Claiming archgate plugin token... - -Your GitHub account yourname is not yet registered. -Let's sign you up now. - -? Email address: you@example.com -? Which editor will you use with archgate? Claude Code -? How do you plan to use archgate? Enforcing ADRs in our monorepo -? I agree to be contacted by the Archgate team to provide feedback during the beta period. Yes - -info: Submitting signup request... -info: Claiming archgate plugin token... - info: Authenticated as yourname. Plugin access is now available. Run `archgate init` to set up a project with the archgate plugin. ``` diff --git a/docs/src/content/docs/reference/cli/credential.mdx b/docs/src/content/docs/reference/cli/credential.mdx new file mode 100644 index 00000000..3d93b0d9 --- /dev/null +++ b/docs/src/content/docs/reference/cli/credential.mdx @@ -0,0 +1,78 @@ +--- +title: archgate credential +description: "Git credential helper that supplies access tokens for Archgate plugin repositories." +--- + +Supplies git with a valid access token for Archgate plugin repositories. You do not normally run this command yourself -- git invokes it, and [`archgate login`](/reference/cli/login/) registers it for you. + +```bash +archgate credential get +``` + +Plugin repositories are cloned over HTTPS from `plugins.archgate.dev`, and git authenticates those requests itself rather than going through the CLI. Access tokens are short-lived, so a token written once into your credential manager would stop working within the hour. This helper closes that gap: git asks it for credentials on each request, and it answers with a cached access token, renewing it from the stored refresh token only once it has lapsed. + +The command speaks git's [credential helper protocol](https://git-scm.com/docs/git-credential) on standard input and output. It answers only for `plugins.archgate.dev` and stays silent for every other host, so credentials for the rest of your remotes keep using whatever helper you already configured. + +## Subcommands + +| Subcommand | Description | +| --------------------------- | -------------------------------------------------------- | +| `archgate credential get` | Print credentials for a plugin repository | +| `archgate credential store` | Accept a credential from git (no-op) | +| `archgate credential erase` | Remove stored credentials when git reports them rejected | + +`store` is deliberately a no-op: tokens come from `archgate login`, so there is nothing for git to teach the CLI. + +## Configuration + +`archgate login` writes the following to your global git config: + +```ini +[credential "https://plugins.archgate.dev"] + helper = + helper = !archgate credential +``` + +The empty first entry resets any helper inherited from a broader config scope, so `archgate` is the only helper consulted for this host. `archgate login logout` removes both entries. + +## Examples + +Inspect what git receives: + +```bash +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` +protocol=https +host=plugins.archgate.dev +username=yourname +password=eyJhbGciOiJFUzM4NCIsInR5cCI6ImF0K2p3dCJ9... +``` + +When you are signed out, the command prints nothing and exits successfully, which tells git to fall through to its other helpers: + +```bash +archgate login logout +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` + +``` + +## Troubleshooting + +### `git clone` prompts for a username and password + +The helper is not registered. Run `archgate login` again, or check the configuration directly: + +```bash +git config --get-all credential.https://plugins.archgate.dev.helper +``` + +The output should list an empty line followed by `!archgate credential`. If it does not, the CLI could not write your global git config -- confirm `git config --global --list` works. + +### The helper is registered but credentials are still rejected + +Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, `archgate credential get` cannot renew the access token and stays silent; `archgate login` re-establishes it. diff --git a/docs/src/content/docs/reference/cli/login.mdx b/docs/src/content/docs/reference/cli/login.mdx index a0ecbbfe..b862b530 100644 --- a/docs/src/content/docs/reference/cli/login.mdx +++ b/docs/src/content/docs/reference/cli/login.mdx @@ -1,22 +1,24 @@ --- title: archgate login -description: "Authenticate with GitHub to access Archgate editor plugins." +description: "Sign in to access Archgate editor plugins." --- -Authenticate with GitHub to access Archgate editor plugins. If you are not registered yet, the CLI handles signup automatically -- it prompts for your email, editor preference (Claude Code, Cursor, VS Code, GitHub Copilot, or opencode), and use case, then registers you before completing the login. +Sign in to access Archgate editor plugins. ```bash archgate login ``` -Starts a GitHub Device Flow (OAuth). The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and authorize the Archgate GitHub OAuth App. Once authorized, the CLI exchanges your GitHub identity for an Archgate plugin token and stores it securely in your OS credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. +Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate identity provider. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI stores the resulting tokens in your OS credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. -If your GitHub account is not yet registered, the CLI prompts for your email, preferred editor, and use case, then signs you up automatically. +Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is enough. + +Signing in also registers `archgate credential` as a git credential helper for `plugins.archgate.dev`, which is what lets `git clone` of a plugin repository authenticate without prompting. See [`archgate credential`](/reference/cli/credential/). Credentials are required to install editor plugins via `archgate init --install-plugin`. The CLI itself (check, init, etc.) works without login. :::tip[Plugin beta] -Editor plugins are currently in beta. Run `archgate login` to sign up and authenticate. +Editor plugins are currently in beta. Run `archgate login` to sign in. ::: ## Subcommands @@ -26,7 +28,7 @@ Editor plugins are currently in beta. Run `archgate login` to sign up and authen | `archgate login` | Authenticate (skips if already logged in) | | `archgate login status` | Show current authentication status | | `archgate login logout` | Remove stored credentials | -| `archgate login refresh` | Re-authenticate and claim a new token | +| `archgate login refresh` | Sign in again, replacing stored tokens | ## Examples @@ -37,29 +39,13 @@ archgate login ``` ``` -By signing up, you agree to the Archgate Terms of Service: +By signing in, you agree to the Archgate Terms of Service: https://archgate.dev/terms-of-service -info: Authenticating with GitHub... - -Open https://github.com/login/device in your browser -and enter the code: ABCD-1234 +Open https://auth.archgate.dev/device in your browser +and enter the code: HZML-HXLB Waiting for authorization... -info: GitHub user: yourname -info: Claiming archgate plugin token... - -Your GitHub account yourname is not yet registered. -Let's sign you up now. - -? Email address: you@example.com -? Which editor will you use with archgate? Claude Code -? How do you plan to use archgate? Enforcing ADRs in our monorepo -? I agree to be contacted by the Archgate team to provide feedback during the beta period. Yes - -info: Submitting signup request... -info: Claiming archgate plugin token... - info: Authenticated as yourname. Plugin access is now available. Run `archgate init` to set up a project with the archgate plugin. ``` diff --git a/src/cli.ts b/src/cli.ts index 52af809d..e1afbe3c 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,6 +12,7 @@ import packageJson from "../package.json"; import { registerAdrCommand } from "./commands/adr/index"; import { registerCheckCommand } from "./commands/check"; import { registerCleanCommand } from "./commands/clean"; +import { registerCredentialCommand } from "./commands/credential"; import { registerDoctorCommand } from "./commands/doctor"; import { registerInitCommand } from "./commands/init"; import { registerLoginCommand } from "./commands/login"; @@ -156,6 +157,7 @@ async function main() { registerCleanCommand(program); registerDoctorCommand(program); registerTelemetryCommand(program); + registerCredentialCommand(program); const updateCheckPromise = maybeCheckForUpdates(packageJson.version); await program.parseAsync(process.argv); diff --git a/src/commands/credential.ts b/src/commands/credential.ts new file mode 100644 index 00000000..ac890162 --- /dev/null +++ b/src/commands/credential.ts @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import type { Command } from "@commander-js/extra-typings"; + +import { + clearCredentials, + PLUGINS_HOST, + resolveAccessToken, +} from "../helpers/credential-store"; +import { handleCommandError } from "../helpers/exit"; +import { + formatCredentialResponse, + parseCredentialRequest, +} from "../helpers/git-credential-protocol"; +import { logDebug } from "../helpers/log"; + +/** + * Read the credential request git writes to stdin. + * + * @returns The parsed request, or an empty one when stdin is closed. + */ +async function readRequest(): Promise> { + const raw = await Bun.stdin.text(); + return parseCredentialRequest(raw); +} + +export function registerCredentialCommand(program: Command) { + const credential = program + .command("credential") + .description("Git credential helper for archgate plugin repositories"); + + credential + .command("get") + .description("Print credentials for a plugin repository") + .action(async () => { + try { + const request = await readRequest(); + if (request.host !== PLUGINS_HOST) { + logDebug("Ignoring credential request for host:", request.host); + return; + } + + const credentials = await resolveAccessToken(); + if (!credentials) { + logDebug("No stored credentials; deferring to other helpers"); + return; + } + + process.stdout.write( + formatCredentialResponse({ + protocol: "https", + host: PLUGINS_HOST, + username: credentials.github_user, + password: credentials.token, + }) + ); + } catch (err) { + await handleCommandError(err); + } + }); + + credential + .command("store") + .description("Accept a credential from git (no-op)") + .action(async () => { + try { + // Tokens come from `archgate login`, so git has nothing to teach us. + await readRequest(); + } catch (err) { + await handleCommandError(err); + } + }); + + credential + .command("erase") + .description("Remove stored credentials when git reports them rejected") + .action(async () => { + try { + const request = await readRequest(); + if (request.host !== PLUGINS_HOST) return; + await clearCredentials(); + } catch (err) { + await handleCommandError(err); + } + }); +} diff --git a/src/commands/init.ts b/src/commands/init.ts index 5ccf9c57..cdad5a9b 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -13,7 +13,6 @@ import { exitWith, handleCommandError } from "../helpers/exit"; import { EDITOR_LABELS, EDITOR_TARGETS, - SIGNUP_EDITORS, initProject, } from "../helpers/init-project"; import type { EditorTarget } from "../helpers/init-project"; @@ -102,9 +101,7 @@ export function registerInitCommand(program: Command) { ); if (wantPlugin) { - const result = await runLoginFlow({ - editor: SIGNUP_EDITORS[editors[0]], - }); + const result = await runLoginFlow(); hasCredentials = result.ok; } } diff --git a/src/commands/login.ts b/src/commands/login.ts index 8ad7f381..ba7c737c 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -6,6 +6,7 @@ import type { Command } from "@commander-js/extra-typings"; import { loadCredentials, clearCredentials } from "../helpers/credential-store"; import { exitWith, handleCommandError } from "../helpers/exit"; +import { unregisterGitCredentialHelper } from "../helpers/git-credential-config"; import { logError, logInfo } from "../helpers/log"; import { runLoginFlow } from "../helpers/login-flow"; import { findProjectRoot } from "../helpers/paths"; @@ -23,7 +24,7 @@ export function registerLoginCommand(program: Command) { if (existing) { logInfo( `Already logged in as ${styleText("bold", existing.github_user)}.`, - "Run `archgate login refresh` to re-authenticate." + "Run `archgate login refresh` to sign in again." ); return; } @@ -75,6 +76,7 @@ export function registerLoginCommand(program: Command) { .action(async () => { try { await clearCredentials(); + await unregisterGitCredentialHelper(); trackLoginResult({ subcommand: "logout", success: true }); console.log("Logged out successfully."); } catch (err) { diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts deleted file mode 100644 index 189afeff..00000000 --- a/src/helpers/auth.ts +++ /dev/null @@ -1,241 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -/** - * auth.ts — GitHub Device Flow authentication and archgate token management. - * - * Implements RFC 8628 (OAuth 2.0 Device Authorization Grant) for CLI login. - * Token storage is delegated to credential-store.ts which uses git's native - * credential helpers (macOS Keychain, Windows Credential Manager, libsecret). - */ - -import { z } from "zod"; - -import { logDebug } from "./log"; -import { SignupRequiredError, isSignupRequiredError } from "./signup"; -import { UserError } from "./user-error"; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const PLUGINS_API = "https://plugins.archgate.dev"; -const GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code"; -const GITHUB_DEVICE_TOKEN_URL = "https://github.com/login/oauth/access_token"; - -/** - * GitHub OAuth App client ID for the archgate CLI (public client — no secret). - * Device flow apps are public clients; the client_id is not confidential. - */ -const GITHUB_CLIENT_ID = "Ov23liZUI9Aiv2ZrSAgn"; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -const DeviceCodeResponseSchema = z.object({ - device_code: z.string(), - user_code: z.string(), - verification_uri: z.string(), - expires_in: z.number(), - interval: z.number(), -}); - -type DeviceCodeResponse = z.infer; - -const DeviceTokenResponseSchema = z.union([ - z.object({ - access_token: z.string(), - token_type: z.string(), - scope: z.string(), - }), - z.object({ error: z.string(), error_description: z.string().optional() }), -]); - -const GitHubUserSchema = z.object({ - login: z.string().optional(), - email: z.string().nullable().default(null), -}); - -const TokenResponseSchema = z.object({ token: z.string().optional() }); -const ErrorResponseSchema = z.object({ error: z.string().optional() }); - -// --------------------------------------------------------------------------- -// GitHub Device Flow -// --------------------------------------------------------------------------- - -/** - * Step 1: Request a device code from GitHub. - * The user will be shown the `user_code` and asked to visit `verification_uri`. - */ -export async function requestDeviceCode(): Promise { - logDebug("Requesting device code from:", GITHUB_DEVICE_CODE_URL); - const response = await fetch(GITHUB_DEVICE_CODE_URL, { - method: "POST", - headers: { Accept: "application/json", "Content-Type": "application/json" }, - body: JSON.stringify({ client_id: GITHUB_CLIENT_ID, scope: "read:user" }), - signal: AbortSignal.timeout(15_000), - }); - - if (!response.ok) { - logDebug("Device code request failed, status:", response.status); - throw new UserError( - `GitHub device code request failed (HTTP ${response.status})` - ); - } - - const data = DeviceCodeResponseSchema.parse(await response.json()); - logDebug("Device code received, expires in:", data.expires_in, "seconds"); - return data; -} - -/** - * Step 2: Poll GitHub until the user authorizes, or the code expires. - * - * @param deviceCode - Device code from the step-1 authorization request. - * @param interval - Seconds to wait between polls, per RFC 8628. GitHub can - * widen this via a `slow_down` response. - * @param expiresIn - Lifetime of the device code in seconds, forming the - * polling deadline. - * @returns The GitHub access token. - * @throws {UserError} When GitHub rejects a poll, or the code expires or is - * denied before authorization completes. - */ -export async function pollForAccessToken( - deviceCode: string, - interval: number, - expiresIn: number -): Promise { - const deadline = Date.now() + expiresIn * 1000; - let pollInterval = interval; - logDebug( - "Starting device code polling, deadline:", - new Date(deadline).toISOString() - ); - - /* oxlint-disable no-await-in-loop -- sequential polling is required by RFC 8628 */ - while (Date.now() < deadline) { - await Bun.sleep(pollInterval * 1000); - - const response = await fetch(GITHUB_DEVICE_TOKEN_URL, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - }, - body: JSON.stringify({ - client_id: GITHUB_CLIENT_ID, - device_code: deviceCode, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }), - signal: AbortSignal.timeout(15_000), - }); - - if (!response.ok) { - throw new UserError(`GitHub token poll failed (HTTP ${response.status})`); - } - - const data = DeviceTokenResponseSchema.parse(await response.json()); - - if ("access_token" in data) { - logDebug("Access token received"); - return data.access_token; - } - - if ("error" in data) { - if (data.error === "authorization_pending") { - logDebug("Authorization pending, retrying in", pollInterval, "seconds"); - continue; - } - if (data.error === "slow_down") { - pollInterval += 5; - logDebug("Slow down requested, new interval:", pollInterval, "seconds"); - continue; - } - // expired_token, access_denied, or other terminal error - throw new UserError( - data.error_description ?? `GitHub authorization failed: ${data.error}` - ); - } - } - /* oxlint-enable no-await-in-loop */ - - throw new UserError("Device code expired. Please try again."); -} - -interface GitHubUserInfo { - login: string; - email: string | null; -} - -/** - * Step 3: Get the authenticated GitHub user info. - */ -export async function getGitHubUser( - accessToken: string -): Promise { - logDebug("Fetching GitHub user info"); - const response = await fetch("https://api.github.com/user", { - headers: { - Authorization: `Bearer ${accessToken}`, - "User-Agent": "archgate-cli", - }, - signal: AbortSignal.timeout(15_000), - }); - - if (!response.ok) { - throw new UserError( - `Failed to fetch GitHub user (HTTP ${response.status})` - ); - } - - const data = GitHubUserSchema.parse(await response.json()); - if (data.login === undefined || data.login === "") { - throw new Error("GitHub API did not return a username"); - } - logDebug("GitHub user:", data.login); - return { login: data.login, email: data.email }; -} - -// --------------------------------------------------------------------------- -// Token Claim -// --------------------------------------------------------------------------- - -/** - * Exchange a GitHub access token for an archgate plugin token - * via POST /api/token/claim on the plugins service. - */ -export async function claimArchgateToken(githubToken: string): Promise { - logDebug("Claiming archgate token from:", `${PLUGINS_API}/api/token/claim`); - const response = await fetch(`${PLUGINS_API}/api/token/claim`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": "archgate-cli", - }, - body: JSON.stringify({ github_token: githubToken }), - signal: AbortSignal.timeout(15_000), - redirect: "error", - }); - - if (!response.ok) { - const errorResult = ErrorResponseSchema.safeParse( - await response.json().catch(() => ({})) - ); - const body = errorResult.success ? errorResult.data : {}; - - if (isSignupRequiredError(body.error)) { - throw new SignupRequiredError(); - } - - const message = - body.error ?? `Token claim failed (HTTP ${response.status})`; - throw new UserError(message); - } - - const data = TokenResponseSchema.parse(await response.json()); - if (data.token === undefined || data.token === "") { - throw new Error("Plugins service did not return a token"); - } - logDebug("Archgate token claimed successfully"); - return data.token; -} diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index 4ec80bb1..80696edd 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -12,9 +12,25 @@ import { unlinkSync } from "node:fs"; import { logDebug, logWarn } from "./log"; +import { + isExpired, + refreshAccessToken, + type TokenSet, + TokenSetSchema, +} from "./logto-auth"; import { internalPath } from "./paths"; -const CREDENTIAL_HOST = "plugins.archgate.dev"; +/** Host git itself authenticates against for plugin repositories. */ +export const PLUGINS_HOST = "plugins.archgate.dev"; + +/** + * Host the Logto token set is filed under. + * + * archgate registers itself as git's credential helper for {@link PLUGINS_HOST}, + * so storing its own tokens there would make every read re-enter this process. + */ +export const AUTH_HOST = "auth.archgate.dev"; + const CREDENTIAL_TIMEOUT_MS = 3_000; /** @@ -43,8 +59,12 @@ export interface StoredCredentials { // Git credential protocol helpers // --------------------------------------------------------------------------- -function credentialInput(username?: string, password?: string): string { - const lines = ["protocol=https", `host=${CREDENTIAL_HOST}`]; +function credentialInput( + host: string, + username?: string, + password?: string +): string { + const lines = ["protocol=https", `host=${host}`]; if (username !== undefined && username !== "") lines.push(`username=${username}`); if (password !== undefined && password !== "") @@ -54,11 +74,12 @@ function credentialInput(username?: string, password?: string): string { } async function gitCredentialApprove( + host: string, username: string, password: string ): Promise { const proc = Bun.spawn(["git", "credential", "approve"], { - stdin: new Blob([credentialInput(username, password)]), + stdin: new Blob([credentialInput(host, username, password)]), stdout: "pipe", stderr: "pipe", env: gitCredentialEnv(), @@ -66,13 +87,12 @@ async function gitCredentialApprove( return (await proc.exited) === 0; } -async function gitCredentialFill(): Promise<{ - username: string; - password: string; -} | null> { +async function gitCredentialFill( + host: string +): Promise<{ username: string; password: string } | null> { try { const proc = Bun.spawn(["git", "credential", "fill"], { - stdin: new Blob([credentialInput()]), + stdin: new Blob([credentialInput(host)]), stdout: "pipe", stderr: "pipe", env: gitCredentialEnv(), @@ -114,11 +134,12 @@ async function gitCredentialFill(): Promise<{ } async function gitCredentialReject( + host: string, username: string, password: string ): Promise { const proc = Bun.spawn(["git", "credential", "reject"], { - stdin: new Blob([credentialInput(username, password)]), + stdin: new Blob([credentialInput(host, username, password)]), stdout: "pipe", stderr: "pipe", env: gitCredentialEnv(), @@ -158,47 +179,91 @@ const CREDENTIAL_HELPER_HINT = "Run `git config --global credential.helper` to check your configuration."; /** - * Persist archgate credentials in the OS credential manager. + * Persist a Logto token set in the OS credential manager. * - * A verification round-trip (`git credential fill`) confirms the token was + * A verification round-trip (`git credential fill`) confirms the blob was * actually persisted — `git credential approve` exits 0 even without a * configured helper, silently storing nothing. + * + * @param user - Display name shown by `archgate login status`. + * @param tokens - Access token, refresh token and absolute expiry. */ -export async function saveCredentials( - credentials: StoredCredentials +export async function saveTokenSet( + user: string, + tokens: TokenSet ): Promise { await cleanupLegacyMetadata(); const stored = await gitCredentialApprove( - credentials.github_user, - credentials.token + AUTH_HOST, + user, + JSON.stringify(tokens) ); - if (stored) { - const verified = await gitCredentialFill(); - if (verified) { - logDebug("Token verified in git credential manager"); - } else { - logWarn( - "Token could not be verified in git credential manager.", - "Your credential helper may not persist credentials.", - CREDENTIAL_HELPER_HINT, - "Without a working credential helper, you will need to re-login after each session." - ); - } - } else { + if (!stored) { logWarn( "git credential approve failed.", "Your git credential helper may not be configured.", CREDENTIAL_HELPER_HINT ); + return; + } + + if (await gitCredentialFill(AUTH_HOST)) { + logDebug("Token set verified in git credential manager"); + } else { + logWarn( + "Token could not be verified in git credential manager.", + "Your credential helper may not persist credentials.", + CREDENTIAL_HELPER_HINT, + "Without a working credential helper, you will need to re-login after each session." + ); + } +} + +/** + * Read the stored Logto token set without renewing it. + * + * @returns The token set and the user it belongs to, or `null` when absent. + */ +export async function loadTokenSet(): Promise<{ + user: string; + tokens: TokenSet; +} | null> { + const stored = await gitCredentialFill(AUTH_HOST); + if (!stored) return null; + + const parsed = TokenSetSchema.safeParse(safeJsonParse(stored.password)); + if (!parsed.success) { + logDebug("Stored token set is unreadable"); + return null; + } + return { user: stored.username, tokens: parsed.data }; +} + +/** + * Return a usable access token, renewing it when it has lapsed. + * + * @returns Credentials ready to send, or `null` when the user is signed out. + */ +export async function resolveAccessToken(): Promise { + const stored = await loadTokenSet(); + if (!stored) return null; + + if (!isExpired(stored.tokens.expiresAt)) { + return { token: stored.tokens.accessToken, github_user: stored.user }; } + + const renewed = await refreshAccessToken(stored.tokens.refreshToken); + await saveTokenSet(stored.user, renewed); + return { token: renewed.accessToken, github_user: stored.user }; } /** - * Load stored archgate credentials from the OS credential manager. A legacy - * `~/.archgate/credentials` file is deleted on sight and the user is asked - * to re-login. + * Load archgate credentials, renewing a lapsed access token on the way. + * + * Tokens issued before Logto sign-in are filed under {@link PLUGINS_HOST} and + * are returned unchanged; they keep working until the user signs in again. * * @returns The stored credentials, or `null` when none are stored. */ @@ -213,22 +278,38 @@ export async function loadCredentials(): Promise { return null; } - const gitCreds = await gitCredentialFill(); - if (gitCreds) { - return { token: gitCreds.password, github_user: gitCreds.username }; + const resolved = await resolveAccessToken(); + if (resolved) return resolved; + + const legacy = await gitCredentialFill(PLUGINS_HOST); + if (legacy) { + return { token: legacy.password, github_user: legacy.username }; } return null; } /** * Remove stored credentials (logout). - * Clears the OS credential manager and any legacy metadata file. + * Clears both credential entries and any legacy metadata file. */ export async function clearCredentials(): Promise { - const gitCreds = await gitCredentialFill(); - if (gitCreds) { - await gitCredentialReject(gitCreds.username, gitCreds.password); - logDebug("Token removed from git credential manager"); + /* oxlint-disable no-await-in-loop -- two fixed hosts, cleared in order */ + for (const host of [AUTH_HOST, PLUGINS_HOST]) { + const stored = await gitCredentialFill(host); + if (stored) { + await gitCredentialReject(host, stored.username, stored.password); + logDebug("Credentials removed from git credential manager"); + } } + /* oxlint-enable no-await-in-loop */ await cleanupLegacyMetadata(); } + +/** Parse JSON, returning `null` rather than throwing on malformed input. */ +function safeJsonParse(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return null; + } +} diff --git a/src/helpers/git-credential-config.ts b/src/helpers/git-credential-config.ts new file mode 100644 index 00000000..506b869e --- /dev/null +++ b/src/helpers/git-credential-config.ts @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * git-credential-config.ts — register archgate as git's credential helper. + * + * Scoped to the plugins host alone, so credentials for every other host keep + * using whatever helper the user already configured. + */ + +import { PLUGINS_HOST } from "./credential-store"; +import { logDebug } from "./log"; + +/** Config key holding the helper list for the plugins host. */ +const HELPER_KEY = `credential.https://${PLUGINS_HOST}.helper`; + +/** Shell form git invokes; `!` marks it as a command rather than a suffix. */ +const HELPER_VALUE = "!archgate credential"; + +/** + * Run a git command, resolving to its exit code. + * + * The environment is passed explicitly: `Bun.spawn` snapshots the environment + * at startup, so a later `Bun.env` assignment — which is how tests redirect + * `GIT_CONFIG_GLOBAL` — would otherwise not reach git. + */ +async function git(args: string[]): Promise { + const proc = Bun.spawn(["git", ...args], { + stdout: "pipe", + stderr: "pipe", + env: { ...Bun.env }, + }); + return proc.exited; +} + +/** + * Point git at archgate for plugin-host credentials. + * + * An empty value is written first: git accumulates helpers across config + * scopes, and the empty entry resets any inherited list so archgate is the + * only helper consulted for this host. + * + * @returns `true` when both config writes succeed. + */ +export async function registerGitCredentialHelper(): Promise { + const reset = await git([ + "config", + "--global", + "--replace-all", + HELPER_KEY, + "", + ]); + if (reset !== 0) return false; + + const added = await git([ + "config", + "--global", + "--add", + HELPER_KEY, + HELPER_VALUE, + ]); + if (added !== 0) return false; + + logDebug("Registered archgate as git credential helper for", PLUGINS_HOST); + return true; +} + +/** + * Remove archgate from git's credential configuration for the plugins host. + * + * @returns `true` when the section was removed or was already absent. + */ +export async function unregisterGitCredentialHelper(): Promise { + const code = await git(["config", "--global", "--unset-all", HELPER_KEY]); + // Exit code 5 means the key was not set, which is the desired end state. + return code === 0 || code === 5; +} diff --git a/src/helpers/git-credential-protocol.ts b/src/helpers/git-credential-protocol.ts new file mode 100644 index 00000000..b0b63547 --- /dev/null +++ b/src/helpers/git-credential-protocol.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * git-credential-protocol.ts — parse and format git's credential helper format. + * + * @see https://git-scm.com/docs/git-credential + */ + +/** Key/value pairs from one credential request. */ +export type CredentialRequest = Record; + +/** + * Parse the key=value block git writes to a helper's stdin. + * + * @param input - Raw stdin contents; parsing stops at the first blank line. + * @returns The parsed pairs, with unknown keys preserved. + */ +export function parseCredentialRequest(input: string): CredentialRequest { + const request: CredentialRequest = {}; + for (const line of input.split("\n")) { + if (line === "") break; + const separator = line.indexOf("="); + if (separator <= 0) continue; + request[line.slice(0, separator)] = line.slice(separator + 1); + } + return request; +} + +/** + * Render a credential answer in the format git expects on stdout. + * + * @param fields - Pairs to emit; values containing a newline are dropped, + * since a newline would let a value forge additional protocol lines. + */ +export function formatCredentialResponse(fields: CredentialRequest): string { + return Object.entries(fields) + .filter(([, value]) => !value.includes("\n") && !value.includes("\0")) + .map(([key, value]) => `${key}=${value}\n`) + .join(""); +} diff --git a/src/helpers/init-project.ts b/src/helpers/init-project.ts index 14bdec44..2ed0502f 100644 --- a/src/helpers/init-project.ts +++ b/src/helpers/init-project.ts @@ -38,22 +38,6 @@ export const EDITOR_LABELS: Record = { opencode: "opencode", }; -/** Values sent to the signup API — one per EditorTarget. */ -export type SignupEditor = - | "claude-code" - | "vscode" - | "copilot-cli" - | "cursor" - | "opencode"; - -export const SIGNUP_EDITORS: Record = { - claude: "claude-code", - cursor: "cursor", - vscode: "vscode", - copilot: "copilot-cli", - opencode: "opencode", -}; - interface InitOptions { editor?: EditorTarget; /** When true, attempt to install the archgate plugin using stored credentials. */ diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index 0d9c3e4e..f1228d2c 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -1,68 +1,42 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate /** - * login-flow.ts — Shared GitHub device flow + signup logic - * used by both `login` and `init` commands. + * login-flow.ts — Shared Logto device flow used by `login` and `init`. */ import { styleText } from "node:util"; +import { saveTokenSet } from "./credential-store"; +import { registerGitCredentialHelper } from "./git-credential-config"; +import { logDebug, logInfo, logWarn } from "./log"; import { + identityFromIdToken, + pollForTokens, requestDeviceCode, - pollForAccessToken, - getGitHubUser, - claimArchgateToken, -} from "./auth"; -import { saveCredentials } from "./credential-store"; -import { - EDITOR_LABELS, - EDITOR_TARGETS, - SIGNUP_EDITORS, - type SignupEditor, -} from "./init-project"; -import { logDebug, logError, logInfo } from "./log"; -import { withPromptFix } from "./prompt"; -import { SignupRequiredError, requestSignup } from "./signup"; - -export type { SignupEditor } from "./init-project"; - -export interface LoginFlowOptions { - /** - * Pre-selected editor for signup (skip the editor prompt). - * When omitted, the user is prompted to choose. - */ - editor?: SignupEditor; -} +} from "./logto-auth"; export interface LoginFlowResult { /** Whether credentials were obtained. */ ok: boolean; - /** GitHub username, if login succeeded. */ + /** Signed-in account name, if login succeeded. */ githubUser?: string; } /** - * Run the full GitHub device flow: authenticate, claim token (or sign up - * if the user is unregistered), and store credentials. + * Run the Logto device authorization flow and store the resulting tokens. * - * @param options - Flow overrides, such as a pre-selected editor target. - * @returns `{ ok: true }` once credentials are stored, or `{ ok: false }` on - * failure — the error is printed before returning, so callers exit quietly. + * Registers archgate as git's credential helper for the plugins host so that + * `git clone` of a plugin repository gets a freshly minted access token. + * + * @returns `{ ok: true }` once credentials are stored. */ -export async function runLoginFlow( - options?: LoginFlowOptions -): Promise { - console.log("By signing up, you agree to the Archgate Terms of Service:"); +export async function runLoginFlow(): Promise { + console.log("By signing in, you agree to the Archgate Terms of Service:"); console.log("https://archgate.dev/terms-of-service\n"); - logInfo("Authenticating with GitHub...\n"); - - logDebug("Starting login flow"); + logDebug("Starting Logto device flow"); const deviceCode = await requestDeviceCode(); - logDebug( - "Device code received, verification URI:", - deviceCode.verification_uri - ); + console.log( `Open ${styleText("bold", deviceCode.verification_uri)} in your browser` ); @@ -71,132 +45,24 @@ export async function runLoginFlow( ); console.log("Waiting for authorization..."); - const githubToken = await pollForAccessToken( + const { tokens, idToken } = await pollForTokens( deviceCode.device_code, deviceCode.interval, deviceCode.expires_in ); - const { login: githubUser, email: githubEmail } = - await getGitHubUser(githubToken); - logInfo(`GitHub user: ${styleText("bold", githubUser)}`); + const user = identityFromIdToken(idToken); + await saveTokenSet(user, tokens); - logInfo("Claiming archgate plugin token..."); - let archgateToken: string; - try { - archgateToken = await claimArchgateToken(githubToken); - logDebug("Token claimed successfully"); - } catch (err) { - if (!(err instanceof SignupRequiredError)) throw err; - logDebug("Signup required — starting signup flow"); - - console.log( - `\nYour GitHub account ${styleText("bold", githubUser)} is not yet registered.` - ); - console.log("Let's sign you up now.\n"); - - const result = await runSignupPrompt( - githubUser, - githubToken, - githubEmail, - options?.editor + if (!(await registerGitCredentialHelper())) { + logWarn( + "Could not register archgate as a git credential helper.", + "Plugin downloads still work; `git clone` of a plugin repository may prompt for credentials." ); - if (result === null || result === "") return { ok: false }; - archgateToken = result; } - await saveCredentials({ token: archgateToken, github_user: githubUser }); - logInfo( - `Authenticated as ${styleText("bold", githubUser)}. Plugin access is now available.` - ); - return { ok: true, githubUser }; -} - -/** - * Prompt for signup details and submit the registration request. - * - * @param githubUser - GitHub login of the authenticated user. - * @param githubToken - GitHub access token proving that identity. - * @param githubEmail - Email from GitHub, or `null` to prompt for one. - * @param preselectedEditor - Skips the editor prompt when supplied. - * @returns The archgate token, or `null` on failure — the error is printed - * before returning. - */ -async function runSignupPrompt( - githubUser: string, - githubToken: string, - githubEmail: string | null, - preselectedEditor?: SignupEditor -): Promise { - // Lazy-load inquirer — it costs ~200ms to parse and is only needed for - // interactive signup prompts, not on every CLI startup. - const { default: inquirer } = await import("inquirer"); - const { email } = await withPromptFix(async () => - inquirer.prompt<{ email: string }>({ - type: "input", - name: "email", - message: "Email address:", - default: githubEmail ?? undefined, - validate: (v: string) => - /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(v) || "Enter a valid email address", - }) - ); - - let editor: SignupEditor = preselectedEditor ?? "claude-code"; - if (!preselectedEditor) { - // Build choices from the canonical EDITOR_LABELS + SIGNUP_EDITORS maps - // so adding a new editor in init-project.ts propagates here automatically. - const choices = EDITOR_TARGETS.map((key) => ({ - name: EDITOR_LABELS[key], - value: SIGNUP_EDITORS[key], - })); - const ans = await withPromptFix(async () => - inquirer.prompt<{ editor: SignupEditor }>({ - type: "select", - name: "editor", - message: "Which editor will you use with archgate?", - choices, - }) - ); - editor = ans.editor; - } - - const { useCase } = await withPromptFix(async () => - inquirer.prompt<{ useCase: string }>({ - type: "input", - name: "useCase", - message: "How do you plan to use archgate?", - validate: (v: string) => - v.trim().length > 0 || "Please describe your use case", - }) - ); - - const { confirmed } = await withPromptFix(async () => - inquirer.prompt<{ confirmed: boolean }>({ - type: "confirm", - name: "confirmed", - message: - "I agree to be contacted by the Archgate team to provide feedback during the beta period.", - default: true, - }) + `Authenticated as ${styleText("bold", user)}. Plugin access is now available.` ); - - if (!confirmed) { - logInfo("Signup cancelled."); - return null; - } - - logInfo("\nSubmitting signup request..."); - const result = await requestSignup(githubUser, email, useCase, editor); - - if (!result.ok) { - logError("Signup request failed. Please try again with `archgate login`."); - return null; - } - - if (result.token !== null && result.token !== "") return result.token; - - logInfo("Claiming archgate plugin token..."); - return claimArchgateToken(githubToken); + return { ok: true, githubUser: user }; } diff --git a/src/helpers/logto-auth.ts b/src/helpers/logto-auth.ts new file mode 100644 index 00000000..35a1307d --- /dev/null +++ b/src/helpers/logto-auth.ts @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * logto-auth.ts — Logto device authorization and token refresh. + * + * Implements RFC 8628 against the Archgate identity provider. Access tokens + * are short-lived; the refresh token obtained via `offline_access` is what + * survives, and {@link refreshAccessToken} trades it for a new pair. + */ + +import { z } from "zod"; + +import { logDebug } from "./log"; +import { UserError } from "./user-error"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const LOGTO_ENDPOINT = "https://auth.archgate.dev"; +const DEVICE_AUTH_URL = `${LOGTO_ENDPOINT}/oidc/device/auth`; +const TOKEN_URL = `${LOGTO_ENDPOINT}/oidc/token`; + +/** Public client — a native app holds no secret. */ +const CLIENT_ID = "gciwm5x5fc2hfr5zjy4ve"; + +/** Audience the plugins backend validates access tokens against. */ +export const PLUGINS_RESOURCE = "https://plugins.archgate.dev"; + +const SCOPES = "openid profile email offline_access"; + +const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; + +/** RFC 8628 default when the server omits `interval`. */ +const DEFAULT_POLL_INTERVAL_SECONDS = 5; + +/** Renew this long before expiry so a token cannot lapse mid-request. */ +const EXPIRY_SKEW_SECONDS = 60; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +const DeviceCodeSchema = z.object({ + device_code: z.string(), + user_code: z.string(), + verification_uri: z.string(), + verification_uri_complete: z.string().optional(), + expires_in: z.number(), + interval: z.number().default(DEFAULT_POLL_INTERVAL_SECONDS), +}); + +export type DeviceCode = z.infer; + +const TokenSuccessSchema = z.object({ + access_token: z.string(), + refresh_token: z.string().optional(), + id_token: z.string().optional(), + expires_in: z.number(), +}); + +const IdTokenClaimsSchema = z.object({ + sub: z.string(), + username: z.string().nullish(), + name: z.string().nullish(), + email: z.string().nullish(), +}); + +const TokenErrorSchema = z.object({ + error: z.string(), + error_description: z.string().optional(), +}); + +/** A token pair plus the absolute epoch-ms instant the access token lapses. */ +export const TokenSetSchema = z.object({ + accessToken: z.string().min(1), + refreshToken: z.string().min(1), + expiresAt: z.number(), +}); + +export type TokenSet = z.infer; + +/** A token set plus the ID token from the exchange that produced it. */ +export interface TokenSetWithIdentity { + tokens: TokenSet; + idToken: string | undefined; +} + +// --------------------------------------------------------------------------- +// Device authorization +// --------------------------------------------------------------------------- + +/** + * Start a device authorization request. + * + * @returns The user code and verification URI to show the user. + * @throws {UserError} When the identity provider rejects the request. + */ +export async function requestDeviceCode(): Promise { + logDebug("Requesting Logto device code from:", DEVICE_AUTH_URL); + const response = await fetch(DEVICE_AUTH_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + scope: SCOPES, + resource: PLUGINS_RESOURCE, + }), + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + throw new UserError( + `Could not start sign-in (HTTP ${response.status}). Please try again.` + ); + } + + const parsed = DeviceCodeSchema.safeParse(await response.json()); + if (!parsed.success) { + throw new UserError("Sign-in service returned an unexpected response."); + } + return parsed.data; +} + +/** + * Poll the token endpoint until the user authorizes, the code expires, or the + * provider reports a terminal error. + * + * @param deviceCode - The `device_code` from {@link requestDeviceCode}. + * @param intervalSeconds - Server-supplied poll interval. + * @param expiresInSeconds - Lifetime of the device code. + * @returns The token set once the user has authorized. + * @throws {UserError} On denial, expiry, or a terminal provider error. + */ +export async function pollForTokens( + deviceCode: string, + intervalSeconds: number, + expiresInSeconds: number +): Promise { + const deadline = Date.now() + expiresInSeconds * 1000; + let interval = intervalSeconds; + + /* oxlint-disable no-await-in-loop -- sequential polling is required by RFC 8628 */ + while (Date.now() < deadline) { + await Bun.sleep(interval * 1000); + + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + grant_type: DEVICE_GRANT, + device_code: deviceCode, + }), + signal: AbortSignal.timeout(15_000), + }); + + const body: unknown = await response.json(); + + if (response.ok) { + return { tokens: toTokenSet(body), idToken: idTokenFrom(body) }; + } + + const error = TokenErrorSchema.safeParse(body); + if (!error.success) { + throw new UserError("Sign-in service returned an unexpected response."); + } + + switch (error.data.error) { + case "authorization_pending": + break; + case "slow_down": + interval += DEFAULT_POLL_INTERVAL_SECONDS; + break; + case "access_denied": + throw new UserError("Sign-in was denied."); + case "expired_token": + throw new UserError( + "The sign-in code expired. Run `archgate login` to try again." + ); + default: + throw new UserError( + error.data.error_description ?? `Sign-in failed: ${error.data.error}` + ); + } + } + /* oxlint-enable no-await-in-loop */ + + throw new UserError( + "The sign-in code expired. Run `archgate login` to try again." + ); +} + +// --------------------------------------------------------------------------- +// Token renewal +// --------------------------------------------------------------------------- + +/** + * Trade a refresh token for a fresh access token. + * + * @param refreshToken - The stored refresh token. + * @returns A new token set; the refresh token rotates, so store the result. + * @throws {UserError} When the refresh token is expired or revoked. + */ +export async function refreshAccessToken( + refreshToken: string +): Promise { + logDebug("Refreshing Logto access token"); + const response = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: CLIENT_ID, + grant_type: "refresh_token", + refresh_token: refreshToken, + resource: PLUGINS_RESOURCE, + }), + signal: AbortSignal.timeout(15_000), + }); + + const body: unknown = await response.json(); + if (!response.ok) { + throw new UserError( + "Your session has expired. Run `archgate login` to sign in again." + ); + } + + return toTokenSet(body, refreshToken); +} + +/** + * Read a display name out of an ID token. + * + * The claims are used for presentation only — the plugins backend verifies the + * access token independently, so nothing here is trusted for authorization. + * + * @param idToken - Compact JWS from the token endpoint. + * @returns A username, name, email or subject, in that order of preference. + */ +export function identityFromIdToken(idToken: string | undefined): string { + if (idToken === undefined || idToken === "") return "archgate"; + const parts = idToken.split("."); + if (parts.length < 3) return "archgate"; + const payload = parts[1]; + if (payload === "") return "archgate"; + + try { + const decoded: unknown = JSON.parse( + Buffer.from(payload, "base64url").toString("utf8") + ); + const claims = IdTokenClaimsSchema.safeParse(decoded); + if (!claims.success) return "archgate"; + return ( + claims.data.username ?? + claims.data.name ?? + claims.data.email ?? + claims.data.sub + ); + } catch { + return "archgate"; + } +} + +/** True when `expiresAt` is in the past or close enough to treat as lapsed. */ +export function isExpired(expiresAt: number): boolean { + return Date.now() >= expiresAt - EXPIRY_SKEW_SECONDS * 1000; +} + +/** Pull the raw ID token out of a token response, when present. */ +function idTokenFrom(body: unknown): string | undefined { + return TokenSuccessSchema.safeParse(body).data?.id_token; +} + +/** + * Normalize a token response into a {@link TokenSet}. + * + * @param body - Parsed JSON from the token endpoint. + * @param fallbackRefreshToken - Kept when the response omits a rotated one. + */ +function toTokenSet(body: unknown, fallbackRefreshToken?: string): TokenSet { + const parsed = TokenSuccessSchema.safeParse(body); + if (!parsed.success) { + throw new UserError("Sign-in service returned an unexpected response."); + } + + const refreshToken = parsed.data.refresh_token ?? fallbackRefreshToken; + if (refreshToken === undefined || refreshToken === "") { + throw new UserError( + "Sign-in did not return a refresh token. Please try again." + ); + } + + return { + accessToken: parsed.data.access_token, + refreshToken, + expiresAt: Date.now() + parsed.data.expires_in * 1000, + }; +} diff --git a/src/helpers/signup.ts b/src/helpers/signup.ts deleted file mode 100644 index fd2bd4d9..00000000 --- a/src/helpers/signup.ts +++ /dev/null @@ -1,76 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -/** - * signup.ts — Archgate plugins platform signup for unregistered users. - */ - -import { z } from "zod"; - -import { logDebug } from "./log"; - -const PLUGINS_API = "https://plugins.archgate.dev"; - -/** - * Sentinel error thrown when the token claim endpoint reports that the - * GitHub account has no approved signup. - */ -export class SignupRequiredError extends Error { - constructor() { - super("No approved signup found for this GitHub account"); - this.name = "SignupRequiredError"; - } -} - -export function isSignupRequiredError(message?: string): boolean { - if (message === undefined || message === "") return false; - const lower = message.toLowerCase(); - return ( - lower.includes("no approved signup") || lower.includes("not registered") - ); -} - -interface SignupResult { - ok: boolean; - /** Token returned by the API when signup is auto-approved. */ - token: string | null; -} - -/** - * Submit a signup request to the archgate plugins platform. - * On auto-approved signups the API returns the token directly, - * avoiding a separate claim round-trip. - */ -export async function requestSignup( - github: string, - email: string, - useCase: string, - editor: string = "claude-code" -): Promise { - logDebug("Submitting signup request for:", github); - const response = await fetch(`${PLUGINS_API}/api/signup`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": "archgate-cli", - Origin: PLUGINS_API, - }, - body: JSON.stringify({ github, email, useCase, editor }), - signal: AbortSignal.timeout(15_000), - redirect: "error", - }); - - if (response.status !== 201) { - logDebug("Signup request failed, status:", response.status); - return { ok: false, token: null }; - } - - const SignupResponseSchema = z.object({ - token: z.string().nullable().default(null), - }); - const result = SignupResponseSchema.safeParse( - await response.json().catch(() => ({})) - ); - const data = result.success ? result.data : { token: null }; - logDebug("Signup successful, token provided:", Boolean(data.token)); - return { ok: true, token: data.token }; -} diff --git a/tests/commands/credential.test.ts b/tests/commands/credential.test.ts new file mode 100644 index 00000000..33284343 --- /dev/null +++ b/tests/commands/credential.test.ts @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from "bun:test"; + +import { Command } from "@commander-js/extra-typings"; + +import { registerCredentialCommand } from "../../src/commands/credential"; +import * as credMod from "../../src/helpers/credential-store"; + +let stdinSpy: Mock; +let stdoutSpy: Mock; +let resolveSpy: Mock; +let clearSpy: Mock; + +/** Everything the command wrote to stdout during one run. */ +function written(): string { + return stdoutSpy.mock.calls.map((call) => String(call[0])).join(""); +} + +/** Run a `credential` subcommand with the given request on stdin. */ +async function run(subcommand: string, request: string): Promise { + stdinSpy = spyOn(Bun.stdin, "text").mockResolvedValue(request); + const program = new Command(); + program.exitOverride(); + registerCredentialCommand(program); + await program.parseAsync(["node", "archgate", "credential", subcommand]); +} + +const PLUGIN_REQUEST = "protocol=https\nhost=plugins.archgate.dev\n\n"; + +beforeEach(() => { + stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true); + resolveSpy = spyOn(credMod, "resolveAccessToken").mockResolvedValue({ + token: "ey.access", + github_user: "octocat", + }); + clearSpy = spyOn(credMod, "clearCredentials").mockResolvedValue(); +}); + +afterEach(() => { + stdinSpy.mockRestore(); + stdoutSpy.mockRestore(); + resolveSpy.mockRestore(); + clearSpy.mockRestore(); +}); + +describe("archgate credential get", () => { + test("answers with the stored username and access token", async () => { + await run("get", PLUGIN_REQUEST); + + expect(written()).toBe( + "protocol=https\nhost=plugins.archgate.dev\nusername=octocat\npassword=ey.access\n" + ); + }); + + // Silence tells git to fall through to its other helpers rather than fail. + test("prints nothing when signed out", async () => { + resolveSpy.mockResolvedValue(null); + + await run("get", PLUGIN_REQUEST); + + expect(written()).toBe(""); + }); + + test.each([ + "protocol=https\nhost=github.com\n\n", + "protocol=https\nhost=evil.example.com\n\n", + "protocol=https\n\n", + ])("stays silent for a request that is not ours (%p)", async (request) => { + await run("get", request); + + expect(written()).toBe(""); + expect(resolveSpy).not.toHaveBeenCalled(); + }); +}); + +describe("archgate credential erase", () => { + test("clears stored credentials for the plugins host", async () => { + await run("erase", PLUGIN_REQUEST); + + expect(clearSpy).toHaveBeenCalled(); + }); + + test("leaves credentials alone for another host", async () => { + await run("erase", "protocol=https\nhost=github.com\n\n"); + + expect(clearSpy).not.toHaveBeenCalled(); + }); +}); + +describe("archgate credential store", () => { + test("consumes the request without touching stored credentials", async () => { + await run("store", PLUGIN_REQUEST); + + expect(written()).toBe(""); + expect(clearSpy).not.toHaveBeenCalled(); + expect(resolveSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/helpers/auth.test.ts b/tests/helpers/auth.test.ts deleted file mode 100644 index aa89f52d..00000000 --- a/tests/helpers/auth.test.ts +++ /dev/null @@ -1,355 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { rejectionMessage, restoreEnv } from "../test-utils"; - -/** Type-safe fetch mock — Bun's fetch type includes `preconnect` which mock() doesn't provide. */ -function mockFetch(handler: () => Promise) { - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = mock(handler) as unknown as typeof fetch; -} - -describe("auth", () => { - let tempDir: string; - let originalHome: string | undefined; - let originalUserProfile: string | undefined; - let originalGitConfigNoSystem: string | undefined; - let originalGitConfigGlobal: string | undefined; - let originalFetch: typeof fetch; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "archgate-auth-test-")); - originalHome = Bun.env.HOME; - originalUserProfile = Bun.env.USERPROFILE; - originalGitConfigNoSystem = Bun.env.GIT_CONFIG_NOSYSTEM; - originalGitConfigGlobal = Bun.env.GIT_CONFIG_GLOBAL; - Bun.env.HOME = tempDir; - Bun.env.USERPROFILE = tempDir; - // Isolate git credential operations from the system credential store. - Bun.env.GIT_CONFIG_NOSYSTEM = "1"; - const emptyGitConfig = join(tempDir, ".gitconfig"); - writeFileSync(emptyGitConfig, ""); - Bun.env.GIT_CONFIG_GLOBAL = emptyGitConfig; - originalFetch = globalThis.fetch; - }); - - afterEach(() => { - // `Bun.env.X = undefined` assigns the STRING "undefined" and leaves the key - // present — it does not unset. HOME and GIT_CONFIG_GLOBAL are normally unset - // on Windows, and Bun.env is process-global across test files, so a plain - // restore would publish HOME="undefined" to every later test and any - // subprocess it spawns. restoreEnv deletes when the capture was absent. - restoreEnv("HOME", originalHome); - restoreEnv("USERPROFILE", originalUserProfile); - restoreEnv("GIT_CONFIG_NOSYSTEM", originalGitConfigNoSystem); - restoreEnv("GIT_CONFIG_GLOBAL", originalGitConfigGlobal); - rmSync(tempDir, { recursive: true, force: true }); - globalThis.fetch = originalFetch; - }); - - describe("saveCredentials / loadCredentials", () => { - test("does not write any file to disk", async () => { - const { saveCredentials } = - await import("../../src/helpers/credential-store"); - - await saveCredentials({ - token: "ag_beta_abc123", - github_user: "testuser", - }); - - // No credentials file should exist — everything is in git credential manager. - const credPath = join(tempDir, ".archgate", "credentials"); - expect(await Bun.file(credPath).exists()).toBe(false); - }); - - test("returns null when no credentials exist anywhere", async () => { - const { loadCredentials } = - await import("../../src/helpers/credential-store"); - - const result = await loadCredentials(); - expect(result).toBeNull(); - }); - - test("returns null and deletes legacy credentials file", async () => { - const { loadCredentials } = - await import("../../src/helpers/credential-store"); - - const credPath = join(tempDir, ".archgate", "credentials"); - const { mkdirSync } = await import("node:fs"); - mkdirSync(join(tempDir, ".archgate"), { recursive: true }); - await Bun.write( - credPath, - JSON.stringify({ token: "abc", github_user: "old" }) - ); - - const result = await loadCredentials(); - expect(result).toBeNull(); - expect(await Bun.file(credPath).exists()).toBe(false); - }); - }); - - describe("clearCredentials", () => { - test("clears git creds and removes legacy file", async () => { - const { clearCredentials } = - await import("../../src/helpers/credential-store"); - - // Create a legacy file - const { mkdirSync } = await import("node:fs"); - mkdirSync(join(tempDir, ".archgate"), { recursive: true }); - const credPath = join(tempDir, ".archgate", "credentials"); - await Bun.write(credPath, "{}"); - - await clearCredentials(); - - expect(await Bun.file(credPath).exists()).toBe(false); - }); - - test("does not throw when no credentials exist", async () => { - const { clearCredentials } = - await import("../../src/helpers/credential-store"); - - expect(clearCredentials()).resolves.toBeUndefined(); - }); - }); - - describe("requestDeviceCode", () => { - test("sends POST to GitHub device code endpoint", async () => { - const { requestDeviceCode } = await import("../../src/helpers/auth"); - - mockFetch(async () => - Response.json({ - device_code: "dc_123", - user_code: "ABCD-1234", - verification_uri: "https://github.com/login/device", - expires_in: 900, - interval: 5, - }) - ); - - const result = await requestDeviceCode(); - expect(result.device_code).toBe("dc_123"); - expect(result.user_code).toBe("ABCD-1234"); - expect(result.verification_uri).toBe("https://github.com/login/device"); - expect(result.interval).toBe(5); - }); - - test("throws on non-200 response", async () => { - const { requestDeviceCode } = await import("../../src/helpers/auth"); - - mockFetch(async () => new Response("Bad Request", { status: 400 })); - - expect(requestDeviceCode()).rejects.toThrow("HTTP 400"); - }); - }); - - describe("getGitHubUser", () => { - test("returns login from GitHub API", async () => { - const { getGitHubUser } = await import("../../src/helpers/auth"); - - mockFetch(async () => - Response.json({ login: "octocat", email: "octo@cat.com" }) - ); - - const user = await getGitHubUser("gho_test_token"); - expect(user.login).toBe("octocat"); - expect(user.email).toBe("octo@cat.com"); - }); - - test("throws when GitHub API returns non-200", async () => { - const { getGitHubUser } = await import("../../src/helpers/auth"); - - mockFetch(async () => new Response("Unauthorized", { status: 401 })); - - expect(getGitHubUser("bad_token")).rejects.toThrow("HTTP 401"); - }); - - test("throws when login missing from response", async () => { - const { getGitHubUser } = await import("../../src/helpers/auth"); - - mockFetch(async () => Response.json({ email: "octo@cat.com" })); - - expect(getGitHubUser("gho_test_token")).rejects.toThrow( - "GitHub API did not return a username" - ); - }); - }); - - describe("claimArchgateToken", () => { - test("returns token from plugins service", async () => { - const { claimArchgateToken } = await import("../../src/helpers/auth"); - - mockFetch(async () => Response.json({ token: "ag_beta_claimed_token" })); - - const token = await claimArchgateToken("gho_github_token"); - expect(token).toBe("ag_beta_claimed_token"); - }); - - test("throws SignupRequiredError on 403 with no approved signup", async () => { - const { claimArchgateToken } = await import("../../src/helpers/auth"); - const { SignupRequiredError } = await import("../../src/helpers/signup"); - - mockFetch(async () => - Response.json( - { error: "No approved signup found for this GitHub account" }, - { status: 403 } - ) - ); - - expect(claimArchgateToken("gho_token")).rejects.toBeInstanceOf( - SignupRequiredError - ); - }); - - test("throws generic error on non-signup 403", async () => { - const { claimArchgateToken } = await import("../../src/helpers/auth"); - - mockFetch(async () => - Response.json({ error: "Account suspended" }, { status: 403 }) - ); - - expect(claimArchgateToken("gho_token")).rejects.toThrow( - "Account suspended" - ); - }); - - test("throws when token missing from successful response", async () => { - const { claimArchgateToken } = await import("../../src/helpers/auth"); - - mockFetch(async () => Response.json({})); - - expect(claimArchgateToken("gho_token")).rejects.toThrow( - "Plugins service did not return a token" - ); - }); - }); - - describe("pollForAccessToken", () => { - let originalSleep: typeof Bun.sleep; - - beforeEach(() => { - originalSleep = Bun.sleep; - }); - - afterEach(() => { - Bun.sleep = originalSleep; - }); - - test("returns token after authorization_pending then success", async () => { - const { pollForAccessToken } = await import("../../src/helpers/auth"); - - Bun.sleep = mock(async () => {}); - - let callCount = 0; - // Deliberately incomplete fake: only the call signature fetch - // actually invokes matters for this test. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = mock(async () => { - callCount++; - if (callCount === 1) { - return Response.json({ error: "authorization_pending" }); - } - return Response.json({ - access_token: "gho_polled_token", - token_type: "bearer", - scope: "read:user", - }); - }) as unknown as typeof fetch; - - const token = await pollForAccessToken("dc_abc", 0, 60); - expect(token).toBe("gho_polled_token"); - expect(callCount).toBe(2); - }); - - test("handles slow_down by increasing poll interval", async () => { - const { pollForAccessToken } = await import("../../src/helpers/auth"); - - const sleepArgs: number[] = []; - Bun.sleep = mock(async (ms: number | Date) => { - sleepArgs.push(typeof ms === "number" ? ms : ms.getTime()); - }); - - let callCount = 0; - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = mock(async () => { - callCount++; - if (callCount === 1) { - return Response.json({ error: "slow_down" }); - } - return Response.json({ - access_token: "gho_after_slow_down", - token_type: "bearer", - scope: "read:user", - }); - }) as unknown as typeof fetch; - - const token = await pollForAccessToken("dc_abc", 0, 60); - expect(token).toBe("gho_after_slow_down"); - // After slow_down, interval increases by 5; second sleep should be 5*1000 - expect(sleepArgs[1]).toBe(5 * 1000); - }); - - test("throws on expired_token", async () => { - const { pollForAccessToken } = await import("../../src/helpers/auth"); - - Bun.sleep = mock(async () => {}); - - mockFetch(async () => - Response.json({ - error: "expired_token", - error_description: "The device code has expired.", - }) - ); - - expect(pollForAccessToken("dc_abc", 0, 60)).rejects.toThrow( - "The device code has expired." - ); - }); - - test("throws on access_denied", async () => { - const { pollForAccessToken } = await import("../../src/helpers/auth"); - - Bun.sleep = mock(async () => {}); - - mockFetch(async () => - Response.json({ - error: "access_denied", - error_description: "The user denied your request.", - }) - ); - - expect(pollForAccessToken("dc_abc", 0, 60)).rejects.toThrow( - "The user denied your request." - ); - }); - - test("throws when deadline expires before authorization", async () => { - const { pollForAccessToken } = await import("../../src/helpers/auth"); - - Bun.sleep = mock(async () => {}); - - mockFetch(async () => Response.json({ error: "authorization_pending" })); - - expect(pollForAccessToken("dc_abc", 0, 0)).rejects.toThrow( - "Device code expired. Please try again." - ); - }); - - test("throws on a non-OK HTTP status without parsing the body", async () => { - const { pollForAccessToken } = await import("../../src/helpers/auth"); - - Bun.sleep = mock(async () => {}); - - // A gateway error carries no OAuth error code, so the status is all the - // message can report. - mockFetch(async () => new Response("upstream failure", { status: 502 })); - - expect(await rejectionMessage(pollForAccessToken("dc_abc", 0, 60))).toBe( - "GitHub token poll failed (HTTP 502)" - ); - }); - }); -}); diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts index 993cc26c..96dc61eb 100644 --- a/tests/helpers/credential-store.test.ts +++ b/tests/helpers/credential-store.test.ts @@ -6,8 +6,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { - saveCredentials, + saveTokenSet, loadCredentials, + loadTokenSet, clearCredentials, } from "../../src/helpers/credential-store"; import { restoreEnv } from "../test-utils"; @@ -46,11 +47,12 @@ describe("credential-store", () => { } }); - describe("saveCredentials", () => { + describe("saveTokenSet", () => { test("does not write any metadata file to disk", async () => { - await saveCredentials({ - token: "ag_beta_abc123", - github_user: "testuser", + await saveTokenSet("testuser", { + accessToken: "ey.access", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, }); // No credentials file should be written — everything is in git credential manager. @@ -71,9 +73,10 @@ describe("credential-store", () => { JSON.stringify({ github_user: "old", created_at: "2025-01-01" }) ); - await saveCredentials({ - token: "ag_beta_abc123", - github_user: "testuser", + await saveTokenSet("testuser", { + accessToken: "ag_beta_abc123", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, }); expect(await Bun.file(credPath).exists()).toBe(false); @@ -89,9 +92,10 @@ describe("credential-store", () => { // fill returns nothing — triggers the verification warning path. const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); try { - await saveCredentials({ - token: "ag_beta_test", - github_user: "testuser", + await saveTokenSet("testuser", { + accessToken: "ag_beta_test", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, }); // The warning is printed because fill cannot verify the stored token. @@ -185,9 +189,10 @@ describe("credential-store", () => { const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); try { - await saveCredentials({ - token: "ag_beta_roundtrip", - github_user: "rounduser", + await saveTokenSet("rounduser", { + accessToken: "ag_beta_roundtrip", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, }); // With a working helper, verification succeeds — no warning about @@ -272,7 +277,11 @@ describe("credential-store", () => { }); const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); try { - await saveCredentials({ token: "ag_beta_x", github_user: "u" }); + await saveTokenSet("u", { + accessToken: "ag_beta_x", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, + }); expect(warnSpy.mock.calls.flat().join(" ")).toContain( "git credential approve failed." @@ -284,9 +293,10 @@ describe("credential-store", () => { }); }); - describe("StoredCredentials type", () => { - test("interface has expected shape", () => { - expect(typeof saveCredentials).toBe("function"); + describe("public surface", () => { + test("exposes the store, load and clear entry points", () => { + expect(typeof saveTokenSet).toBe("function"); + expect(typeof loadTokenSet).toBe("function"); expect(typeof loadCredentials).toBe("function"); expect(typeof clearCredentials).toBe("function"); }); diff --git a/tests/helpers/git-credential-config.test.ts b/tests/helpers/git-credential-config.test.ts new file mode 100644 index 00000000..dee8c00e --- /dev/null +++ b/tests/helpers/git-credential-config.test.ts @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + registerGitCredentialHelper, + unregisterGitCredentialHelper, +} from "../../src/helpers/git-credential-config"; +import { restoreEnv } from "../test-utils"; + +const HELPER_KEY = "credential.https://plugins.archgate.dev.helper"; + +let tempDir: string; +let gitConfigPath: string; +let originalNoSystem: string | undefined; +let originalGlobal: string | undefined; + +/** Read the helper entries git has recorded, in order. */ +async function configuredHelpers(): Promise { + const proc = Bun.spawn( + ["git", "config", "--global", "--get-all", HELPER_KEY], + { stdout: "pipe", stderr: "pipe", env: { ...Bun.env } } + ); + const stdout = await new Response(proc.stdout).text(); + await proc.exited; + return stdout.split("\n").slice(0, -1); +} + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "archgate-gitcred-test-")); + originalNoSystem = Bun.env.GIT_CONFIG_NOSYSTEM; + originalGlobal = Bun.env.GIT_CONFIG_GLOBAL; + gitConfigPath = join(tempDir, ".gitconfig"); + writeFileSync(gitConfigPath, ""); + Bun.env.GIT_CONFIG_NOSYSTEM = "1"; + Bun.env.GIT_CONFIG_GLOBAL = gitConfigPath; +}); + +afterEach(() => { + restoreEnv("GIT_CONFIG_NOSYSTEM", originalNoSystem); + restoreEnv("GIT_CONFIG_GLOBAL", originalGlobal); + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + /* temp dir cleanup best-effort */ + } +}); + +describe("registerGitCredentialHelper", () => { + test("records the helper for the plugins host", async () => { + expect(await registerGitCredentialHelper()).toBe(true); + + expect(await configuredHelpers()).toEqual(["", "!archgate credential"]); + }); + + // The empty entry resets helpers inherited from a broader config scope, so + // archgate is the only helper git consults for this host. + test("writes the reset entry ahead of its own", async () => { + writeFileSync( + gitConfigPath, + `[credential "https://plugins.archgate.dev"]\n\thelper = store\n` + ); + + await registerGitCredentialHelper(); + + const helpers = await configuredHelpers(); + expect(helpers[0]).toBe(""); + expect(helpers).not.toContain("store"); + }); + + test("is idempotent across repeated logins", async () => { + await registerGitCredentialHelper(); + await registerGitCredentialHelper(); + + expect(await configuredHelpers()).toEqual(["", "!archgate credential"]); + }); + + test("scopes the entry to the plugins host alone", async () => { + await registerGitCredentialHelper(); + + const config = readFileSync(gitConfigPath, "utf8"); + expect(config).toContain('[credential "https://plugins.archgate.dev"]'); + }); + + test("reports failure when git cannot write the config", async () => { + // Pointing the config at a directory leaves git with nothing it can write. + Bun.env.GIT_CONFIG_GLOBAL = tempDir; + + expect(await registerGitCredentialHelper()).toBe(false); + }); +}); + +describe("unregisterGitCredentialHelper", () => { + test("removes the entries login added", async () => { + await registerGitCredentialHelper(); + + expect(await unregisterGitCredentialHelper()).toBe(true); + expect(await configuredHelpers()).toEqual([]); + }); + + // Exit code 5 means the key was already absent, which is the desired state. + test("succeeds when no helper is configured", async () => { + expect(await unregisterGitCredentialHelper()).toBe(true); + }); +}); diff --git a/tests/helpers/git-credential-protocol.test.ts b/tests/helpers/git-credential-protocol.test.ts new file mode 100644 index 00000000..38dfacc4 --- /dev/null +++ b/tests/helpers/git-credential-protocol.test.ts @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, test } from "bun:test"; + +import { + formatCredentialResponse, + parseCredentialRequest, +} from "../../src/helpers/git-credential-protocol"; + +describe("parseCredentialRequest", () => { + test("reads the key=value block git writes", () => { + const request = parseCredentialRequest( + "protocol=https\nhost=plugins.archgate.dev\npath=archgate.git\n\n" + ); + + expect(request).toEqual({ + protocol: "https", + host: "plugins.archgate.dev", + path: "archgate.git", + }); + }); + + test("stops at the first blank line", () => { + const request = parseCredentialRequest( + "host=plugins.archgate.dev\n\nhost=evil.example.com\n" + ); + + expect(request.host).toBe("plugins.archgate.dev"); + }); + + test("keeps '=' inside a value", () => { + const request = parseCredentialRequest("password=a=b=c\n"); + + expect(request.password).toBe("a=b=c"); + }); + + test.each(["", "\n", "novalue\n", "=leading\n"])( + "yields no usable pairs for %p", + (input) => { + expect(parseCredentialRequest(input)).toEqual({}); + } + ); +}); + +describe("formatCredentialResponse", () => { + test("emits one newline-terminated pair per field", () => { + const output = formatCredentialResponse({ + protocol: "https", + host: "plugins.archgate.dev", + username: "octocat", + password: "ey.token", + }); + + expect(output).toBe( + "protocol=https\nhost=plugins.archgate.dev\nusername=octocat\npassword=ey.token\n" + ); + }); + + // A newline in a value would let it forge further protocol lines, so such a + // field is dropped rather than emitted. + test.each([ + ["password", "tok\nhost=evil.example.com"], + ["username", "user\0root"], + ])("drops %s when the value can forge protocol lines", (key, value) => { + const output = formatCredentialResponse({ host: "h", [key]: value }); + + expect(output).toBe("host=h\n"); + }); +}); diff --git a/tests/helpers/login-flow.test.ts b/tests/helpers/login-flow.test.ts index c4595d0c..8cbe3a1b 100644 --- a/tests/helpers/login-flow.test.ts +++ b/tests/helpers/login-flow.test.ts @@ -5,473 +5,137 @@ import { beforeEach, describe, expect, - mock, type Mock, spyOn, test, } from "bun:test"; -// --------------------------------------------------------------------------- -// Module mocks — must be declared before imports that use them. -// --------------------------------------------------------------------------- - -// Auth + credential-store stubs are installed per-test via spyOn (see -// beforeEach), NOT mock.module. spyOn is auto-restored and scoped to this -// file; mock.module is process-global and would leak mocked implementations -// into auth.test.ts and credential-store.test.ts. -let mockRequestDeviceCode: Mock; -let mockPollForAccessToken: Mock; -let mockGetGitHubUser: Mock; -let mockClaimArchgateToken: Mock; -let mockSaveCredentials: Mock; - -// Mock inquirer for the signup flow prompts (lazy-loaded via dynamic import). -// Use Record as return type so mockImplementation can return -// different shapes for different prompts (email, editor, useCase, confirmed). -/** - * The subset of each signup question the tests read back. Declaring it lets - * the validator assertions below index the recorded call without a cast. - */ -interface SignupQuestion { - name?: string; - validate?: (value: string) => boolean | string; -} - -const mockInquirerPrompt = mock( - async (_question: SignupQuestion): Promise> => ({ - email: "test@example.com", - }) -); -void mock.module("inquirer", () => ({ - default: { prompt: mockInquirerPrompt }, -})); - -// --------------------------------------------------------------------------- -// Import SignupRequiredError BEFORE mocking — we need the real class so -// instanceof checks in login-flow.ts work correctly. -// Note: we do NOT mock signup.ts to avoid cross-test contamination with -// signup.test.ts (which uses static imports). -// --------------------------------------------------------------------------- - -import * as authMod from "../../src/helpers/auth"; import * as credMod from "../../src/helpers/credential-store"; +import * as gitConfigMod from "../../src/helpers/git-credential-config"; import { runLoginFlow } from "../../src/helpers/login-flow"; -// --------------------------------------------------------------------------- -// Imports under test — loaded AFTER mocks are registered. -// --------------------------------------------------------------------------- -import type { - LoginFlowOptions, - LoginFlowResult, -} from "../../src/helpers/login-flow"; -import { SignupRequiredError } from "../../src/helpers/signup"; - -// --------------------------------------------------------------------------- -// Fetch mock for signup endpoint — requestSignup() uses globalThis.fetch. -// Per ARCH-005: assign globalThis.fetch directly, don't use mock.module. -// --------------------------------------------------------------------------- - -let originalFetch: typeof globalThis.fetch; - -describe("login-flow", () => { - beforeEach(() => { - // Silence console output (restored via mock.restore() in afterEach). - spyOn(console, "log").mockImplementation(() => {}); - spyOn(console, "error").mockImplementation(() => {}); - - // Save original fetch — only needed for signup tests that go through - // the real requestSignup function. - originalFetch = globalThis.fetch; - - // Install fresh per-test spies with default implementations. - mockRequestDeviceCode = spyOn( - authMod, - "requestDeviceCode" - ).mockResolvedValue({ - device_code: "dc-test-123", - user_code: "ABCD-1234", - verification_uri: "https://github.com/login/device", - expires_in: 900, - interval: 5, - }); - mockPollForAccessToken = spyOn( - authMod, - "pollForAccessToken" - ).mockResolvedValue("gh-token-test-456"); - mockGetGitHubUser = spyOn(authMod, "getGitHubUser").mockResolvedValue({ - login: "octocat", - email: "octocat@github.com", - }); - mockClaimArchgateToken = spyOn( - authMod, - "claimArchgateToken" - ).mockResolvedValue("archgate-token-789"); - mockSaveCredentials = spyOn(credMod, "saveCredentials").mockImplementation( - async () => {} - ); - - mockInquirerPrompt.mockClear(); - mockInquirerPrompt.mockImplementation(async () => ({ - email: "test@example.com", - })); - }); - - afterEach(() => { - // Restore all spyOn spies (console + auth + credential-store). - mock.restore(); - globalThis.fetch = originalFetch; - }); - - // ----------------------------------------------------------------------- - // Type exports - // ----------------------------------------------------------------------- - - test("runLoginFlow is exported as a function", () => { - expect(typeof runLoginFlow).toBe("function"); - }); - - test("LoginFlowOptions accepts editor field", () => { - const opts: LoginFlowOptions = { editor: "claude-code" }; - expect(opts.editor).toBe("claude-code"); - }); +import * as logtoMod from "../../src/helpers/logto-auth"; +import { rejectionMessage } from "../test-utils"; + +// Stubs are installed per-test via spyOn, which is auto-restored and scoped to +// this file; mock.module is process-global and would leak into sibling suites. +let mockRequestDeviceCode: Mock; +let mockPollForTokens: Mock; +let mockSaveTokenSet: Mock; +let mockRegisterHelper: Mock; +let logSpy: Mock; + +const DEVICE_CODE = { + device_code: "device-abc", + user_code: "HZML-HXLB", + verification_uri: "https://auth.archgate.dev/device", + expires_in: 600, + interval: 5, +}; + +const TOKENS = { + accessToken: "ey.access.token", + refreshToken: "refresh-abc", + expiresAt: 1_800_000_000_000, +}; + +/** An ID token whose payload carries the given claims. */ +function idTokenFor(claims: Record): string { + const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); + return `header.${payload}.signature`; +} - test("LoginFlowResult shape", () => { - const success: LoginFlowResult = { ok: true, githubUser: "octocat" }; - expect(success.ok).toBe(true); - expect(success.githubUser).toBe("octocat"); +beforeEach(() => { + mockRequestDeviceCode = spyOn( + logtoMod, + "requestDeviceCode" + ).mockResolvedValue(DEVICE_CODE); + mockPollForTokens = spyOn(logtoMod, "pollForTokens").mockResolvedValue({ + tokens: TOKENS, + idToken: idTokenFor({ sub: "usr_1", username: "octocat" }), + }); + mockSaveTokenSet = spyOn(credMod, "saveTokenSet").mockResolvedValue(); + mockRegisterHelper = spyOn( + gitConfigMod, + "registerGitCredentialHelper" + ).mockResolvedValue(true); + logSpy = spyOn(console, "log").mockImplementation(() => {}); +}); - const failure: LoginFlowResult = { ok: false }; - expect(failure.ok).toBe(false); - expect(failure.githubUser).toBeUndefined(); - }); +afterEach(() => { + mockRequestDeviceCode.mockRestore(); + mockPollForTokens.mockRestore(); + mockSaveTokenSet.mockRestore(); + mockRegisterHelper.mockRestore(); + logSpy.mockRestore(); +}); - test("successful login: device code -> poll -> claim -> save", async () => { +describe("runLoginFlow", () => { + test("stores the token set under the signed-in identity", async () => { const result = await runLoginFlow(); - expect(result.ok).toBe(true); - expect(result.githubUser).toBe("octocat"); - - // Verify the full auth chain was called - expect(mockRequestDeviceCode).toHaveBeenCalledTimes(1); - expect(mockPollForAccessToken).toHaveBeenCalledTimes(1); - expect(mockPollForAccessToken).toHaveBeenCalledWith("dc-test-123", 5, 900); - expect(mockGetGitHubUser).toHaveBeenCalledTimes(1); - expect(mockGetGitHubUser).toHaveBeenCalledWith("gh-token-test-456"); - expect(mockClaimArchgateToken).toHaveBeenCalledTimes(1); - expect(mockClaimArchgateToken).toHaveBeenCalledWith("gh-token-test-456"); - expect(mockSaveCredentials).toHaveBeenCalledTimes(1); - expect(mockSaveCredentials).toHaveBeenCalledWith({ - token: "archgate-token-789", - github_user: "octocat", - }); + expect(result).toEqual({ ok: true, githubUser: "octocat" }); + expect(mockSaveTokenSet).toHaveBeenCalledWith("octocat", TOKENS); }); - test("requestDeviceCode throws -> propagates error", async () => { - mockRequestDeviceCode.mockImplementation(async () => { - throw new Error("GitHub device code request failed (HTTP 500)"); - }); + test("shows the verification URI and user code", async () => { + await runLoginFlow(); - expect(runLoginFlow()).rejects.toThrow("GitHub device code request failed"); - expect(mockPollForAccessToken).not.toHaveBeenCalled(); - expect(mockSaveCredentials).not.toHaveBeenCalled(); + const printed = logSpy.mock.calls.flat().join("\n"); + expect(printed).toContain("https://auth.archgate.dev/device"); + expect(printed).toContain("HZML-HXLB"); }); - test("pollForAccessToken throws -> propagates error", async () => { - mockPollForAccessToken.mockImplementation(async () => { - throw new Error("Device code expired"); - }); + test("polls with the device code and server-supplied interval", async () => { + await runLoginFlow(); - expect(runLoginFlow()).rejects.toThrow("Device code expired"); - expect(mockClaimArchgateToken).not.toHaveBeenCalled(); - expect(mockSaveCredentials).not.toHaveBeenCalled(); + expect(mockPollForTokens).toHaveBeenCalledWith("device-abc", 5, 600); }); - test("getGitHubUser throws -> propagates error", async () => { - mockGetGitHubUser.mockImplementation(async () => { - throw new Error("Failed to fetch GitHub user (HTTP 401)"); - }); - - expect(runLoginFlow()).rejects.toThrow("Failed to fetch GitHub user"); - expect(mockClaimArchgateToken).not.toHaveBeenCalled(); - expect(mockSaveCredentials).not.toHaveBeenCalled(); - }); - - // ----------------------------------------------------------------------- - // claimArchgateToken throws SignupRequiredError -> enters signup flow - // ----------------------------------------------------------------------- - - test("signup required: auto-approved token returned from signup", async () => { - // First call to claimArchgateToken throws SignupRequiredError - mockClaimArchgateToken.mockImplementation(async () => { - throw new SignupRequiredError(); - }); - - // Mock fetch for the signup endpoint — requestSignup uses globalThis.fetch - // Deliberately incomplete fake: only the call signature fetch invokes - // matters for this test. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = (async (url: string | URL | Request) => { - const urlStr = - typeof url === "string" - ? url - : url instanceof URL - ? url.toString() - : url.url; - if (urlStr.includes("/api/signup")) { - return Response.json({ token: "auto-approved-token" }, { status: 201 }); - } - return originalFetch(url); - }) as unknown as typeof fetch; - - // Mock the sequence of inquirer prompts: - // 1. email -> 2. editor -> 3. useCase -> 4. confirmed - let promptCallCount = 0; - mockInquirerPrompt.mockImplementation(async () => { - promptCallCount++; - switch (promptCallCount) { - case 1: - return { email: "test@example.com" }; - case 2: - return { editor: "vscode" }; - case 3: - return { useCase: "governance" }; - case 4: - return { confirmed: true }; - default: - return {}; - } - }); - - const result = await runLoginFlow(); + test("registers archgate as git's credential helper", async () => { + await runLoginFlow(); - expect(result.ok).toBe(true); - expect(result.githubUser).toBe("octocat"); - expect(mockSaveCredentials).toHaveBeenCalledWith({ - token: "auto-approved-token", - github_user: "octocat", - }); + expect(mockRegisterHelper).toHaveBeenCalled(); }); - test("signup without auto-token falls back to second claim call", async () => { - // First claimArchgateToken call throws, second succeeds - let claimCallCount = 0; - mockClaimArchgateToken.mockImplementation(async () => { - claimCallCount++; - if (claimCallCount === 1) { - throw new SignupRequiredError(); - } - return "fallback-token-xyz"; - }); - - // Mock fetch for the signup endpoint — no token returned - // Deliberately incomplete fake: only the call signature fetch invokes - // matters for this test. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = (async (url: string | URL | Request) => { - const urlStr = - typeof url === "string" - ? url - : url instanceof URL - ? url.toString() - : url.url; - if (urlStr.includes("/api/signup")) { - return Response.json({}, { status: 201 }); - } - return originalFetch(url); - }) as unknown as typeof fetch; - - let promptCallCount = 0; - mockInquirerPrompt.mockImplementation(async () => { - promptCallCount++; - switch (promptCallCount) { - case 1: - return { email: "test@example.com" }; - case 2: - return { editor: "cursor" }; - case 3: - return { useCase: "testing" }; - case 4: - return { confirmed: true }; - default: - return {}; - } - }); + // Downloads use the Bearer token directly, so a failed git config write must + // not fail the login — only clones lose their non-interactive credentials. + test("still succeeds when the git config write fails", async () => { + mockRegisterHelper.mockResolvedValue(false); const result = await runLoginFlow(); expect(result.ok).toBe(true); - expect(claimCallCount).toBe(2); - expect(mockSaveCredentials).toHaveBeenCalledWith({ - token: "fallback-token-xyz", - github_user: "octocat", - }); + expect(mockSaveTokenSet).toHaveBeenCalled(); }); - test("signup cancelled (confirmed=false) -> returns ok:false", async () => { - mockClaimArchgateToken.mockImplementation(async () => { - throw new SignupRequiredError(); - }); - - let promptCallCount = 0; - mockInquirerPrompt.mockImplementation(async () => { - promptCallCount++; - switch (promptCallCount) { - case 1: - return { email: "test@example.com" }; - case 2: - return { editor: "vscode" }; - case 3: - return { useCase: "testing" }; - case 4: - return { confirmed: false }; - default: - return {}; - } + test.each([ + [{ sub: "usr_1", username: "octocat" }, "octocat"], + [{ sub: "usr_1", name: "Octo Cat" }, "Octo Cat"], + [{ sub: "usr_1", email: "octo@example.com" }, "octo@example.com"], + [{ sub: "usr_1" }, "usr_1"], + ])("names the account from %o", async (claims, expected) => { + mockPollForTokens.mockResolvedValue({ + tokens: TOKENS, + idToken: idTokenFor(claims), }); const result = await runLoginFlow(); - expect(result.ok).toBe(false); - expect(result.githubUser).toBeUndefined(); - expect(mockSaveCredentials).not.toHaveBeenCalled(); + expect(result.githubUser).toBe(expected); }); - test("signup request fails -> returns ok:false", async () => { - mockClaimArchgateToken.mockImplementation(async () => { - throw new SignupRequiredError(); - }); - - // Mock fetch for the signup endpoint — returns failure - // Deliberately incomplete fake: only the call signature fetch invokes - // matters for this test. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = (async (url: string | URL | Request) => { - const urlStr = - typeof url === "string" - ? url - : url instanceof URL - ? url.toString() - : url.url; - if (urlStr.includes("/api/signup")) { - return new Response("Conflict", { status: 409 }); - } - return originalFetch(url); - }) as unknown as typeof fetch; - - let promptCallCount = 0; - mockInquirerPrompt.mockImplementation(async () => { - promptCallCount++; - switch (promptCallCount) { - case 1: - return { email: "test@example.com" }; - case 2: - return { editor: "vscode" }; - case 3: - return { useCase: "testing" }; - case 4: - return { confirmed: true }; - default: - return {}; - } - }); + test("falls back to a generic name when no ID token is returned", async () => { + mockPollForTokens.mockResolvedValue({ tokens: TOKENS, idToken: undefined }); const result = await runLoginFlow(); - expect(result.ok).toBe(false); - expect(mockSaveCredentials).not.toHaveBeenCalled(); + expect(result.githubUser).toBe("archgate"); }); - test("pre-selected editor skips editor prompt in signup flow", async () => { - mockClaimArchgateToken.mockImplementation(async () => { - throw new SignupRequiredError(); - }); - - let signupBody: Record | null = null; - // Deliberately incomplete fake: only the call signature fetch invokes - // matters for this test. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = (async ( - _url: string | URL | Request, - init?: RequestInit - ) => { - const rawBody = typeof init?.body === "string" ? init.body : "{}"; - // Test-only signup request body; no schema exists for it. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - signupBody = JSON.parse(rawBody) as Record; - return Response.json({ token: "editor-preset-token" }, { status: 201 }); - }) as unknown as typeof fetch; - - // With preselected editor, only 3 prompts: email, useCase, confirmed - let promptCallCount = 0; - mockInquirerPrompt.mockImplementation(async () => { - promptCallCount++; - switch (promptCallCount) { - case 1: - return { email: "test@example.com" }; - case 2: - return { useCase: "governance" }; - case 3: - return { confirmed: true }; - default: - return {}; - } - }); - - const result = await runLoginFlow({ editor: "claude-code" }); - - expect(result.ok).toBe(true); - // The pre-selected editor "claude-code" should be passed to requestSignup - expect(signupBody).not.toBeNull(); - expect(signupBody!.editor).toBe("claude-code"); - // Only 3 prompts (no editor prompt) - expect(promptCallCount).toBe(3); - }); - - test("signup prompts validate the email and use-case answers", async () => { - mockClaimArchgateToken.mockImplementation(async () => { - throw new SignupRequiredError(); - }); - - // Deliberately incomplete fake: only the call signature fetch invokes - // matters for this test. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = (async () => - Response.json( - { token: "validated-token" }, - { status: 201 } - )) as unknown as typeof fetch; - - // A pre-selected editor drops the editor prompt, so the recorded calls - // are email, useCase, confirm — in that order. - let promptCallCount = 0; - mockInquirerPrompt.mockImplementation(async () => { - promptCallCount++; - switch (promptCallCount) { - case 1: - return { email: "test@example.com" }; - case 2: - return { useCase: "governance" }; - default: - return { confirmed: true }; - } - }); - - await runLoginFlow({ editor: "claude-code" }); - - const emailValidate = mockInquirerPrompt.mock.calls[0][0].validate; - expect(emailValidate?.("not-an-email")).toBe("Enter a valid email address"); - expect(emailValidate?.("dev@example.com")).toBe(true); - - const useCaseValidate = mockInquirerPrompt.mock.calls[1][0].validate; - expect(useCaseValidate?.(" ")).toBe("Please describe your use case"); - expect(useCaseValidate?.("governance")).toBe(true); - }); - - test("claimArchgateToken throws non-signup error -> propagates", async () => { - mockClaimArchgateToken.mockImplementation(async () => { - throw new Error("Token claim failed (HTTP 500)"); - }); + test("propagates a failure from the device authorization request", async () => { + mockRequestDeviceCode.mockRejectedValue(new Error("network down")); - expect(runLoginFlow()).rejects.toThrow("Token claim failed"); - expect(mockSaveCredentials).not.toHaveBeenCalled(); + expect(await rejectionMessage(runLoginFlow())).toContain("network down"); + expect(mockSaveTokenSet).not.toHaveBeenCalled(); }); }); diff --git a/tests/helpers/logto-auth.test.ts b/tests/helpers/logto-auth.test.ts new file mode 100644 index 00000000..ad9f080d --- /dev/null +++ b/tests/helpers/logto-auth.test.ts @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +import { + identityFromIdToken, + isExpired, + pollForTokens, + refreshAccessToken, + requestDeviceCode, +} from "../../src/helpers/logto-auth"; +import { rejectionMessage } from "../test-utils"; + +const originalFetch = globalThis.fetch; + +/** Queue of responses returned by the stubbed fetch, in order. */ +let responses: Response[]; + +/** A `globalThis.fetch` stand-in carrying the `preconnect` member its type requires. */ +function stubFetch(impl: () => Promise): typeof globalThis.fetch { + return Object.assign(impl, { + preconnect: () => { + // Present only to satisfy the fetch type; no test calls it. + }, + }); +} + +beforeEach(() => { + responses = []; + globalThis.fetch = stubFetch( + mock(async (): Promise => { + const next = responses.shift(); + if (!next) throw new Error("unexpected fetch call"); + return next; + }) + ); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +/** A token endpoint error body in the shape Logto returns. */ +function errorResponse(error: string): Response { + return Response.json({ error }, { status: 400 }); +} + +describe("requestDeviceCode", () => { + test("returns the user code and verification URI", async () => { + responses.push( + Response.json({ + device_code: "device-abc", + user_code: "HZML-HXLB", + verification_uri: "https://auth.archgate.dev/device", + expires_in: 600, + }) + ); + + const code = await requestDeviceCode(); + + expect(code.user_code).toBe("HZML-HXLB"); + expect(code.device_code).toBe("device-abc"); + }); + + // Logto omits `interval`; RFC 8628 makes 5 seconds the default. + test("defaults the poll interval when the server omits it", async () => { + responses.push( + Response.json({ + device_code: "device-abc", + user_code: "HZML-HXLB", + verification_uri: "https://auth.archgate.dev/device", + expires_in: 600, + }) + ); + + expect((await requestDeviceCode()).interval).toBe(5); + }); + + test("reports a non-OK response as a user-facing error", async () => { + responses.push(new Response("nope", { status: 503 })); + + expect(await rejectionMessage(requestDeviceCode())).toContain( + "Could not start sign-in" + ); + }); +}); + +describe("pollForTokens", () => { + test("keeps polling while authorization is pending", async () => { + responses.push( + errorResponse("authorization_pending"), + Response.json({ + access_token: "ey.access", + refresh_token: "refresh-abc", + expires_in: 3600, + }) + ); + + const result = await pollForTokens("device-abc", 0, 60); + + expect(result.tokens.accessToken).toBe("ey.access"); + expect(result.tokens.refreshToken).toBe("refresh-abc"); + }); + + test.each([ + ["access_denied", "Sign-in was denied."], + ["expired_token", "The sign-in code expired."], + ])("stops on %s", async (error, message) => { + responses.push(errorResponse(error)); + + expect( + await rejectionMessage(pollForTokens("device-abc", 0, 60)) + ).toContain(message); + }); + + test("gives up once the device code lifetime elapses", async () => { + expect( + await rejectionMessage(pollForTokens("device-abc", 0, -1)) + ).toContain("The sign-in code expired."); + }); + + test("fails when the exchange returns no refresh token", async () => { + responses.push( + Response.json({ access_token: "ey.access", expires_in: 60 }) + ); + + expect( + await rejectionMessage(pollForTokens("device-abc", 0, 60)) + ).toContain("did not return a refresh token"); + }); +}); + +describe("refreshAccessToken", () => { + test("returns the rotated token pair", async () => { + responses.push( + Response.json({ + access_token: "ey.new", + refresh_token: "refresh-new", + expires_in: 3600, + }) + ); + + const tokens = await refreshAccessToken("refresh-old"); + + expect(tokens.accessToken).toBe("ey.new"); + expect(tokens.refreshToken).toBe("refresh-new"); + }); + + // Logto rotates refresh tokens, but a response without one must not discard + // the token the caller already holds. + test("keeps the existing refresh token when none is returned", async () => { + responses.push(Response.json({ access_token: "ey.new", expires_in: 3600 })); + + expect((await refreshAccessToken("refresh-old")).refreshToken).toBe( + "refresh-old" + ); + }); + + test("asks the user to sign in again when the grant is rejected", async () => { + responses.push(errorResponse("invalid_grant")); + + expect(await rejectionMessage(refreshAccessToken("refresh-old"))).toContain( + "Your session has expired" + ); + }); +}); + +describe("isExpired", () => { + test.each([ + ["already past", -1_000, true], + ["inside the renewal skew", 30_000, true], + ["comfortably valid", 600_000, false], + ])("%s", (_label, offsetMs, expected) => { + expect(isExpired(Date.now() + offsetMs)).toBe(expected); + }); +}); + +describe("identityFromIdToken", () => { + test.each([ + [{ sub: "usr_1", username: "octocat" }, "octocat"], + [{ sub: "usr_1", name: "Octo Cat" }, "Octo Cat"], + [{ sub: "usr_1", email: "octo@example.com" }, "octo@example.com"], + [{ sub: "usr_1" }, "usr_1"], + ])("prefers the most specific claim in %o", (claims, expected) => { + const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); + + expect(identityFromIdToken(`header.${payload}.sig`)).toBe(expected); + }); + + test.each([undefined, "", "not-a-jwt", "header.!!!notbase64!!!.sig"])( + "falls back to a generic name for %p", + (token) => { + expect(identityFromIdToken(token)).toBe("archgate"); + } + ); +}); diff --git a/tests/helpers/signup.test.ts b/tests/helpers/signup.test.ts deleted file mode 100644 index d4b77ce8..00000000 --- a/tests/helpers/signup.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import { afterEach, beforeEach, describe, expect, test, mock } from "bun:test"; - -import { - SignupRequiredError, - isSignupRequiredError, - requestSignup, -} from "../../src/helpers/signup"; - -/** Type-safe fetch mock — Bun's fetch type includes `preconnect` which mock() doesn't provide. */ -function mockFetch(handler: () => Promise) { - // Deliberately incomplete fake: mock() can't reproduce fetch's full type - // (preconnect etc.), and only the callable shape is ever exercised. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = mock(handler) as unknown as typeof fetch; -} - -describe("SignupRequiredError", () => { - test("is an instance of Error", () => { - const err = new SignupRequiredError(); - expect(err).toBeInstanceOf(Error); - }); - - test("has the correct name", () => { - expect(new SignupRequiredError().name).toBe("SignupRequiredError"); - }); - - test("has a descriptive message", () => { - expect(new SignupRequiredError().message).toContain("No approved signup"); - }); -}); - -describe("isSignupRequiredError", () => { - test.each<[string | undefined, boolean]>([ - ["No approved signup found for this GitHub account", true], - ["User is not registered", true], - ["NO APPROVED SIGNUP", true], - ["Token expired", false], - [undefined, false], - ])("returns %p for %p", (input, expected) => { - expect(isSignupRequiredError(input)).toBe(expected); - }); -}); - -describe("requestSignup", () => { - let originalFetch: typeof fetch; - - beforeEach(() => { - originalFetch = globalThis.fetch; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - test("returns ok=true and token on 201 with token", async () => { - mockFetch(async () => - Response.json({ token: "ag_beta_auto_approved" }, { status: 201 }) - ); - - const result = await requestSignup( - "octocat", - "octo@example.com", - "testing" - ); - expect(result.ok).toBe(true); - expect(result.token).toBe("ag_beta_auto_approved"); - }); - - test("returns ok=true and token=null on 201 without token (manual approval)", async () => { - mockFetch(async () => Response.json({}, { status: 201 })); - - const result = await requestSignup( - "octocat", - "octo@example.com", - "testing" - ); - expect(result.ok).toBe(true); - expect(result.token).toBeNull(); - }); - - test("returns ok=false and token=null on non-201 status", async () => { - mockFetch(async () => new Response("Conflict", { status: 409 })); - - const result = await requestSignup( - "octocat", - "octo@example.com", - "testing" - ); - expect(result.ok).toBe(false); - expect(result.token).toBeNull(); - }); - - test("returns ok=true and token=null when response.json() throws", async () => { - mockFetch(async () => new Response("not-json", { status: 201 })); - - const result = await requestSignup( - "octocat", - "octo@example.com", - "testing" - ); - expect(result.ok).toBe(true); - expect(result.token).toBeNull(); - }); - - test("propagates rejection when fetch fails (e.g. network error or timeout)", async () => { - mockFetch(async () => { - throw new Error("network down"); - }); - - expect( - requestSignup("octocat", "octo@example.com", "testing") - ).rejects.toThrow("network down"); - }); - - test("sends default editor=claude-code when editor not provided", async () => { - let capturedBody: string | null = null; - - // Deliberately incomplete fake: only the callable shape is exercised. - // oxlint-disable-next-line typescript/no-unsafe-type-assertion - globalThis.fetch = mock( - async (_input: string | URL | Request, init?: RequestInit) => { - capturedBody = typeof init?.body === "string" ? init.body : null; - return Response.json({ token: "ag_beta_tok" }, { status: 201 }); - } - ) as unknown as typeof fetch; - - await requestSignup("octocat", "octo@example.com", "testing"); - expect(capturedBody).not.toBeNull(); - const parsed: unknown = JSON.parse(capturedBody!); - if ( - typeof parsed !== "object" || - parsed === null || - !("editor" in parsed) - ) { - throw new Error("expected a parsed request body with an editor field"); - } - expect(parsed.editor).toBe("claude-code"); - }); -}); From 3a03947999b65e3ab02f3db7639a61109c1fa847 Mon Sep 17 00:00:00 2001 From: rhuanbarreto <283004+rhuanbarreto@users.noreply.github.com> Date: Sat, 5 Sep 2026 22:53:58 +0000 Subject: [PATCH 02/27] docs: regenerate llms-full.txt Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 114 ++++++++++++++++++++++++++++++-------- 1 file changed, 90 insertions(+), 24 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 19182afb..3f25e9b5 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4459,6 +4459,86 @@ When the running binary lives under `~/.archgate/bin/`, the message notes that ` --- +## Reference: archgate credential + +Source: https://cli.archgate.dev/reference/cli/credential/ + +Supplies git with a valid access token for Archgate plugin repositories. You do not normally run this command yourself -- git invokes it, and [`archgate login`](/reference/cli/login/) registers it for you. + +```bash +archgate credential get +``` + +Plugin repositories are cloned over HTTPS from `plugins.archgate.dev`, and git authenticates those requests itself rather than going through the CLI. Access tokens are short-lived, so a token written once into your credential manager would stop working within the hour. This helper closes that gap: git asks it for credentials on each request, and it answers with a cached access token, renewing it from the stored refresh token only once it has lapsed. + +The command speaks git's [credential helper protocol](https://git-scm.com/docs/git-credential) on standard input and output. It answers only for `plugins.archgate.dev` and stays silent for every other host, so credentials for the rest of your remotes keep using whatever helper you already configured. + +## Subcommands + +| Subcommand | Description | +| --------------------------- | -------------------------------------------------------- | +| `archgate credential get` | Print credentials for a plugin repository | +| `archgate credential store` | Accept a credential from git (no-op) | +| `archgate credential erase` | Remove stored credentials when git reports them rejected | + +`store` is deliberately a no-op: tokens come from `archgate login`, so there is nothing for git to teach the CLI. + +## Configuration + +`archgate login` writes the following to your global git config: + +```ini +[credential "https://plugins.archgate.dev"] + helper = + helper = !archgate credential +``` + +The empty first entry resets any helper inherited from a broader config scope, so `archgate` is the only helper consulted for this host. `archgate login logout` removes both entries. + +## Examples + +Inspect what git receives: + +```bash +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` +protocol=https +host=plugins.archgate.dev +username=yourname +password=eyJhbGciOiJFUzM4NCIsInR5cCI6ImF0K2p3dCJ9... +``` + +When you are signed out, the command prints nothing and exits successfully, which tells git to fall through to its other helpers: + +```bash +archgate login logout +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get +``` + +``` + +``` + +## Troubleshooting + +### `git clone` prompts for a username and password + +The helper is not registered. Run `archgate login` again, or check the configuration directly: + +```bash +git config --get-all credential.https://plugins.archgate.dev.helper +``` + +The output should list an empty line followed by `!archgate credential`. If it does not, the CLI could not write your global git config -- confirm `git config --global --list` works. + +### The helper is registered but credentials are still rejected + +Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, `archgate credential get` cannot renew the access token and stays silent; `archgate login` re-establishes it. + +--- + ## Reference: archgate doctor Source: https://cli.archgate.dev/reference/cli/doctor/ @@ -4627,19 +4707,21 @@ Re-running `archgate init` does **not** overwrite a manually configured `baseBra Source: https://cli.archgate.dev/reference/cli/login/ -Authenticate with GitHub to access Archgate editor plugins. If you are not registered yet, the CLI handles signup automatically -- it prompts for your email, editor preference (Claude Code, Cursor, VS Code, GitHub Copilot, or opencode), and use case, then registers you before completing the login. +Sign in to access Archgate editor plugins. ```bash archgate login ``` -Starts a GitHub Device Flow (OAuth). The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and authorize the Archgate GitHub OAuth App. Once authorized, the CLI exchanges your GitHub identity for an Archgate plugin token and stores it securely in your OS credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. +Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate identity provider. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI stores the resulting tokens in your OS credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. -If your GitHub account is not yet registered, the CLI prompts for your email, preferred editor, and use case, then signs you up automatically. +Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is enough. + +Signing in also registers `archgate credential` as a git credential helper for `plugins.archgate.dev`, which is what lets `git clone` of a plugin repository authenticate without prompting. See [`archgate credential`](/reference/cli/credential/). Credentials are required to install editor plugins via `archgate init --install-plugin`. The CLI itself (check, init, etc.) works without login. -Editor plugins are currently in beta. Run `archgate login` to sign up and authenticate. +Editor plugins are currently in beta. Run `archgate login` to sign in. ## Subcommands @@ -4648,7 +4730,7 @@ Editor plugins are currently in beta. Run `archgate login` to sign up and authen | `archgate login` | Authenticate (skips if already logged in) | | `archgate login status` | Show current authentication status | | `archgate login logout` | Remove stored credentials | -| `archgate login refresh` | Re-authenticate and claim a new token | +| `archgate login refresh` | Sign in again, replacing stored tokens | ## Examples @@ -4659,29 +4741,13 @@ archgate login ``` ``` -By signing up, you agree to the Archgate Terms of Service: +By signing in, you agree to the Archgate Terms of Service: https://archgate.dev/terms-of-service -info: Authenticating with GitHub... - -Open https://github.com/login/device in your browser -and enter the code: ABCD-1234 +Open https://auth.archgate.dev/device in your browser +and enter the code: HZML-HXLB Waiting for authorization... -info: GitHub user: yourname -info: Claiming archgate plugin token... - -Your GitHub account yourname is not yet registered. -Let's sign you up now. - -? Email address: you@example.com -? Which editor will you use with archgate? Claude Code -? How do you plan to use archgate? Enforcing ADRs in our monorepo -? I agree to be contacted by the Archgate team to provide feedback during the beta period. Yes - -info: Submitting signup request... -info: Claiming archgate plugin token... - info: Authenticated as yourname. Plugin access is now available. Run `archgate init` to set up a project with the archgate plugin. ``` From 18718334138e18b559a71cdac537e59291073f09 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 6 Sep 2026 21:06:13 +0200 Subject: [PATCH 03/27] fix(auth): address review findings on the device-flow login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `loadCredentials()` no longer throws. `resolveAccessToken()` raises a `UserError` when the refresh token is rejected, which propagated out of a function every caller treats as null-on-signed-out — `init` aborted with exit 1 instead of continuing without the plugin — and it did so before the legacy lookup, so a pre-cutover `ag_beta_` token became unreachable the moment a Logto session went stale. That is now caught and falls through; anything that is not a `UserError` still propagates. Resolving the signed-out path exposed a second cost: it performed two credential lookups, so a hung helper waited 3s twice over. `gitCredentialFill` now reports whether it timed out, and a helper that timed out once is not asked again. Other findings: - `archgate credential` answers only `protocol=https`. Git uses the requested protocol for the exchange, so an http request would have carried the token in cleartext; the hardcoded protocol in the reply cannot upgrade the transport. - `runLoginFlow` fails when the token set cannot be persisted, rather than reporting plugin access that no later lookup can obtain. - `logout` reports and exits non-zero when the git helper entry cannot be removed. - `pollForTokens` and `refreshAccessToken` tolerate a non-JSON body. A proxy's HTML error page made `response.json()` reject with a SyntaxError, which is not a `UserError`, so it surfaced as an internal fault and reached Sentry — from inside `git clone`. - `git()` no longer leaves unread pipes that could block git on a full buffer; only the exit code is used. - `TokenSetWithIdentity` derives from a Zod schema. - Command help, the init prompt, the guides, the command tables, the privacy policy and the VS Code manual setup no longer describe GitHub device flow, signup fields the CLI never collects, or a bare token read from the plugins host. `llms-full.txt` regenerated. Tests cover the unrenewable-session fallthrough, a non-UserError still propagating, `loadTokenSet` and `resolveAccessToken` behaviour, failed persistence, and the https-only guard. Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 18 +- .../docs/guides/claude-code-plugin.mdx | 2 +- .../docs/guides/copilot-cli-plugin.mdx | 2 +- .../src/content/docs/guides/vscode-plugin.mdx | 6 +- .../docs/nb/guides/claude-code-plugin.mdx | 2 +- .../docs/nb/guides/copilot-cli-plugin.mdx | 2 +- .../content/docs/nb/guides/vscode-plugin.mdx | 6 +- .../docs/nb/reference/cli/credential.mdx | 2 +- .../content/docs/nb/reference/cli/index.mdx | 2 +- .../content/docs/nb/reference/cli/login.mdx | 2 +- .../docs/nb/reference/privacy-policy.mdx | 2 +- .../docs/pt-br/guides/claude-code-plugin.mdx | 2 +- .../docs/pt-br/guides/copilot-cli-plugin.mdx | 2 +- .../docs/pt-br/guides/vscode-plugin.mdx | 6 +- .../docs/pt-br/reference/cli/credential.mdx | 2 +- .../docs/pt-br/reference/cli/index.mdx | 2 +- .../docs/pt-br/reference/cli/login.mdx | 2 +- .../docs/pt-br/reference/privacy-policy.mdx | 2 +- .../content/docs/reference/cli/credential.mdx | 2 +- docs/src/content/docs/reference/cli/index.mdx | 2 +- docs/src/content/docs/reference/cli/login.mdx | 2 +- .../content/docs/reference/privacy-policy.mdx | 2 +- src/commands/credential.ts | 14 +- src/commands/init.ts | 2 +- src/commands/login.ts | 16 +- src/helpers/credential-store.ts | 113 +++++++++--- src/helpers/git-credential-config.ts | 6 +- src/helpers/login-flow.ts | 7 +- src/helpers/logto-auth.ts | 33 +++- tests/commands/credential.test.ts | 11 +- tests/helpers/credential-store.test.ts | 173 +++++++++++++++++- tests/helpers/login-flow.test.ts | 10 +- 32 files changed, 371 insertions(+), 86 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 3f25e9b5..9d3c4635 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -1338,7 +1338,7 @@ Authenticate with your GitHub account to obtain a plugin token: archgate login ``` -This starts a GitHub Device Flow. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts a OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin @@ -1485,7 +1485,7 @@ Authenticate with your GitHub account to obtain a plugin token: archgate login ``` -This starts a GitHub Device Flow. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts a OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin @@ -2318,7 +2318,7 @@ Authenticate with your GitHub account to obtain a plugin token: archgate login ``` -This starts a GitHub Device Flow. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts a OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin @@ -2377,7 +2377,7 @@ The user-level marketplace setting (added to your `settings.json`): } ``` -The URL carries no credentials. Git authenticates against `plugins.archgate.dev` using the token `archgate login` stored in your OS credential manager. +The URL carries no credentials. Git authenticates against `plugins.archgate.dev` by asking `archgate credential`, the credential helper `archgate login` registers for that host, which supplies a freshly renewed access token. ### Manual setup @@ -2393,7 +2393,7 @@ code --install-extension archgate.vsix rm archgate.vsix ``` -Replace `` with the plugin token `archgate login` stored for `plugins.archgate.dev` in your OS credential manager. You can read it back with `git credential fill` (`printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | git credential fill`). +Replace `` with a current access token. Print one with `printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get`, which renews it if needed. Access tokens are short-lived, so a value pasted here stops working within the hour — prefer the marketplace setup above, where git asks the helper on every request. ## What the plugin provides @@ -4535,7 +4535,7 @@ The output should list an empty line followed by `!archgate credential`. If it d ### The helper is registered but credentials are still rejected -Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, `archgate credential get` cannot renew the access token and stays silent; `archgate login` re-establishes it. +Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, `archgate credential get` returns no credentials and reports the error rather than staying silent. Run `archgate login` again. --- @@ -4627,7 +4627,7 @@ archgate --log-level debug check | Command | Description | | ------------------------------------------------ | ----------------------------------------------------- | -| [`archgate login`](./login/) | Authenticate with GitHub | +| [`archgate login`](./login/) | Sign in to access editor plugins | | [`archgate init`](./init/) | Set up linting and rules enforcement | | [`archgate plugin`](./plugin/) | Manage editor plugins | | [`archgate check`](./check/) | Run ADR compliance checks | @@ -4715,7 +4715,7 @@ archgate login Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate identity provider. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI stores the resulting tokens in your OS credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. -Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is enough. +Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI reports that the session has expired and `archgate login` re-establishes it. Signing in also registers `archgate credential` as a git credential helper for `plugins.archgate.dev`, which is what lets `git clone` of a plugin repository authenticate without prompting. See [`archgate credential`](/reference/cli/credential/). @@ -5408,7 +5408,7 @@ Data is transmitted via reverse proxies at `n.archgate.dev` (analytics) and `s.a ### Archgate Plugins Service -When you sign up via `archgate login`, the **Plugins Service** (`plugins.archgate.dev`) collects personal information: your **email address**, **GitHub username**, **editor choice**, and a **use case description**. This data is used to provision your account and send a welcome email. Authentication tokens are stored as SHA-256 hashes on our servers; on your machine, credentials are kept in your OS credential manager (never as plain-text files). +When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate identity provider** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing. On your machine, the resulting tokens are kept in your OS credential manager (never as plain-text files). By creating an account, you agree to the [Terms of Service](https://archgate.dev/terms-of-service). See the [full privacy policy](https://archgate.dev/privacy-policy) for complete details. diff --git a/docs/src/content/docs/guides/claude-code-plugin.mdx b/docs/src/content/docs/guides/claude-code-plugin.mdx index 91b83aa6..67b4aa27 100644 --- a/docs/src/content/docs/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/guides/claude-code-plugin.mdx @@ -42,7 +42,7 @@ Authenticate with your GitHub account to obtain a plugin token: archgate login ``` -This starts a GitHub Device Flow. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts a OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin diff --git a/docs/src/content/docs/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/guides/copilot-cli-plugin.mdx index fc749ed9..019805c0 100644 --- a/docs/src/content/docs/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/guides/copilot-cli-plugin.mdx @@ -27,7 +27,7 @@ Authenticate with your GitHub account to obtain a plugin token: archgate login ``` -This starts a GitHub Device Flow. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts a OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin diff --git a/docs/src/content/docs/guides/vscode-plugin.mdx b/docs/src/content/docs/guides/vscode-plugin.mdx index f2432329..7e68ce54 100644 --- a/docs/src/content/docs/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/guides/vscode-plugin.mdx @@ -29,7 +29,7 @@ Authenticate with your GitHub account to obtain a plugin token: archgate login ``` -This starts a GitHub Device Flow. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts a OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin @@ -88,7 +88,7 @@ The user-level marketplace setting (added to your `settings.json`): } ``` -The URL carries no credentials. Git authenticates against `plugins.archgate.dev` using the token `archgate login` stored in your OS credential manager. +The URL carries no credentials. Git authenticates against `plugins.archgate.dev` by asking `archgate credential`, the credential helper `archgate login` registers for that host, which supplies a freshly renewed access token. ### Manual setup @@ -104,7 +104,7 @@ code --install-extension archgate.vsix rm archgate.vsix ``` -Replace `` with the plugin token `archgate login` stored for `plugins.archgate.dev` in your OS credential manager. You can read it back with `git credential fill` (`printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | git credential fill`). +Replace `` with a current access token. Print one with `printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get`, which renews it if needed. Access tokens are short-lived, so a value pasted here stops working within the hour — prefer the marketplace setup above, where git asks the helper on every request. ## What the plugin provides diff --git a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx index 048da7a3..50760b32 100644 --- a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx @@ -42,7 +42,7 @@ Autentiser med GitHub-kontoen din for å få et plugin-token: archgate login ``` -Dette starter en GitHub Device Flow. CLI-et viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden og autoriser. Når det er ferdig, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. +Dette starter en OAuth 2.0 Device Authorization Grant mot Archgates identitetsleverandør. CLI-et viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden og autoriser. Når det er ferdig, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. ### 2. Initialiser prosjektet ditt med pluginen diff --git a/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx index 10b77ae5..4ee71216 100644 --- a/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx @@ -27,7 +27,7 @@ Autentiser med GitHub-kontoen din for å få et plugin-token: archgate login ``` -Dette starter en GitHub Device Flow. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. +Dette starter en OAuth 2.0 Device Authorization Grant mot Archgates identitetsleverandør. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. ### 2. Initialiser prosjektet ditt med pluginet diff --git a/docs/src/content/docs/nb/guides/vscode-plugin.mdx b/docs/src/content/docs/nb/guides/vscode-plugin.mdx index 07652a0d..ea05c45e 100644 --- a/docs/src/content/docs/nb/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/nb/guides/vscode-plugin.mdx @@ -29,7 +29,7 @@ Autentiser med GitHub-kontoen din for å få et plugin-token: archgate login ``` -Dette starter en GitHub Device Flow. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. +Dette starter en OAuth 2.0 Device Authorization Grant mot Archgates identitetsleverandør. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. ### 2. Initialiser prosjektet ditt med pluginet @@ -88,7 +88,7 @@ Markedsplassinnstillingen på brukernivå (lagt til i `settings.json`): } ``` -URL-en inneholder ingen legitimasjon. Git autentiserer mot `plugins.archgate.dev` med tokenet `archgate login` lagret i operativsystemets legitimasjonsbehandler. +URL-en inneholder ingen legitimasjon. Git autentiserer mot `plugins.archgate.dev` ved å spørre `archgate credential`, credential-helperen `archgate login` registrerer for den verten, som leverer et nylig fornyet tilgangstoken. ### Manuelt oppsett @@ -104,7 +104,7 @@ code --install-extension archgate.vsix rm archgate.vsix ``` -Erstatt `` med plugin-tokenet `archgate login` lagret for `plugins.archgate.dev` i operativsystemets legitimasjonsbehandler. Du kan lese det tilbake med `git credential fill` (`printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | git credential fill`). +Erstatt `` med et gyldig tilgangstoken. Skriv ut ett med `printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get`, som fornyer det ved behov. Tilgangstokener er kortlivede, så en verdi som limes inn her slutter å virke innen en time — bruk heller marketplace-oppsettet over, der git spør helperen ved hver forespørsel. ## Hva pluginet tilbyr diff --git a/docs/src/content/docs/nb/reference/cli/credential.mdx b/docs/src/content/docs/nb/reference/cli/credential.mdx index 9f613775..ed83432d 100644 --- a/docs/src/content/docs/nb/reference/cli/credential.mdx +++ b/docs/src/content/docs/nb/reference/cli/credential.mdx @@ -75,4 +75,4 @@ Utdataene skal vise en tom linje etterfulgt av `!archgate credential`. Hvis ikke ### Helperen er registrert, men legitimasjonen avvises fortsatt -Bekreft at du er logget inn med `archgate login status`. Hvis refresh-tokenet er utløpt eller tilbakekalt, klarer ikke `archgate credential get` å fornye tilgangstokenet og forblir taust; `archgate login` gjenoppretter det. +Bekreft at du er logget inn med `archgate login status`. Hvis refresh-tokenet er utløpt eller tilbakekalt, returnerer `archgate credential get` ingen legitimasjon og melder fra om feilen. Kjør `archgate login` på nytt. diff --git a/docs/src/content/docs/nb/reference/cli/index.mdx b/docs/src/content/docs/nb/reference/cli/index.mdx index 57740095..fc91dc55 100644 --- a/docs/src/content/docs/nb/reference/cli/index.mdx +++ b/docs/src/content/docs/nb/reference/cli/index.mdx @@ -23,7 +23,7 @@ archgate --log-level debug check | Kommando | Beskrivelse | | ------------------------------------------------ | --------------------------------------------------------------- | -| [`archgate login`](./login/) | Autentiser med GitHub | +| [`archgate login`](./login/) | Logg inn for tilgang til editor-plugins | | [`archgate init`](./init/) | Sett opp linting og regelhåndhevelse | | [`archgate plugin`](./plugin/) | Administrer editor-plugins | | [`archgate check`](./check/) | Kjør ADR-samsvarskontroller | diff --git a/docs/src/content/docs/nb/reference/cli/login.mdx b/docs/src/content/docs/nb/reference/cli/login.mdx index a45d0d1d..c9b2438c 100644 --- a/docs/src/content/docs/nb/reference/cli/login.mdx +++ b/docs/src/content/docs/nb/reference/cli/login.mdx @@ -11,7 +11,7 @@ archgate login Starter en OAuth 2.0 Device Authorization Grant (RFC 8628) mot Archgates identitetsleverandør. CLI-en viser en engangskode og en URL. Åpne URL-en i nettleseren din, skriv inn koden og velg hvordan du vil logge inn. Når autorisasjonen er fullført, lagrer CLI-en tokenene i operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager eller Linux libsecret) via `git credential approve`. Ingen legitimasjon skrives til disk som klartekstfiler. -Tilgangstokener er kortlivede. CLI-en beholder et refresh-token ved siden av dem og fornyer tilgangstokenet automatisk, så det holder å logge inn én gang. +Tilgangstokener er kortlivede. CLI-en beholder et refresh-token ved siden av dem og fornyer tilgangstokenet automatisk, så det holder normalt å logge inn én gang. Hvis selve refresh-tokenet utløper eller blir tilbakekalt, melder CLI-en at økten er utløpt, og `archgate login` gjenoppretter den. Innlogging registrerer også `archgate credential` som en git credential helper for `plugins.archgate.dev`, som er det som lar `git clone` av et plugin-repositorium autentisere uten å spørre. Se [`archgate credential`](/reference/cli/credential/). diff --git a/docs/src/content/docs/nb/reference/privacy-policy.mdx b/docs/src/content/docs/nb/reference/privacy-policy.mdx index cbc5bfad..a6cbdace 100644 --- a/docs/src/content/docs/nb/reference/privacy-policy.mdx +++ b/docs/src/content/docs/nb/reference/privacy-policy.mdx @@ -31,7 +31,7 @@ Data overføres via reverseproksier på `n.archgate.dev` (analyse) og `s.archgat ### Archgate Plugins Service -Når du registrerer deg via `archgate login`, samler **Plugins Service** (`plugins.archgate.dev`) inn personopplysninger: din **e-postadresse**, **GitHub-brukernavn**, **editorvalg** og en **beskrivelse av bruksområde**. Disse dataene brukes til å opprette kontoen din og sende en velkomst-e-post. Autentiseringstokener lagres som SHA-256-hasher på serverne våre; på din maskin oppbevares legitimasjonen i operativsystemets legitimasjonsbehandler (aldri som ren tekstfiler). +Når du logger inn via `archgate login`, autentiserer du med GitHub gjennom **Archgates identitetsleverandør** (`auth.archgate.dev`), som mottar profilen GitHub deler med den: ditt **navn**, din **e-postadresse**, ditt **GitHub-brukernavn** og din **avatar**. Dette brukes til å identifisere kontoen din og sende en velkomst-e-post. CLI-en spør deg selv om ingenting. På din maskin oppbevares tokenene i operativsystemets legitimasjonsbehandler (aldri som ren tekstfiler). Ved å opprette en konto godtar du [tjenestevilkårene](https://archgate.dev/terms-of-service). Se den [fullstendige personvernerklæringen](https://archgate.dev/privacy-policy) for komplette detaljer. diff --git a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx index 28036eda..337d63e5 100644 --- a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx @@ -42,7 +42,7 @@ Autentique-se com sua conta do GitHub para obter um token do plugin: archgate login ``` -Isso inicia um GitHub Device Flow. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. +Isso inicia um OAuth 2.0 Device Authorization Grant no provedor de identidade do Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. ### 2. Inicialize seu projeto com o plugin diff --git a/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx index 2be879d2..6f6bbc3b 100644 --- a/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx @@ -27,7 +27,7 @@ Autentique-se com sua conta do GitHub para obter um token do plugin: archgate login ``` -Isso inicia um GitHub Device Flow. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. +Isso inicia um OAuth 2.0 Device Authorization Grant no provedor de identidade do Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. ### 2. Inicialize seu projeto com o plugin diff --git a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx index 0ac9f0bd..4cdbd921 100644 --- a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx @@ -29,7 +29,7 @@ Autentique-se com sua conta do GitHub para obter um token do plugin: archgate login ``` -Isso inicia um GitHub Device Flow. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. +Isso inicia um OAuth 2.0 Device Authorization Grant no provedor de identidade do Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. ### 2. Inicialize seu projeto com o plugin @@ -88,7 +88,7 @@ A configuração do marketplace no nível de usuário (adicionada ao seu `settin } ``` -A URL não contém credenciais. O git se autentica em `plugins.archgate.dev` usando o token que o `archgate login` armazena no gerenciador de credenciais do sistema operacional. +A URL não contém credenciais. O git se autentica em `plugins.archgate.dev` consultando o `archgate credential`, o credential helper que o `archgate login` registra para esse host, que fornece um token de acesso recém-renovado. ### Configuração manual @@ -104,7 +104,7 @@ code --install-extension archgate.vsix rm archgate.vsix ``` -Substitua `` pelo token de plugin que o `archgate login` armazena para `plugins.archgate.dev` no gerenciador de credenciais do sistema operacional. Você pode lê-lo de volta com `git credential fill` (`printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | git credential fill`). +Substitua `` por um token de acesso atual. Imprima um com `printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get`, que o renova se necessário. Tokens de acesso têm vida curta, então um valor colado aqui deixa de funcionar em menos de uma hora — prefira a configuração de marketplace acima, em que o git consulta o helper a cada requisição. ## O que o plugin oferece diff --git a/docs/src/content/docs/pt-br/reference/cli/credential.mdx b/docs/src/content/docs/pt-br/reference/cli/credential.mdx index f3f09f00..261a624b 100644 --- a/docs/src/content/docs/pt-br/reference/cli/credential.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/credential.mdx @@ -75,4 +75,4 @@ A saída deve listar uma linha vazia seguida de `!archgate credential`. Se não ### O helper está registrado, mas as credenciais continuam sendo rejeitadas -Confirme que você está autenticado com `archgate login status`. Se o refresh token expirou ou foi revogado, o `archgate credential get` não consegue renovar o token de acesso e permanece silencioso; o `archgate login` o restabelece. +Confirme que você está autenticado com `archgate login status`. Se o refresh token expirou ou foi revogado, o `archgate credential get` não retorna credenciais e informa o erro, em vez de permanecer silencioso. Execute `archgate login` novamente. diff --git a/docs/src/content/docs/pt-br/reference/cli/index.mdx b/docs/src/content/docs/pt-br/reference/cli/index.mdx index 98e67dac..7b5115e6 100644 --- a/docs/src/content/docs/pt-br/reference/cli/index.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/index.mdx @@ -23,7 +23,7 @@ archgate --log-level debug check | Comando | Descrição | | ------------------------------------------------ | -------------------------------------------------------- | -| [`archgate login`](./login/) | Autenticar com o GitHub | +| [`archgate login`](./login/) | Entre para acessar os plugins de editor | | [`archgate init`](./init/) | Configurar linting e aplicação de regras | | [`archgate plugin`](./plugin/) | Gerenciar plugins de editor | | [`archgate check`](./check/) | Executar verificações de conformidade com ADRs | diff --git a/docs/src/content/docs/pt-br/reference/cli/login.mdx b/docs/src/content/docs/pt-br/reference/cli/login.mdx index b4eb3eac..5a8fb9d8 100644 --- a/docs/src/content/docs/pt-br/reference/cli/login.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/login.mdx @@ -11,7 +11,7 @@ archgate login Inicia um OAuth 2.0 Device Authorization Grant (RFC 8628) no provedor de identidade do Archgate. A CLI exibe um código de uso único e uma URL. Abra a URL no seu navegador, insira o código e escolha como deseja entrar. Após a autorização, a CLI armazena os tokens resultantes no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager ou libsecret no Linux) via `git credential approve`. Nenhuma credencial é gravada em disco como arquivo de texto. -Os tokens de acesso têm vida curta. A CLI mantém um refresh token junto deles e renova o token de acesso automaticamente, então basta entrar uma vez. +Os tokens de acesso têm vida curta. A CLI mantém um refresh token junto deles e renova o token de acesso automaticamente, então normalmente basta entrar uma vez. Se o próprio refresh token expirar ou for revogado, a CLI informa que a sessão expirou e o `archgate login` a restabelece. Entrar também registra `archgate credential` como um credential helper do git para `plugins.archgate.dev`, que é o que permite ao `git clone` de um repositório de plugin autenticar sem solicitar credenciais. Veja [`archgate credential`](/reference/cli/credential/). diff --git a/docs/src/content/docs/pt-br/reference/privacy-policy.mdx b/docs/src/content/docs/pt-br/reference/privacy-policy.mdx index fdbbe1c0..6c80a09a 100644 --- a/docs/src/content/docs/pt-br/reference/privacy-policy.mdx +++ b/docs/src/content/docs/pt-br/reference/privacy-policy.mdx @@ -17,7 +17,7 @@ O Archgate coleta **análises de uso anônimas** (via PostHog) e **relatórios d ### Serviço de Plugins do Archgate -Quando você se cadastra via `archgate login`, o **Serviço de Plugins** (`plugins.archgate.dev`) coleta informações pessoais: seu **endereço de email**, **nome de usuário GitHub**, **escolha do editor** e uma **descrição do caso de uso**. Esses dados são usados para provisionar sua conta e enviar um email de boas-vindas. Os tokens de autenticação são armazenados como hashes SHA-256 em nossos servidores; na sua máquina, as credenciais são mantidas no gerenciador de credenciais do seu sistema operacional (nunca como arquivos de texto simples). Consulte a [política de privacidade completa](https://archgate.dev/privacy-policy) para detalhes completos. +Quando você entra via `archgate login`, você se autentica com o GitHub através do **provedor de identidade do Archgate** (`auth.archgate.dev`), que recebe o perfil que o GitHub compartilha com ele: seu **nome**, **endereço de email**, **nome de usuário GitHub** e **avatar**. Isso é usado para identificar sua conta e enviar um email de boas-vindas. A própria CLI não pede nada a você. Na sua máquina, os tokens resultantes são mantidos no gerenciador de credenciais do seu sistema operacional (nunca como arquivos de texto simples). Consulte a [política de privacidade completa](https://archgate.dev/privacy-policy) para detalhes completos. ### Como desativar diff --git a/docs/src/content/docs/reference/cli/credential.mdx b/docs/src/content/docs/reference/cli/credential.mdx index 3d93b0d9..10532f15 100644 --- a/docs/src/content/docs/reference/cli/credential.mdx +++ b/docs/src/content/docs/reference/cli/credential.mdx @@ -75,4 +75,4 @@ The output should list an empty line followed by `!archgate credential`. If it d ### The helper is registered but credentials are still rejected -Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, `archgate credential get` cannot renew the access token and stays silent; `archgate login` re-establishes it. +Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, `archgate credential get` returns no credentials and reports the error rather than staying silent. Run `archgate login` again. diff --git a/docs/src/content/docs/reference/cli/index.mdx b/docs/src/content/docs/reference/cli/index.mdx index b6a37e88..c3c9d064 100644 --- a/docs/src/content/docs/reference/cli/index.mdx +++ b/docs/src/content/docs/reference/cli/index.mdx @@ -23,7 +23,7 @@ archgate --log-level debug check | Command | Description | | ------------------------------------------------ | ----------------------------------------------------- | -| [`archgate login`](./login/) | Authenticate with GitHub | +| [`archgate login`](./login/) | Sign in to access editor plugins | | [`archgate init`](./init/) | Set up linting and rules enforcement | | [`archgate plugin`](./plugin/) | Manage editor plugins | | [`archgate check`](./check/) | Run ADR compliance checks | diff --git a/docs/src/content/docs/reference/cli/login.mdx b/docs/src/content/docs/reference/cli/login.mdx index b862b530..05d68bbf 100644 --- a/docs/src/content/docs/reference/cli/login.mdx +++ b/docs/src/content/docs/reference/cli/login.mdx @@ -11,7 +11,7 @@ archgate login Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate identity provider. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI stores the resulting tokens in your OS credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. -Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is enough. +Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI reports that the session has expired and `archgate login` re-establishes it. Signing in also registers `archgate credential` as a git credential helper for `plugins.archgate.dev`, which is what lets `git clone` of a plugin repository authenticate without prompting. See [`archgate credential`](/reference/cli/credential/). diff --git a/docs/src/content/docs/reference/privacy-policy.mdx b/docs/src/content/docs/reference/privacy-policy.mdx index 2c8a1658..d95c7bf3 100644 --- a/docs/src/content/docs/reference/privacy-policy.mdx +++ b/docs/src/content/docs/reference/privacy-policy.mdx @@ -31,7 +31,7 @@ Data is transmitted via reverse proxies at `n.archgate.dev` (analytics) and `s.a ### Archgate Plugins Service -When you sign up via `archgate login`, the **Plugins Service** (`plugins.archgate.dev`) collects personal information: your **email address**, **GitHub username**, **editor choice**, and a **use case description**. This data is used to provision your account and send a welcome email. Authentication tokens are stored as SHA-256 hashes on our servers; on your machine, credentials are kept in your OS credential manager (never as plain-text files). +When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate identity provider** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing. On your machine, the resulting tokens are kept in your OS credential manager (never as plain-text files). By creating an account, you agree to the [Terms of Service](https://archgate.dev/terms-of-service). See the [full privacy policy](https://archgate.dev/privacy-policy) for complete details. diff --git a/src/commands/credential.ts b/src/commands/credential.ts index ac890162..cdd9dda1 100644 --- a/src/commands/credential.ts +++ b/src/commands/credential.ts @@ -35,8 +35,14 @@ export function registerCredentialCommand(program: Command) { .action(async () => { try { const request = await readRequest(); - if (request.host !== PLUGINS_HOST) { - logDebug("Ignoring credential request for host:", request.host); + // Git uses the requested protocol for the exchange itself, so an + // http:// request would carry the token in cleartext. Answering only + // https keeps that from happening; the response cannot upgrade it. + if (request.protocol !== "https" || request.host !== PLUGINS_HOST) { + logDebug( + "Ignoring credential request for:", + `${request.protocol}://${request.host}` + ); return; } @@ -77,7 +83,9 @@ export function registerCredentialCommand(program: Command) { .action(async () => { try { const request = await readRequest(); - if (request.host !== PLUGINS_HOST) return; + if (request.protocol !== "https" || request.host !== PLUGINS_HOST) { + return; + } await clearCredentials(); } catch (err) { await handleCommandError(err); diff --git a/src/commands/init.ts b/src/commands/init.ts index cdad5a9b..f242692f 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -94,7 +94,7 @@ export function registerInitCommand(program: Command) { type: "confirm", name: "wantPlugin", message: - "Would you like to install the Archgate editor plugin? (requires GitHub login)", + "Would you like to install the Archgate editor plugin? (requires sign-in)", default: true, }, ]) diff --git a/src/commands/login.ts b/src/commands/login.ts index ba7c737c..e572d10b 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -16,7 +16,7 @@ import { isTlsError, tlsHintMessage } from "../helpers/tls"; export function registerLoginCommand(program: Command) { const login = program .command("login") - .description("Authenticate with GitHub to access archgate plugins"); + .description("Sign in to access archgate plugins"); login.action(async () => { try { @@ -76,9 +76,17 @@ export function registerLoginCommand(program: Command) { .action(async () => { try { await clearCredentials(); - await unregisterGitCredentialHelper(); - trackLoginResult({ subcommand: "logout", success: true }); - console.log("Logged out successfully."); + const unregistered = await unregisterGitCredentialHelper(); + trackLoginResult({ subcommand: "logout", success: unregistered }); + if (unregistered) { + console.log("Logged out successfully."); + } else { + logError( + "Credentials removed, but the git credential helper entry could not be.", + "Remove it with `git config --global --unset-all credential.https://plugins.archgate.dev.helper`." + ); + await exitWith(1); + } } catch (err) { await handleCommandError(err); } diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index 80696edd..086b9c68 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -19,6 +19,7 @@ import { TokenSetSchema, } from "./logto-auth"; import { internalPath } from "./paths"; +import { UserError } from "./user-error"; /** Host git itself authenticates against for plugin repositories. */ export const PLUGINS_HOST = "plugins.archgate.dev"; @@ -87,9 +88,14 @@ async function gitCredentialApprove( return (await proc.exited) === 0; } -async function gitCredentialFill( - host: string -): Promise<{ username: string; password: string } | null> { +/** Outcome of one `git credential fill`, distinguishing absent from unusable. */ +interface FillResult { + credentials: { username: string; password: string } | null; + /** True when the helper had to be killed for exceeding the timeout. */ + timedOut: boolean; +} + +async function gitCredentialFill(host: string): Promise { try { const proc = Bun.spawn(["git", "credential", "fill"], { stdin: new Blob([credentialInput(host)]), @@ -119,7 +125,8 @@ async function gitCredentialFill( if (timer) clearTimeout(timer); }); - if (result?.exitCode !== 0) return null; + if (result === null) return { credentials: null, timedOut: true }; + if (result.exitCode !== 0) return { credentials: null, timedOut: false }; let username = ""; let password = ""; @@ -127,9 +134,12 @@ async function gitCredentialFill( if (line.startsWith("username=")) username = line.slice(9); if (line.startsWith("password=")) password = line.slice(9); } - return username && password ? { username, password } : null; + return { + credentials: username && password ? { username, password } : null, + timedOut: false, + }; } catch { - return null; + return { credentials: null, timedOut: false }; } } @@ -187,11 +197,12 @@ const CREDENTIAL_HELPER_HINT = * * @param user - Display name shown by `archgate login status`. * @param tokens - Access token, refresh token and absolute expiry. + * @returns `true` when the token set is retrievable afterwards. */ export async function saveTokenSet( user: string, tokens: TokenSet -): Promise { +): Promise { await cleanupLegacyMetadata(); const stored = await gitCredentialApprove( @@ -206,19 +217,21 @@ export async function saveTokenSet( "Your git credential helper may not be configured.", CREDENTIAL_HELPER_HINT ); - return; + return false; } - if (await gitCredentialFill(AUTH_HOST)) { + if ((await gitCredentialFill(AUTH_HOST)).credentials) { logDebug("Token set verified in git credential manager"); - } else { - logWarn( - "Token could not be verified in git credential manager.", - "Your credential helper may not persist credentials.", - CREDENTIAL_HELPER_HINT, - "Without a working credential helper, you will need to re-login after each session." - ); + return true; } + + logWarn( + "Token could not be verified in git credential manager.", + "Your credential helper may not persist credentials.", + CREDENTIAL_HELPER_HINT, + "Without a working credential helper, you will need to re-login after each session." + ); + return false; } /** @@ -230,15 +243,26 @@ export async function loadTokenSet(): Promise<{ user: string; tokens: TokenSet; } | null> { - const stored = await gitCredentialFill(AUTH_HOST); - if (!stored) return null; + return (await readTokenSet()).stored; +} - const parsed = TokenSetSchema.safeParse(safeJsonParse(stored.password)); +/** {@link loadTokenSet}, also reporting whether the helper timed out. */ +async function readTokenSet(): Promise<{ + stored: { user: string; tokens: TokenSet } | null; + timedOut: boolean; +}> { + const { credentials, timedOut } = await gitCredentialFill(AUTH_HOST); + if (!credentials) return { stored: null, timedOut }; + + const parsed = TokenSetSchema.safeParse(safeJsonParse(credentials.password)); if (!parsed.success) { logDebug("Stored token set is unreadable"); - return null; + return { stored: null, timedOut: false }; } - return { user: stored.username, tokens: parsed.data }; + return { + stored: { user: credentials.username, tokens: parsed.data }, + timedOut: false, + }; } /** @@ -247,16 +271,33 @@ export async function loadTokenSet(): Promise<{ * @returns Credentials ready to send, or `null` when the user is signed out. */ export async function resolveAccessToken(): Promise { - const stored = await loadTokenSet(); - if (!stored) return null; + return (await resolveWithStatus()).credentials; +} + +/** {@link resolveAccessToken}, also reporting whether the helper timed out. */ +async function resolveWithStatus(): Promise<{ + credentials: StoredCredentials | null; + timedOut: boolean; +}> { + const { stored, timedOut } = await readTokenSet(); + if (!stored) return { credentials: null, timedOut }; if (!isExpired(stored.tokens.expiresAt)) { - return { token: stored.tokens.accessToken, github_user: stored.user }; + return { + credentials: { + token: stored.tokens.accessToken, + github_user: stored.user, + }, + timedOut: false, + }; } const renewed = await refreshAccessToken(stored.tokens.refreshToken); await saveTokenSet(stored.user, renewed); - return { token: renewed.accessToken, github_user: stored.user }; + return { + credentials: { token: renewed.accessToken, github_user: stored.user }, + timedOut: false, + }; } /** @@ -278,10 +319,26 @@ export async function loadCredentials(): Promise { return null; } - const resolved = await resolveAccessToken(); + // A rejected refresh token is an expected signed-out state, not a failure: + // swallowing it here keeps the documented null contract and lets the legacy + // lookup below still answer. Anything else is a real fault and propagates. + let resolved: StoredCredentials | null = null; + let authLookupTimedOut = false; + try { + const outcome = await resolveWithStatus(); + resolved = outcome.credentials; + authLookupTimedOut = outcome.timedOut; + } catch (error) { + if (!(error instanceof UserError)) throw error; + logDebug("stored session could not be renewed", { reason: error.message }); + } if (resolved) return resolved; - const legacy = await gitCredentialFill(PLUGINS_HOST); + // A helper that timed out on the first host will time out on the second too, + // so skip it rather than making every signed-out call wait twice over. + if (authLookupTimedOut) return null; + + const { credentials: legacy } = await gitCredentialFill(PLUGINS_HOST); if (legacy) { return { token: legacy.password, github_user: legacy.username }; } @@ -295,7 +352,7 @@ export async function loadCredentials(): Promise { export async function clearCredentials(): Promise { /* oxlint-disable no-await-in-loop -- two fixed hosts, cleared in order */ for (const host of [AUTH_HOST, PLUGINS_HOST]) { - const stored = await gitCredentialFill(host); + const { credentials: stored } = await gitCredentialFill(host); if (stored) { await gitCredentialReject(host, stored.username, stored.password); logDebug("Credentials removed from git credential manager"); diff --git a/src/helpers/git-credential-config.ts b/src/helpers/git-credential-config.ts index 506b869e..ced1f558 100644 --- a/src/helpers/git-credential-config.ts +++ b/src/helpers/git-credential-config.ts @@ -24,9 +24,11 @@ const HELPER_VALUE = "!archgate credential"; * `GIT_CONFIG_GLOBAL` — would otherwise not reach git. */ async function git(args: string[]): Promise { + // Only the exit code matters; unread pipes can block git once it writes + // enough diagnostic output to fill them. const proc = Bun.spawn(["git", ...args], { - stdout: "pipe", - stderr: "pipe", + stdout: "ignore", + stderr: "ignore", env: { ...Bun.env }, }); return proc.exited; diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index f1228d2c..74c02474 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -14,6 +14,7 @@ import { pollForTokens, requestDeviceCode, } from "./logto-auth"; +import { UserError } from "./user-error"; export interface LoginFlowResult { /** Whether credentials were obtained. */ @@ -52,7 +53,11 @@ export async function runLoginFlow(): Promise { ); const user = identityFromIdToken(idToken); - await saveTokenSet(user, tokens); + if (!(await saveTokenSet(user, tokens))) { + throw new UserError( + "Signed in, but the credentials could not be stored. Configure a git credential helper and run `archgate login` again." + ); + } if (!(await registerGitCredentialHelper())) { logWarn( diff --git a/src/helpers/logto-auth.ts b/src/helpers/logto-auth.ts index 35a1307d..3cfd3133 100644 --- a/src/helpers/logto-auth.ts +++ b/src/helpers/logto-auth.ts @@ -81,10 +81,12 @@ export const TokenSetSchema = z.object({ export type TokenSet = z.infer; /** A token set plus the ID token from the exchange that produced it. */ -export interface TokenSetWithIdentity { - tokens: TokenSet; - idToken: string | undefined; -} +export const TokenSetWithIdentitySchema = z.object({ + tokens: TokenSetSchema, + idToken: z.string().optional(), +}); + +export type TokenSetWithIdentity = z.infer; // --------------------------------------------------------------------------- // Device authorization @@ -155,7 +157,7 @@ export async function pollForTokens( signal: AbortSignal.timeout(15_000), }); - const body: unknown = await response.json(); + const body: unknown = await jsonBody(response); if (response.ok) { return { tokens: toTokenSet(body), idToken: idTokenFrom(body) }; @@ -218,14 +220,14 @@ export async function refreshAccessToken( signal: AbortSignal.timeout(15_000), }); - const body: unknown = await response.json(); if (!response.ok) { + await response.body?.cancel(); throw new UserError( "Your session has expired. Run `archgate login` to sign in again." ); } - return toTokenSet(body, refreshToken); + return toTokenSet(await jsonBody(response), refreshToken); } /** @@ -266,6 +268,23 @@ export function isExpired(expiresAt: number): boolean { return Date.now() >= expiresAt - EXPIRY_SKEW_SECONDS * 1000; } +/** + * Read a JSON body without letting a non-JSON one escape as an internal error. + * + * An identity-provider outage answers with an HTML error page from a proxy, + * where `response.json()` rejects with a `SyntaxError` — not a `UserError`, so + * it would surface as an internal fault and reach Sentry. + * + * @returns The parsed body, or `null` when it is not JSON. + */ +async function jsonBody(response: Response): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + /** Pull the raw ID token out of a token response, when present. */ function idTokenFrom(body: unknown): string | undefined { return TokenSuccessSchema.safeParse(body).data?.id_token; diff --git a/tests/commands/credential.test.ts b/tests/commands/credential.test.ts index 33284343..fcc16e49 100644 --- a/tests/commands/credential.test.ts +++ b/tests/commands/credential.test.ts @@ -70,10 +70,14 @@ describe("archgate credential get", () => { expect(written()).toBe(""); }); + // Git uses the requested protocol for the exchange, so answering a plain + // http request would hand the token over in cleartext. test.each([ "protocol=https\nhost=github.com\n\n", "protocol=https\nhost=evil.example.com\n\n", "protocol=https\n\n", + "protocol=http\nhost=plugins.archgate.dev\n\n", + "host=plugins.archgate.dev\n\n", ])("stays silent for a request that is not ours (%p)", async (request) => { await run("get", request); @@ -89,8 +93,11 @@ describe("archgate credential erase", () => { expect(clearSpy).toHaveBeenCalled(); }); - test("leaves credentials alone for another host", async () => { - await run("erase", "protocol=https\nhost=github.com\n\n"); + test.each([ + "protocol=https\nhost=github.com\n\n", + "protocol=http\nhost=plugins.archgate.dev\n\n", + ])("leaves credentials alone for %p", async (request) => { + await run("erase", request); expect(clearSpy).not.toHaveBeenCalled(); }); diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts index 96dc61eb..5003fdcb 100644 --- a/tests/helpers/credential-store.test.ts +++ b/tests/helpers/credential-store.test.ts @@ -9,9 +9,32 @@ import { saveTokenSet, loadCredentials, loadTokenSet, + resolveAccessToken, clearCredentials, } from "../../src/helpers/credential-store"; -import { restoreEnv } from "../test-utils"; +import * as logtoMod from "../../src/helpers/logto-auth"; +import { UserError } from "../../src/helpers/user-error"; +import { rejectionMessage, restoreEnv } from "../test-utils"; + +/** + * A `Bun.spawn` stand-in that answers `git credential fill` with one record. + * + * @param username - Value returned in the `username` field. + * @param password - Value returned in the `password` field. + */ +function gitCredentialStub( + username: string, + password: string +): ReturnType { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + return { + stdout: new Response(`username=${username}\npassword=${password}\n`).body, + exited: Promise.resolve(0), + kill: () => { + // Nothing to kill: the stub has already settled. + }, + } as unknown as ReturnType; +} describe("credential-store", () => { let tempDir: string; @@ -301,4 +324,152 @@ describe("credential-store", () => { expect(typeof clearCredentials).toBe("function"); }); }); + + describe("loadTokenSet", () => { + test("returns null when the stored blob is not a token set", async () => { + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("archgate", "not-json") + ); + try { + expect(await loadTokenSet()).toBeNull(); + } finally { + fillSpy.mockRestore(); + } + }); + + test("returns null when the JSON is missing token fields", async () => { + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("archgate", JSON.stringify({ a: 1 })) + ); + try { + expect(await loadTokenSet()).toBeNull(); + } finally { + fillSpy.mockRestore(); + } + }); + + test("returns the stored token set and its user", async () => { + const tokens = { + accessToken: "ey.access", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, + }; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("octocat", JSON.stringify(tokens)) + ); + try { + expect(await loadTokenSet()).toEqual({ user: "octocat", tokens }); + } finally { + fillSpy.mockRestore(); + } + }); + }); + + describe("resolveAccessToken", () => { + test("returns the cached token while it is still valid", async () => { + const tokens = { + accessToken: "ey.valid", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, + }; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("octocat", JSON.stringify(tokens)) + ); + const refreshSpy = spyOn(logtoMod, "refreshAccessToken"); + try { + expect(await resolveAccessToken()).toEqual({ + token: "ey.valid", + github_user: "octocat", + }); + expect(refreshSpy).not.toHaveBeenCalled(); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); + + test("renews an expired token and hands back the new one", async () => { + const stale = { + accessToken: "ey.stale", + refreshToken: "refresh-old", + expiresAt: Date.now() - 1_000, + }; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("octocat", JSON.stringify(stale)) + ); + const refreshSpy = spyOn( + logtoMod, + "refreshAccessToken" + ).mockResolvedValue({ + accessToken: "ey.fresh", + refreshToken: "refresh-new", + expiresAt: Date.now() + 3_600_000, + }); + try { + expect(await resolveAccessToken()).toEqual({ + token: "ey.fresh", + github_user: "octocat", + }); + expect(refreshSpy).toHaveBeenCalledWith("refresh-old"); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); + }); + + // The regression the review caught: a rejected refresh token is a signed-out + // state, so loadCredentials must keep its null contract and still reach the + // legacy lookup rather than throwing at every caller. + describe("loadCredentials with an unrenewable session", () => { + test("falls through to a legacy token", async () => { + const stale = { + accessToken: "ey.stale", + refreshToken: "refresh-old", + expiresAt: Date.now() - 1_000, + }; + let call = 0; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => { + call += 1; + // First fill answers for AUTH_HOST, the second for PLUGINS_HOST. + return call === 1 + ? gitCredentialStub("octocat", JSON.stringify(stale)) + : gitCredentialStub("octocat", "ag_beta_legacy"); + }); + const refreshSpy = spyOn( + logtoMod, + "refreshAccessToken" + ).mockRejectedValue(new UserError("Your session has expired.")); + try { + expect(await loadCredentials()).toEqual({ + token: "ag_beta_legacy", + github_user: "octocat", + }); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); + + test("propagates a fault that is not a signed-out state", async () => { + const stale = { + accessToken: "ey.stale", + refreshToken: "refresh-old", + expiresAt: Date.now() - 1_000, + }; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("octocat", JSON.stringify(stale)) + ); + const refreshSpy = spyOn( + logtoMod, + "refreshAccessToken" + ).mockRejectedValue(new TypeError("boom")); + try { + expect(await rejectionMessage(loadCredentials())).toContain("boom"); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); + }); }); diff --git a/tests/helpers/login-flow.test.ts b/tests/helpers/login-flow.test.ts index 8cbe3a1b..4b01f664 100644 --- a/tests/helpers/login-flow.test.ts +++ b/tests/helpers/login-flow.test.ts @@ -53,7 +53,7 @@ beforeEach(() => { tokens: TOKENS, idToken: idTokenFor({ sub: "usr_1", username: "octocat" }), }); - mockSaveTokenSet = spyOn(credMod, "saveTokenSet").mockResolvedValue(); + mockSaveTokenSet = spyOn(credMod, "saveTokenSet").mockResolvedValue(true); mockRegisterHelper = spyOn( gitConfigMod, "registerGitCredentialHelper" @@ -132,6 +132,14 @@ describe("runLoginFlow", () => { expect(result.githubUser).toBe("archgate"); }); + test("fails the login when the token set cannot be persisted", async () => { + mockSaveTokenSet.mockResolvedValue(false); + + expect(await rejectionMessage(runLoginFlow())).toContain( + "credentials could not be stored" + ); + }); + test("propagates a failure from the device authorization request", async () => { mockRequestDeviceCode.mockRejectedValue(new Error("network down")); From 7e8a15eec2e422ef379f3baa2224f9745bab1c95 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 6 Sep 2026 22:25:01 +0200 Subject: [PATCH 04/27] fix(auth): address the second review round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `logout` throws `UserError` from inside the existing command boundary instead of calling `logError` and `exitWith` directly, and says the helper entry "could not be removed". - `FillResult` derives from a Zod schema, and the `git credential fill` output is validated rather than trusted — it is subprocess output. - The two malformed-blob cases become one `test.each`. - Dropped a comment that narrated review history rather than describing current behaviour (GEN-004). Docs: the plugin guides still headed the login step "Log in with GitHub" and told users to authenticate with a GitHub account, contradicting the flow described two lines below. Corrected across all three locales, along with "a OAuth" -> "an OAuth". `archgate credential` is now named as the helper, with `archgate login` only registering it, and the VS Code manual step extracts the `password=` field rather than implying the command prints a bare token. The login and credential references now say the legacy-token fallback runs before the session is reported as expired, which is what the code does. Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 32 +++++++++++-------- .../docs/guides/claude-code-plugin.mdx | 6 ++-- .../docs/guides/copilot-cli-plugin.mdx | 6 ++-- .../src/content/docs/guides/vscode-plugin.mdx | 16 +++++++--- .../docs/nb/guides/claude-code-plugin.mdx | 4 +-- .../docs/nb/guides/copilot-cli-plugin.mdx | 4 +-- .../content/docs/nb/guides/vscode-plugin.mdx | 14 +++++--- .../docs/nb/reference/cli/credential.mdx | 2 +- .../content/docs/nb/reference/cli/login.mdx | 2 +- .../docs/pt-br/guides/claude-code-plugin.mdx | 4 +-- .../docs/pt-br/guides/copilot-cli-plugin.mdx | 4 +-- .../docs/pt-br/guides/vscode-plugin.mdx | 14 +++++--- .../docs/pt-br/reference/cli/credential.mdx | 2 +- .../docs/pt-br/reference/cli/login.mdx | 2 +- .../content/docs/reference/cli/credential.mdx | 2 +- docs/src/content/docs/reference/cli/login.mdx | 2 +- src/commands/login.ts | 12 +++---- src/helpers/credential-store.ts | 32 +++++++++++++------ tests/helpers/credential-store.test.ts | 23 ++++--------- 19 files changed, 105 insertions(+), 78 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 9d3c4635..da349e57 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -1330,15 +1330,15 @@ The `archgate:developer` agent is set as the default agent via `.claude/settings The Claude Code plugin is currently in beta. Run `archgate login` to sign up and authenticate. -### 1. Log in with GitHub +### 1. Sign in -Authenticate with your GitHub account to obtain a plugin token: +Sign in to obtain plugin access: ```bash archgate login ``` -This starts a OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin @@ -1477,15 +1477,15 @@ Installation is declarative: the CLI writes the marketplace and plugin declarati The GitHub Copilot plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. -### 1. Log in with GitHub +### 1. Sign in -Authenticate with your GitHub account to obtain a plugin token: +Sign in to obtain plugin access: ```bash archgate login ``` -This starts a OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin @@ -2310,15 +2310,15 @@ Agent plugins require **VS Code 1.110 (February 2026 release) or later**. Earlie The VS Code plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. -### 1. Log in with GitHub +### 1. Sign in -Authenticate with your GitHub account to obtain a plugin token: +Sign in to obtain plugin access: ```bash archgate login ``` -This starts a OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin @@ -2377,7 +2377,7 @@ The user-level marketplace setting (added to your `settings.json`): } ``` -The URL carries no credentials. Git authenticates against `plugins.archgate.dev` by asking `archgate credential`, the credential helper `archgate login` registers for that host, which supplies a freshly renewed access token. +The URL carries no credentials. `archgate credential` is the credential helper; `archgate login` registers it for `plugins.archgate.dev`. Git asks it on each request and receives a freshly renewed access token. ### Manual setup @@ -2393,7 +2393,13 @@ code --install-extension archgate.vsix rm archgate.vsix ``` -Replace `` with a current access token. Print one with `printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get`, which renews it if needed. Access tokens are short-lived, so a value pasted here stops working within the hour — prefer the marketplace setup above, where git asks the helper on every request. +Replace `` with a current access token. `archgate credential get` emits a git credential response rather than a bare token, so take the `password=` line: + +```bash +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's/^password=//p' +``` + +Access tokens are short-lived, so a value pasted here stops working within the hour — prefer the marketplace setup above, where git asks the helper on every request. ## What the plugin provides @@ -4535,7 +4541,7 @@ The output should list an empty line followed by `!archgate credential`. If it d ### The helper is registered but credentials are still rejected -Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, `archgate credential get` returns no credentials and reports the error rather than staying silent. Run `archgate login` again. +Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, the helper falls back to any token issued before this flow existed; when none remains it returns no credentials and reports the error rather than staying silent. Run `archgate login` again. --- @@ -4715,7 +4721,7 @@ archgate login Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate identity provider. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI stores the resulting tokens in your OS credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. -Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI reports that the session has expired and `archgate login` re-establishes it. +Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI falls back to any token issued before this flow existed; when none remains it reports that the session has expired, and `archgate login` re-establishes it. Signing in also registers `archgate credential` as a git credential helper for `plugins.archgate.dev`, which is what lets `git clone` of a plugin repository authenticate without prompting. See [`archgate credential`](/reference/cli/credential/). diff --git a/docs/src/content/docs/guides/claude-code-plugin.mdx b/docs/src/content/docs/guides/claude-code-plugin.mdx index 67b4aa27..a53cdb7d 100644 --- a/docs/src/content/docs/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/guides/claude-code-plugin.mdx @@ -34,15 +34,15 @@ The `archgate:developer` agent is set as the default agent via `.claude/settings The Claude Code plugin is currently in beta. Run `archgate login` to sign up and authenticate. ::: -### 1. Log in with GitHub +### 1. Sign in -Authenticate with your GitHub account to obtain a plugin token: +Sign in to obtain plugin access: ```bash archgate login ``` -This starts a OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin diff --git a/docs/src/content/docs/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/guides/copilot-cli-plugin.mdx index 019805c0..b8e87344 100644 --- a/docs/src/content/docs/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/guides/copilot-cli-plugin.mdx @@ -19,15 +19,15 @@ Installation is declarative: the CLI writes the marketplace and plugin declarati The GitHub Copilot plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. ::: -### 1. Log in with GitHub +### 1. Sign in -Authenticate with your GitHub account to obtain a plugin token: +Sign in to obtain plugin access: ```bash archgate login ``` -This starts a OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin diff --git a/docs/src/content/docs/guides/vscode-plugin.mdx b/docs/src/content/docs/guides/vscode-plugin.mdx index 7e68ce54..f208b05b 100644 --- a/docs/src/content/docs/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/guides/vscode-plugin.mdx @@ -21,15 +21,15 @@ Agent plugins require **VS Code 1.110 (February 2026 release) or later**. Earlie The VS Code plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. ::: -### 1. Log in with GitHub +### 1. Sign in -Authenticate with your GitHub account to obtain a plugin token: +Sign in to obtain plugin access: ```bash archgate login ``` -This starts a OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin @@ -88,7 +88,7 @@ The user-level marketplace setting (added to your `settings.json`): } ``` -The URL carries no credentials. Git authenticates against `plugins.archgate.dev` by asking `archgate credential`, the credential helper `archgate login` registers for that host, which supplies a freshly renewed access token. +The URL carries no credentials. `archgate credential` is the credential helper; `archgate login` registers it for `plugins.archgate.dev`. Git asks it on each request and receives a freshly renewed access token. ### Manual setup @@ -104,7 +104,13 @@ code --install-extension archgate.vsix rm archgate.vsix ``` -Replace `` with a current access token. Print one with `printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get`, which renews it if needed. Access tokens are short-lived, so a value pasted here stops working within the hour — prefer the marketplace setup above, where git asks the helper on every request. +Replace `` with a current access token. `archgate credential get` emits a git credential response rather than a bare token, so take the `password=` line: + +```bash +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's/^password=//p' +``` + +Access tokens are short-lived, so a value pasted here stops working within the hour — prefer the marketplace setup above, where git asks the helper on every request. ## What the plugin provides diff --git a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx index 50760b32..c073a279 100644 --- a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx @@ -34,9 +34,9 @@ Pluginen legger til agenter og rollebaserte ferdigheter i Claude Code. Utviklera Claude Code-pluginen er for tiden i beta. Kjør `archgate login` for å registrere deg og autentisere. ::: -### 1. Logg inn med GitHub +### 1. Logg inn -Autentiser med GitHub-kontoen din for å få et plugin-token: +Logg inn for å få tilgang til pluginene: ```bash archgate login diff --git a/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx index 4ee71216..4afcb2c4 100644 --- a/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx @@ -19,9 +19,9 @@ Installasjonen er deklarativ: CLI-en skriver markedsplass- og plugin-deklarasjon GitHub Copilot-pluginet er for øyeblikket i beta. Kjør `archgate login` for å registrere deg og autentisere før du følger trinnene nedenfor. ::: -### 1. Logg inn med GitHub +### 1. Logg inn -Autentiser med GitHub-kontoen din for å få et plugin-token: +Logg inn for å få tilgang til pluginene: ```bash archgate login diff --git a/docs/src/content/docs/nb/guides/vscode-plugin.mdx b/docs/src/content/docs/nb/guides/vscode-plugin.mdx index ea05c45e..53780d0f 100644 --- a/docs/src/content/docs/nb/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/nb/guides/vscode-plugin.mdx @@ -21,9 +21,9 @@ Agent-plugins krever **VS Code 1.110 (februar 2026-utgivelsen) eller nyere**. Ti VS Code-pluginet er for øyeblikket i beta. Kjør `archgate login` for å registrere deg og autentisere før du følger trinnene nedenfor. ::: -### 1. Logg inn med GitHub +### 1. Logg inn -Autentiser med GitHub-kontoen din for å få et plugin-token: +Logg inn for å få tilgang til pluginene: ```bash archgate login @@ -88,7 +88,7 @@ Markedsplassinnstillingen på brukernivå (lagt til i `settings.json`): } ``` -URL-en inneholder ingen legitimasjon. Git autentiserer mot `plugins.archgate.dev` ved å spørre `archgate credential`, credential-helperen `archgate login` registrerer for den verten, som leverer et nylig fornyet tilgangstoken. +URL-en inneholder ingen legitimasjon. `archgate credential` er credential-helperen; `archgate login` registrerer den for `plugins.archgate.dev`. Git spør den ved hver forespørsel og får et nylig fornyet tilgangstoken. ### Manuelt oppsett @@ -104,7 +104,13 @@ code --install-extension archgate.vsix rm archgate.vsix ``` -Erstatt `` med et gyldig tilgangstoken. Skriv ut ett med `printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get`, som fornyer det ved behov. Tilgangstokener er kortlivede, så en verdi som limes inn her slutter å virke innen en time — bruk heller marketplace-oppsettet over, der git spør helperen ved hver forespørsel. +Erstatt `` med et gyldig tilgangstoken. `archgate credential get` skriver ut et git credential-svar, ikke et bart token, så ta `password=`-linjen: + +```bash +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's/^password=//p' +``` + +Tilgangstokener er kortlivede, så en verdi som limes inn her slutter å virke innen en time — bruk heller marketplace-oppsettet over, der git spør helperen ved hver forespørsel. ## Hva pluginet tilbyr diff --git a/docs/src/content/docs/nb/reference/cli/credential.mdx b/docs/src/content/docs/nb/reference/cli/credential.mdx index ed83432d..6fb2bc96 100644 --- a/docs/src/content/docs/nb/reference/cli/credential.mdx +++ b/docs/src/content/docs/nb/reference/cli/credential.mdx @@ -75,4 +75,4 @@ Utdataene skal vise en tom linje etterfulgt av `!archgate credential`. Hvis ikke ### Helperen er registrert, men legitimasjonen avvises fortsatt -Bekreft at du er logget inn med `archgate login status`. Hvis refresh-tokenet er utløpt eller tilbakekalt, returnerer `archgate credential get` ingen legitimasjon og melder fra om feilen. Kjør `archgate login` på nytt. +Bekreft at du er logget inn med `archgate login status`. Hvis refresh-tokenet er utløpt eller tilbakekalt, faller helperen tilbake på et token utstedt før denne flyten fantes; finnes ingen, returnerer den ingen legitimasjon og melder fra om feilen. Kjør `archgate login` på nytt. diff --git a/docs/src/content/docs/nb/reference/cli/login.mdx b/docs/src/content/docs/nb/reference/cli/login.mdx index c9b2438c..d158ea9c 100644 --- a/docs/src/content/docs/nb/reference/cli/login.mdx +++ b/docs/src/content/docs/nb/reference/cli/login.mdx @@ -11,7 +11,7 @@ archgate login Starter en OAuth 2.0 Device Authorization Grant (RFC 8628) mot Archgates identitetsleverandør. CLI-en viser en engangskode og en URL. Åpne URL-en i nettleseren din, skriv inn koden og velg hvordan du vil logge inn. Når autorisasjonen er fullført, lagrer CLI-en tokenene i operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager eller Linux libsecret) via `git credential approve`. Ingen legitimasjon skrives til disk som klartekstfiler. -Tilgangstokener er kortlivede. CLI-en beholder et refresh-token ved siden av dem og fornyer tilgangstokenet automatisk, så det holder normalt å logge inn én gang. Hvis selve refresh-tokenet utløper eller blir tilbakekalt, melder CLI-en at økten er utløpt, og `archgate login` gjenoppretter den. +Tilgangstokener er kortlivede. CLI-en beholder et refresh-token ved siden av dem og fornyer tilgangstokenet automatisk, så det holder normalt å logge inn én gang. Hvis selve refresh-tokenet utløper eller blir tilbakekalt, faller CLI-en tilbake på et token utstedt før denne flyten fantes; finnes ingen, melder den at økten er utløpt, og `archgate login` gjenoppretter den. Innlogging registrerer også `archgate credential` som en git credential helper for `plugins.archgate.dev`, som er det som lar `git clone` av et plugin-repositorium autentisere uten å spørre. Se [`archgate credential`](/reference/cli/credential/). diff --git a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx index 337d63e5..c7624a27 100644 --- a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx @@ -34,9 +34,9 @@ O agente `archgate:developer` é definido como agente padrão via `.claude/setti O plugin para Claude Code está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar. ::: -### 1. Faça login com o GitHub +### 1. Entre -Autentique-se com sua conta do GitHub para obter um token do plugin: +Entre para obter acesso aos plugins: ```bash archgate login diff --git a/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx index 6f6bbc3b..0193956c 100644 --- a/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx @@ -19,9 +19,9 @@ A instalação é declarativa: o CLI escreve a declaração do marketplace e do O plugin para GitHub Copilot está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar antes de seguir os passos abaixo. ::: -### 1. Faça login com o GitHub +### 1. Entre -Autentique-se com sua conta do GitHub para obter um token do plugin: +Entre para obter acesso aos plugins: ```bash archgate login diff --git a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx index 4cdbd921..e243c91b 100644 --- a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx @@ -21,9 +21,9 @@ Plugins de agente requerem **VS Code 1.110 (lançamento de fevereiro de 2026) ou O plugin para VS Code está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar antes de seguir os passos abaixo. ::: -### 1. Faça login com o GitHub +### 1. Entre -Autentique-se com sua conta do GitHub para obter um token do plugin: +Entre para obter acesso aos plugins: ```bash archgate login @@ -88,7 +88,7 @@ A configuração do marketplace no nível de usuário (adicionada ao seu `settin } ``` -A URL não contém credenciais. O git se autentica em `plugins.archgate.dev` consultando o `archgate credential`, o credential helper que o `archgate login` registra para esse host, que fornece um token de acesso recém-renovado. +A URL não contém credenciais. O `archgate credential` é o credential helper; o `archgate login` o registra para `plugins.archgate.dev`. O git o consulta a cada requisição e recebe um token de acesso recém-renovado. ### Configuração manual @@ -104,7 +104,13 @@ code --install-extension archgate.vsix rm archgate.vsix ``` -Substitua `` por um token de acesso atual. Imprima um com `printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get`, que o renova se necessário. Tokens de acesso têm vida curta, então um valor colado aqui deixa de funcionar em menos de uma hora — prefira a configuração de marketplace acima, em que o git consulta o helper a cada requisição. +Substitua `` por um token de acesso atual. O `archgate credential get` emite uma resposta de credencial do git, não um token puro, então use a linha `password=`: + +```bash +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's/^password=//p' +``` + +Tokens de acesso têm vida curta, então um valor colado aqui deixa de funcionar em menos de uma hora — prefira a configuração de marketplace acima, em que o git consulta o helper a cada requisição. ## O que o plugin oferece diff --git a/docs/src/content/docs/pt-br/reference/cli/credential.mdx b/docs/src/content/docs/pt-br/reference/cli/credential.mdx index 261a624b..c404e404 100644 --- a/docs/src/content/docs/pt-br/reference/cli/credential.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/credential.mdx @@ -75,4 +75,4 @@ A saída deve listar uma linha vazia seguida de `!archgate credential`. Se não ### O helper está registrado, mas as credenciais continuam sendo rejeitadas -Confirme que você está autenticado com `archgate login status`. Se o refresh token expirou ou foi revogado, o `archgate credential get` não retorna credenciais e informa o erro, em vez de permanecer silencioso. Execute `archgate login` novamente. +Confirme que você está autenticado com `archgate login status`. Se o refresh token expirou ou foi revogado, o helper recorre a um token emitido antes deste fluxo existir; se não houver nenhum, não retorna credenciais e informa o erro. Execute `archgate login` novamente. diff --git a/docs/src/content/docs/pt-br/reference/cli/login.mdx b/docs/src/content/docs/pt-br/reference/cli/login.mdx index 5a8fb9d8..883e814e 100644 --- a/docs/src/content/docs/pt-br/reference/cli/login.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/login.mdx @@ -11,7 +11,7 @@ archgate login Inicia um OAuth 2.0 Device Authorization Grant (RFC 8628) no provedor de identidade do Archgate. A CLI exibe um código de uso único e uma URL. Abra a URL no seu navegador, insira o código e escolha como deseja entrar. Após a autorização, a CLI armazena os tokens resultantes no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager ou libsecret no Linux) via `git credential approve`. Nenhuma credencial é gravada em disco como arquivo de texto. -Os tokens de acesso têm vida curta. A CLI mantém um refresh token junto deles e renova o token de acesso automaticamente, então normalmente basta entrar uma vez. Se o próprio refresh token expirar ou for revogado, a CLI informa que a sessão expirou e o `archgate login` a restabelece. +Os tokens de acesso têm vida curta. A CLI mantém um refresh token junto deles e renova o token de acesso automaticamente, então normalmente basta entrar uma vez. Se o próprio refresh token expirar ou for revogado, a CLI recorre a um token emitido antes deste fluxo existir; se não houver nenhum, informa que a sessão expirou, e o `archgate login` a restabelece. Entrar também registra `archgate credential` como um credential helper do git para `plugins.archgate.dev`, que é o que permite ao `git clone` de um repositório de plugin autenticar sem solicitar credenciais. Veja [`archgate credential`](/reference/cli/credential/). diff --git a/docs/src/content/docs/reference/cli/credential.mdx b/docs/src/content/docs/reference/cli/credential.mdx index 10532f15..e42976ed 100644 --- a/docs/src/content/docs/reference/cli/credential.mdx +++ b/docs/src/content/docs/reference/cli/credential.mdx @@ -75,4 +75,4 @@ The output should list an empty line followed by `!archgate credential`. If it d ### The helper is registered but credentials are still rejected -Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, `archgate credential get` returns no credentials and reports the error rather than staying silent. Run `archgate login` again. +Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, the helper falls back to any token issued before this flow existed; when none remains it returns no credentials and reports the error rather than staying silent. Run `archgate login` again. diff --git a/docs/src/content/docs/reference/cli/login.mdx b/docs/src/content/docs/reference/cli/login.mdx index 05d68bbf..68ae8eb3 100644 --- a/docs/src/content/docs/reference/cli/login.mdx +++ b/docs/src/content/docs/reference/cli/login.mdx @@ -11,7 +11,7 @@ archgate login Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate identity provider. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI stores the resulting tokens in your OS credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. -Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI reports that the session has expired and `archgate login` re-establishes it. +Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI falls back to any token issued before this flow existed; when none remains it reports that the session has expired, and `archgate login` re-establishes it. Signing in also registers `archgate credential` as a git credential helper for `plugins.archgate.dev`, which is what lets `git clone` of a plugin repository authenticate without prompting. See [`archgate credential`](/reference/cli/credential/). diff --git a/src/commands/login.ts b/src/commands/login.ts index e572d10b..370551e6 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -12,6 +12,7 @@ import { runLoginFlow } from "../helpers/login-flow"; import { findProjectRoot } from "../helpers/paths"; import { trackLoginResult } from "../helpers/telemetry"; import { isTlsError, tlsHintMessage } from "../helpers/tls"; +import { UserError } from "../helpers/user-error"; export function registerLoginCommand(program: Command) { const login = program @@ -78,15 +79,12 @@ export function registerLoginCommand(program: Command) { await clearCredentials(); const unregistered = await unregisterGitCredentialHelper(); trackLoginResult({ subcommand: "logout", success: unregistered }); - if (unregistered) { - console.log("Logged out successfully."); - } else { - logError( - "Credentials removed, but the git credential helper entry could not be.", - "Remove it with `git config --global --unset-all credential.https://plugins.archgate.dev.helper`." + if (!unregistered) { + throw new UserError( + "Credentials removed, but the git credential helper entry could not be removed. Remove it with `git config --global --unset-all credential.https://plugins.archgate.dev.helper`." ); - await exitWith(1); } + console.log("Logged out successfully."); } catch (err) { await handleCommandError(err); } diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index 086b9c68..1e0acd7a 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -11,6 +11,8 @@ import { unlinkSync } from "node:fs"; +import { z } from "zod"; + import { logDebug, logWarn } from "./log"; import { isExpired, @@ -89,10 +91,19 @@ async function gitCredentialApprove( } /** Outcome of one `git credential fill`, distinguishing absent from unusable. */ -interface FillResult { - credentials: { username: string; password: string } | null; +const FillResultSchema = z.object({ + credentials: z + .object({ username: z.string().min(1), password: z.string().min(1) }) + .nullable(), /** True when the helper had to be killed for exceeding the timeout. */ - timedOut: boolean; + timedOut: z.boolean(), +}); + +type FillResult = z.infer; + +/** Empty outcome, used for every path that yields no credentials. */ +function noCredentials(timedOut: boolean): FillResult { + return { credentials: null, timedOut }; } async function gitCredentialFill(host: string): Promise { @@ -125,8 +136,8 @@ async function gitCredentialFill(host: string): Promise { if (timer) clearTimeout(timer); }); - if (result === null) return { credentials: null, timedOut: true }; - if (result.exitCode !== 0) return { credentials: null, timedOut: false }; + if (result === null) return noCredentials(true); + if (result.exitCode !== 0) return noCredentials(false); let username = ""; let password = ""; @@ -134,12 +145,15 @@ async function gitCredentialFill(host: string): Promise { if (line.startsWith("username=")) username = line.slice(9); if (line.startsWith("password=")) password = line.slice(9); } - return { - credentials: username && password ? { username, password } : null, + + // The helper is a subprocess, so its output is validated rather than trusted. + const parsed = FillResultSchema.safeParse({ + credentials: { username, password }, timedOut: false, - }; + }); + return parsed.success ? parsed.data : noCredentials(false); } catch { - return { credentials: null, timedOut: false }; + return noCredentials(false); } } diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts index 5003fdcb..bd3941b4 100644 --- a/tests/helpers/credential-store.test.ts +++ b/tests/helpers/credential-store.test.ts @@ -326,20 +326,12 @@ describe("credential-store", () => { }); describe("loadTokenSet", () => { - test("returns null when the stored blob is not a token set", async () => { + test.each([ + ["not JSON at all", "not-json"], + ["JSON without token fields", JSON.stringify({ a: 1 })], + ])("returns null when the stored blob is %s", async (_label, stored) => { const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => - gitCredentialStub("archgate", "not-json") - ); - try { - expect(await loadTokenSet()).toBeNull(); - } finally { - fillSpy.mockRestore(); - } - }); - - test("returns null when the JSON is missing token fields", async () => { - const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => - gitCredentialStub("archgate", JSON.stringify({ a: 1 })) + gitCredentialStub("archgate", stored) ); try { expect(await loadTokenSet()).toBeNull(); @@ -418,9 +410,8 @@ describe("credential-store", () => { }); }); - // The regression the review caught: a rejected refresh token is a signed-out - // state, so loadCredentials must keep its null contract and still reach the - // legacy lookup rather than throwing at every caller. + // A rejected refresh token is a signed-out state, so loadCredentials keeps + // its null contract and still reaches the legacy lookup. describe("loadCredentials with an unrenewable session", () => { test("falls through to a legacy token", async () => { const stale = { From a93b97e1627eb72ff973fc786036fe4789a97f71 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 6 Sep 2026 22:39:54 +0200 Subject: [PATCH 05/27] docs: correct the credential-helper fallback and keep tokens out of argv The credential reference claimed `archgate credential get` falls back to a legacy token. It does not: the helper calls `resolveAccessToken()`, which reads and renews only the stored token set, while the legacy lookup lives in `loadCredentials()`. The page now says so, and the login reference keeps the fallback note that belongs to it. The VS Code manual install placed a bearer token in a curl argument, where it is visible in the process list and shell history. The credential response is now reshaped into a `curl --config` directive and piped in, so the token never reaches the command line. "Receives a freshly renewed access token" implied a refresh on every git request; the helper returns the cached token and renews only once it lapses. Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 17 ++++++++--------- docs/src/content/docs/guides/vscode-plugin.mdx | 15 +++++++-------- .../content/docs/nb/guides/vscode-plugin.mdx | 15 +++++++-------- .../docs/nb/reference/cli/credential.mdx | 2 +- .../content/docs/pt-br/guides/vscode-plugin.mdx | 15 +++++++-------- .../docs/pt-br/reference/cli/credential.mdx | 2 +- .../content/docs/reference/cli/credential.mdx | 2 +- 7 files changed, 32 insertions(+), 36 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index da349e57..40930d3d 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -2377,7 +2377,7 @@ The user-level marketplace setting (added to your `settings.json`): } ``` -The URL carries no credentials. `archgate credential` is the credential helper; `archgate login` registers it for `plugins.archgate.dev`. Git asks it on each request and receives a freshly renewed access token. +The URL carries no credentials. `archgate credential` is the credential helper; `archgate login` registers it for `plugins.archgate.dev`. Git asks it on each request and receives a valid access token, renewed only when the cached one has lapsed. ### Manual setup @@ -2388,18 +2388,17 @@ If you prefer not to let the CLI modify your user settings, you can set things u **Extension:** Download and install the `.vsix` file directly: ```bash -curl -H "Authorization: Bearer " https://plugins.archgate.dev/api/vscode -o archgate.vsix +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | + archgate credential get | + sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | + curl --config - --output archgate.vsix https://plugins.archgate.dev/api/vscode code --install-extension archgate.vsix rm archgate.vsix ``` -Replace `` with a current access token. `archgate credential get` emits a git credential response rather than a bare token, so take the `password=` line: +`archgate credential get` emits a git credential response, so the `password=` line is reshaped into a `curl --config` directive and piped in. The token never reaches the command line, where it would be visible in the process list and your shell history. -```bash -printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's/^password=//p' -``` - -Access tokens are short-lived, so a value pasted here stops working within the hour — prefer the marketplace setup above, where git asks the helper on every request. +Prefer the marketplace setup above where you can: it lets git ask the helper on every request, so no token is handled by hand at all. ## What the plugin provides @@ -4541,7 +4540,7 @@ The output should list an empty line followed by `!archgate credential`. If it d ### The helper is registered but credentials are still rejected -Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, the helper falls back to any token issued before this flow existed; when none remains it returns no credentials and reports the error rather than staying silent. Run `archgate login` again. +Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, the helper returns no credentials and reports the error rather than staying silent. It reads only the token set stored by `archgate login`, so the legacy-token fallback other commands have does not apply here. Run `archgate login` again. --- diff --git a/docs/src/content/docs/guides/vscode-plugin.mdx b/docs/src/content/docs/guides/vscode-plugin.mdx index f208b05b..c7c2bfea 100644 --- a/docs/src/content/docs/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/guides/vscode-plugin.mdx @@ -88,7 +88,7 @@ The user-level marketplace setting (added to your `settings.json`): } ``` -The URL carries no credentials. `archgate credential` is the credential helper; `archgate login` registers it for `plugins.archgate.dev`. Git asks it on each request and receives a freshly renewed access token. +The URL carries no credentials. `archgate credential` is the credential helper; `archgate login` registers it for `plugins.archgate.dev`. Git asks it on each request and receives a valid access token, renewed only when the cached one has lapsed. ### Manual setup @@ -99,18 +99,17 @@ If you prefer not to let the CLI modify your user settings, you can set things u **Extension:** Download and install the `.vsix` file directly: ```bash -curl -H "Authorization: Bearer " https://plugins.archgate.dev/api/vscode -o archgate.vsix +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | + archgate credential get | + sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | + curl --config - --output archgate.vsix https://plugins.archgate.dev/api/vscode code --install-extension archgate.vsix rm archgate.vsix ``` -Replace `` with a current access token. `archgate credential get` emits a git credential response rather than a bare token, so take the `password=` line: +`archgate credential get` emits a git credential response, so the `password=` line is reshaped into a `curl --config` directive and piped in. The token never reaches the command line, where it would be visible in the process list and your shell history. -```bash -printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's/^password=//p' -``` - -Access tokens are short-lived, so a value pasted here stops working within the hour — prefer the marketplace setup above, where git asks the helper on every request. +Prefer the marketplace setup above where you can: it lets git ask the helper on every request, so no token is handled by hand at all. ## What the plugin provides diff --git a/docs/src/content/docs/nb/guides/vscode-plugin.mdx b/docs/src/content/docs/nb/guides/vscode-plugin.mdx index 53780d0f..60d78a37 100644 --- a/docs/src/content/docs/nb/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/nb/guides/vscode-plugin.mdx @@ -88,7 +88,7 @@ Markedsplassinnstillingen på brukernivå (lagt til i `settings.json`): } ``` -URL-en inneholder ingen legitimasjon. `archgate credential` er credential-helperen; `archgate login` registrerer den for `plugins.archgate.dev`. Git spør den ved hver forespørsel og får et nylig fornyet tilgangstoken. +URL-en inneholder ingen legitimasjon. `archgate credential` er credential-helperen; `archgate login` registrerer den for `plugins.archgate.dev`. Git spør den ved hver forespørsel og får et gyldig tilgangstoken, fornyet bare når det mellomlagrede har utløpt. ### Manuelt oppsett @@ -99,18 +99,17 @@ Hvis du foretrekker at CLI-en ikke endrer brukerinnstillingene dine, kan du sett **Utvidelse:** Last ned og installer `.vsix`-filen direkte: ```bash -curl -H "Authorization: Bearer " https://plugins.archgate.dev/api/vscode -o archgate.vsix +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | + archgate credential get | + sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | + curl --config - --output archgate.vsix https://plugins.archgate.dev/api/vscode code --install-extension archgate.vsix rm archgate.vsix ``` -Erstatt `` med et gyldig tilgangstoken. `archgate credential get` skriver ut et git credential-svar, ikke et bart token, så ta `password=`-linjen: +`archgate credential get` skriver ut et git credential-svar, så `password=`-linjen formes om til et `curl --config`-direktiv og sendes inn via pipe. Tokenet havner aldri på kommandolinjen, der det ville vært synlig i prosesslisten og i shell-historikken. -```bash -printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's/^password=//p' -``` - -Tilgangstokener er kortlivede, så en verdi som limes inn her slutter å virke innen en time — bruk heller marketplace-oppsettet over, der git spør helperen ved hver forespørsel. +Bruk heller marketplace-oppsettet over når du kan: da spør git helperen ved hver forespørsel, og du håndterer ikke noe token manuelt. ## Hva pluginet tilbyr diff --git a/docs/src/content/docs/nb/reference/cli/credential.mdx b/docs/src/content/docs/nb/reference/cli/credential.mdx index 6fb2bc96..49fae16a 100644 --- a/docs/src/content/docs/nb/reference/cli/credential.mdx +++ b/docs/src/content/docs/nb/reference/cli/credential.mdx @@ -75,4 +75,4 @@ Utdataene skal vise en tom linje etterfulgt av `!archgate credential`. Hvis ikke ### Helperen er registrert, men legitimasjonen avvises fortsatt -Bekreft at du er logget inn med `archgate login status`. Hvis refresh-tokenet er utløpt eller tilbakekalt, faller helperen tilbake på et token utstedt før denne flyten fantes; finnes ingen, returnerer den ingen legitimasjon og melder fra om feilen. Kjør `archgate login` på nytt. +Bekreft at du er logget inn med `archgate login status`. Hvis refresh-tokenet er utløpt eller tilbakekalt, returnerer helperen ingen legitimasjon og melder fra om feilen. Den leser bare tokensettet `archgate login` lagret, så tilbakefallet til eldre tokener som andre kommandoer har, gjelder ikke her. Kjør `archgate login` på nytt. diff --git a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx index e243c91b..2bd23990 100644 --- a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx @@ -88,7 +88,7 @@ A configuração do marketplace no nível de usuário (adicionada ao seu `settin } ``` -A URL não contém credenciais. O `archgate credential` é o credential helper; o `archgate login` o registra para `plugins.archgate.dev`. O git o consulta a cada requisição e recebe um token de acesso recém-renovado. +A URL não contém credenciais. O `archgate credential` é o credential helper; o `archgate login` o registra para `plugins.archgate.dev`. O git o consulta a cada requisição e recebe um token de acesso válido, renovado apenas quando o token em cache expira. ### Configuração manual @@ -99,18 +99,17 @@ Se preferir não deixar o CLI modificar suas configurações, você pode configu **Extensão:** Baixe e instale o arquivo `.vsix` diretamente: ```bash -curl -H "Authorization: Bearer " https://plugins.archgate.dev/api/vscode -o archgate.vsix +printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | + archgate credential get | + sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | + curl --config - --output archgate.vsix https://plugins.archgate.dev/api/vscode code --install-extension archgate.vsix rm archgate.vsix ``` -Substitua `` por um token de acesso atual. O `archgate credential get` emite uma resposta de credencial do git, não um token puro, então use a linha `password=`: +O `archgate credential get` emite uma resposta de credencial do git, então a linha `password=` é convertida em uma diretiva `curl --config` e enviada por pipe. O token nunca chega à linha de comando, onde ficaria visível na lista de processos e no histórico do shell. -```bash -printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's/^password=//p' -``` - -Tokens de acesso têm vida curta, então um valor colado aqui deixa de funcionar em menos de uma hora — prefira a configuração de marketplace acima, em que o git consulta o helper a cada requisição. +Prefira a configuração de marketplace acima quando possível: assim o git consulta o helper a cada requisição e nenhum token é manipulado à mão. ## O que o plugin oferece diff --git a/docs/src/content/docs/pt-br/reference/cli/credential.mdx b/docs/src/content/docs/pt-br/reference/cli/credential.mdx index c404e404..693af38c 100644 --- a/docs/src/content/docs/pt-br/reference/cli/credential.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/credential.mdx @@ -75,4 +75,4 @@ A saída deve listar uma linha vazia seguida de `!archgate credential`. Se não ### O helper está registrado, mas as credenciais continuam sendo rejeitadas -Confirme que você está autenticado com `archgate login status`. Se o refresh token expirou ou foi revogado, o helper recorre a um token emitido antes deste fluxo existir; se não houver nenhum, não retorna credenciais e informa o erro. Execute `archgate login` novamente. +Confirme que você está autenticado com `archgate login status`. Se o refresh token expirou ou foi revogado, o helper não retorna credenciais e informa o erro. Ele lê apenas o conjunto de tokens armazenado pelo `archgate login`, então o recurso a tokens antigos que outros comandos têm não se aplica aqui. Execute `archgate login` novamente. diff --git a/docs/src/content/docs/reference/cli/credential.mdx b/docs/src/content/docs/reference/cli/credential.mdx index e42976ed..5dc392d3 100644 --- a/docs/src/content/docs/reference/cli/credential.mdx +++ b/docs/src/content/docs/reference/cli/credential.mdx @@ -75,4 +75,4 @@ The output should list an empty line followed by `!archgate credential`. If it d ### The helper is registered but credentials are still rejected -Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, the helper falls back to any token issued before this flow existed; when none remains it returns no credentials and reports the error rather than staying silent. Run `archgate login` again. +Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, the helper returns no credentials and reports the error rather than staying silent. It reads only the token set stored by `archgate login`, so the legacy-token fallback other commands have does not apply here. Run `archgate login` again. From 7197d193f23cc2e42b31f272e65c193fd906f4a2 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 6 Sep 2026 23:34:39 +0200 Subject: [PATCH 06/27] fix(auth): survive a concurrent refresh, and stop overstating token storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git invokes the credential helper many times for one operation, so several processes can read the same expired token set and refresh concurrently. Logto rotates the refresh token, so every exchange after the first fails with `invalid_grant` and the clone dies mid-way. A failed exchange now re-reads the store: when the token set has changed and is usable, the winner's tokens are used instead of raising. The login and privacy pages claimed tokens are stored in the OS credential manager and never in plain text. `saveTokenSet` hands them to `git credential approve`, so that depends entirely on the configured helper — git's built-in `store` writes `~/.git-credentials` in the clear. Both pages now say what actually determines it, in all three locales. Coverage: the command error boundaries, the logout failure path, and the provider's malformed-response paths were untested, leaving CI's 99.9% line threshold short at 99.8%. All four touched source files are now at 100%. The logout test also ran `git config --global --unset-all` against the real machine's configuration; it now stubs the helper removal. Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 4 +- .../content/docs/nb/reference/cli/login.mdx | 2 +- .../docs/nb/reference/privacy-policy.mdx | 2 +- .../docs/pt-br/reference/cli/login.mdx | 2 +- .../docs/pt-br/reference/privacy-policy.mdx | 2 +- docs/src/content/docs/reference/cli/login.mdx | 2 +- .../content/docs/reference/privacy-policy.mdx | 2 +- src/helpers/credential-store.ts | 39 +++++++++- tests/commands/credential.test.ts | 31 ++++++++ tests/commands/login.test.ts | 21 ++++++ tests/helpers/credential-store.test.ts | 58 +++++++++++++++ tests/helpers/logto-auth.test.ts | 71 +++++++++++++++++++ 12 files changed, 225 insertions(+), 11 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 40930d3d..2ca9b810 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4718,7 +4718,7 @@ Sign in to access Archgate editor plugins. archgate login ``` -Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate identity provider. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI stores the resulting tokens in your OS credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. +Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate identity provider. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI falls back to any token issued before this flow existed; when none remains it reports that the session has expired, and `archgate login` re-establishes it. @@ -5413,7 +5413,7 @@ Data is transmitted via reverse proxies at `n.archgate.dev` (analytics) and `s.a ### Archgate Plugins Service -When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate identity provider** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing. On your machine, the resulting tokens are kept in your OS credential manager (never as plain-text files). +When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate identity provider** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing. On your machine, the resulting tokens are handed to your configured git credential helper; with an OS-backed helper they are kept encrypted, while git's built-in `store` helper writes them in plain text. By creating an account, you agree to the [Terms of Service](https://archgate.dev/terms-of-service). See the [full privacy policy](https://archgate.dev/privacy-policy) for complete details. diff --git a/docs/src/content/docs/nb/reference/cli/login.mdx b/docs/src/content/docs/nb/reference/cli/login.mdx index d158ea9c..9b9da848 100644 --- a/docs/src/content/docs/nb/reference/cli/login.mdx +++ b/docs/src/content/docs/nb/reference/cli/login.mdx @@ -9,7 +9,7 @@ Logg inn for å få tilgang til Archgate editor-plugins. archgate login ``` -Starter en OAuth 2.0 Device Authorization Grant (RFC 8628) mot Archgates identitetsleverandør. CLI-en viser en engangskode og en URL. Åpne URL-en i nettleseren din, skriv inn koden og velg hvordan du vil logge inn. Når autorisasjonen er fullført, lagrer CLI-en tokenene i operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager eller Linux libsecret) via `git credential approve`. Ingen legitimasjon skrives til disk som klartekstfiler. +Starter en OAuth 2.0 Device Authorization Grant (RFC 8628) mot Archgates identitetsleverandør. CLI-en viser en engangskode og en URL. Åpne URL-en i nettleseren din, skriv inn koden og velg hvordan du vil logge inn. Når autorisasjonen er fullført, gir CLI-en tokenene til git via `git credential approve`, så hvor de havner avhenger av hvilken credential-helper du har konfigurert. En helper som bruker operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager, Linux libsecret) holder dem krypterte; gits innebygde `store`-helper skriver dem til `~/.git-credentials` i klartekst. Sjekk din med `git config --global credential.helper`. Tilgangstokener er kortlivede. CLI-en beholder et refresh-token ved siden av dem og fornyer tilgangstokenet automatisk, så det holder normalt å logge inn én gang. Hvis selve refresh-tokenet utløper eller blir tilbakekalt, faller CLI-en tilbake på et token utstedt før denne flyten fantes; finnes ingen, melder den at økten er utløpt, og `archgate login` gjenoppretter den. diff --git a/docs/src/content/docs/nb/reference/privacy-policy.mdx b/docs/src/content/docs/nb/reference/privacy-policy.mdx index a6cbdace..4384f322 100644 --- a/docs/src/content/docs/nb/reference/privacy-policy.mdx +++ b/docs/src/content/docs/nb/reference/privacy-policy.mdx @@ -31,7 +31,7 @@ Data overføres via reverseproksier på `n.archgate.dev` (analyse) og `s.archgat ### Archgate Plugins Service -Når du logger inn via `archgate login`, autentiserer du med GitHub gjennom **Archgates identitetsleverandør** (`auth.archgate.dev`), som mottar profilen GitHub deler med den: ditt **navn**, din **e-postadresse**, ditt **GitHub-brukernavn** og din **avatar**. Dette brukes til å identifisere kontoen din og sende en velkomst-e-post. CLI-en spør deg selv om ingenting. På din maskin oppbevares tokenene i operativsystemets legitimasjonsbehandler (aldri som ren tekstfiler). +Når du logger inn via `archgate login`, autentiserer du med GitHub gjennom **Archgates identitetsleverandør** (`auth.archgate.dev`), som mottar profilen GitHub deler med den: ditt **navn**, din **e-postadresse**, ditt **GitHub-brukernavn** og din **avatar**. Dette brukes til å identifisere kontoen din og sende en velkomst-e-post. CLI-en spør deg selv om ingenting. På din maskin gis tokenene til den credential-helperen du har konfigurert; med en helper som bruker operativsystemet holdes de krypterte, mens gits innebygde `store`-helper skriver dem i klartekst. Ved å opprette en konto godtar du [tjenestevilkårene](https://archgate.dev/terms-of-service). Se den [fullstendige personvernerklæringen](https://archgate.dev/privacy-policy) for komplette detaljer. diff --git a/docs/src/content/docs/pt-br/reference/cli/login.mdx b/docs/src/content/docs/pt-br/reference/cli/login.mdx index 883e814e..5cf1139d 100644 --- a/docs/src/content/docs/pt-br/reference/cli/login.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/login.mdx @@ -9,7 +9,7 @@ Entre para acessar os plugins de editor do Archgate. archgate login ``` -Inicia um OAuth 2.0 Device Authorization Grant (RFC 8628) no provedor de identidade do Archgate. A CLI exibe um código de uso único e uma URL. Abra a URL no seu navegador, insira o código e escolha como deseja entrar. Após a autorização, a CLI armazena os tokens resultantes no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager ou libsecret no Linux) via `git credential approve`. Nenhuma credencial é gravada em disco como arquivo de texto. +Inicia um OAuth 2.0 Device Authorization Grant (RFC 8628) no provedor de identidade do Archgate. A CLI exibe um código de uso único e uma URL. Abra a URL no seu navegador, insira o código e escolha como deseja entrar. Após a autorização, a CLI entrega os tokens ao git via `git credential approve`, então onde eles ficam depende do credential helper que você tem configurado. Um helper apoiado no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager, libsecret no Linux) os mantém criptografados; o helper `store` embutido do git os grava em `~/.git-credentials` em texto puro. Verifique o seu com `git config --global credential.helper`. Os tokens de acesso têm vida curta. A CLI mantém um refresh token junto deles e renova o token de acesso automaticamente, então normalmente basta entrar uma vez. Se o próprio refresh token expirar ou for revogado, a CLI recorre a um token emitido antes deste fluxo existir; se não houver nenhum, informa que a sessão expirou, e o `archgate login` a restabelece. diff --git a/docs/src/content/docs/pt-br/reference/privacy-policy.mdx b/docs/src/content/docs/pt-br/reference/privacy-policy.mdx index 6c80a09a..f673af61 100644 --- a/docs/src/content/docs/pt-br/reference/privacy-policy.mdx +++ b/docs/src/content/docs/pt-br/reference/privacy-policy.mdx @@ -17,7 +17,7 @@ O Archgate coleta **análises de uso anônimas** (via PostHog) e **relatórios d ### Serviço de Plugins do Archgate -Quando você entra via `archgate login`, você se autentica com o GitHub através do **provedor de identidade do Archgate** (`auth.archgate.dev`), que recebe o perfil que o GitHub compartilha com ele: seu **nome**, **endereço de email**, **nome de usuário GitHub** e **avatar**. Isso é usado para identificar sua conta e enviar um email de boas-vindas. A própria CLI não pede nada a você. Na sua máquina, os tokens resultantes são mantidos no gerenciador de credenciais do seu sistema operacional (nunca como arquivos de texto simples). Consulte a [política de privacidade completa](https://archgate.dev/privacy-policy) para detalhes completos. +Quando você entra via `archgate login`, você se autentica com o GitHub através do **provedor de identidade do Archgate** (`auth.archgate.dev`), que recebe o perfil que o GitHub compartilha com ele: seu **nome**, **endereço de email**, **nome de usuário GitHub** e **avatar**. Isso é usado para identificar sua conta e enviar um email de boas-vindas. A própria CLI não pede nada a você. Na sua máquina, os tokens resultantes são entregues ao credential helper do git que você configurou; com um helper apoiado no sistema operacional eles ficam criptografados, enquanto o helper `store` embutido do git os grava em texto puro. Consulte a [política de privacidade completa](https://archgate.dev/privacy-policy) para detalhes completos. ### Como desativar diff --git a/docs/src/content/docs/reference/cli/login.mdx b/docs/src/content/docs/reference/cli/login.mdx index 68ae8eb3..6385e555 100644 --- a/docs/src/content/docs/reference/cli/login.mdx +++ b/docs/src/content/docs/reference/cli/login.mdx @@ -9,7 +9,7 @@ Sign in to access Archgate editor plugins. archgate login ``` -Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate identity provider. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI stores the resulting tokens in your OS credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. +Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate identity provider. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI falls back to any token issued before this flow existed; when none remains it reports that the session has expired, and `archgate login` re-establishes it. diff --git a/docs/src/content/docs/reference/privacy-policy.mdx b/docs/src/content/docs/reference/privacy-policy.mdx index d95c7bf3..ad05c7d7 100644 --- a/docs/src/content/docs/reference/privacy-policy.mdx +++ b/docs/src/content/docs/reference/privacy-policy.mdx @@ -31,7 +31,7 @@ Data is transmitted via reverse proxies at `n.archgate.dev` (analytics) and `s.a ### Archgate Plugins Service -When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate identity provider** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing. On your machine, the resulting tokens are kept in your OS credential manager (never as plain-text files). +When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate identity provider** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing. On your machine, the resulting tokens are handed to your configured git credential helper; with an OS-backed helper they are kept encrypted, while git's built-in `store` helper writes them in plain text. By creating an account, you agree to the [Terms of Service](https://archgate.dev/terms-of-service). See the [full privacy policy](https://archgate.dev/privacy-policy) for complete details. diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index 1e0acd7a..d0fd10b3 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -306,14 +306,47 @@ async function resolveWithStatus(): Promise<{ }; } - const renewed = await refreshAccessToken(stored.tokens.refreshToken); - await saveTokenSet(stored.user, renewed); return { - credentials: { token: renewed.accessToken, github_user: stored.user }, + credentials: await renew(stored.user, stored.tokens.refreshToken), timedOut: false, }; } +/** + * Exchange a refresh token, tolerating a sibling process having rotated it. + * + * Git invokes the credential helper many times for one operation, so several + * processes can read the same expired token set and refresh concurrently. + * Logto rotates the refresh token, which makes every exchange after the first + * fail. Re-reading the store recovers the token the winner just saved. + * + * @param user - Account the token set belongs to. + * @param refreshToken - The refresh token read before renewal. + * @returns Credentials from this renewal, or from whichever process won. + * @throws {UserError} When no usable token set exists afterwards. + */ +async function renew( + user: string, + refreshToken: string +): Promise { + try { + const renewed = await refreshAccessToken(refreshToken); + await saveTokenSet(user, renewed); + return { token: renewed.accessToken, github_user: user }; + } catch (error) { + const { stored } = await readTokenSet(); + if ( + stored && + stored.tokens.refreshToken !== refreshToken && + !isExpired(stored.tokens.expiresAt) + ) { + logDebug("token set was renewed by another process"); + return { token: stored.tokens.accessToken, github_user: stored.user }; + } + throw error; + } +} + /** * Load archgate credentials, renewing a lapsed access token on the way. * diff --git a/tests/commands/credential.test.ts b/tests/commands/credential.test.ts index fcc16e49..93678b62 100644 --- a/tests/commands/credential.test.ts +++ b/tests/commands/credential.test.ts @@ -14,6 +14,8 @@ import { Command } from "@commander-js/extra-typings"; import { registerCredentialCommand } from "../../src/commands/credential"; import * as credMod from "../../src/helpers/credential-store"; +import * as exitModule from "../../src/helpers/exit"; +import { rejectionMessage } from "../test-utils"; let stdinSpy: Mock; let stdoutSpy: Mock; @@ -112,3 +114,32 @@ describe("archgate credential store", () => { expect(resolveSpy).not.toHaveBeenCalled(); }); }); + +// Each action wraps its body in the command error boundary; a failure there +// must be routed through handleCommandError rather than escaping the process. +describe("error boundaries", () => { + test.each(["get", "store", "erase"])( + "%s routes a stdin failure to the error handler", + async (subcommand) => { + stdinSpy = spyOn(Bun.stdin, "text").mockRejectedValue( + new Error("stdin closed") + ); + const exitSpy = spyOn(exitModule, "exitWith").mockImplementation(() => { + throw new Error("process.exit"); + }); + const program = new Command(); + program.exitOverride(); + registerCredentialCommand(program); + + try { + expect( + await rejectionMessage( + program.parseAsync(["node", "archgate", "credential", subcommand]) + ) + ).toContain("process.exit"); + } finally { + exitSpy.mockRestore(); + } + } + ); +}); diff --git a/tests/commands/login.test.ts b/tests/commands/login.test.ts index 8bc2d81b..9078b320 100644 --- a/tests/commands/login.test.ts +++ b/tests/commands/login.test.ts @@ -22,6 +22,7 @@ import { Command } from "@commander-js/extra-typings"; import { registerLoginCommand } from "../../src/commands/login"; import * as credentialStore from "../../src/helpers/credential-store"; import * as exitMod from "../../src/helpers/exit"; +import * as gitCredentialConfig from "../../src/helpers/git-credential-config"; import * as loginFlow from "../../src/helpers/login-flow"; import * as paths from "../../src/helpers/paths"; import * as telemetry from "../../src/helpers/telemetry"; @@ -79,6 +80,11 @@ describe("login action handlers", () => { let errorSpy: Mock; let loadCredentialsSpy: Mock; let clearCredentialsSpy: Mock; + // Stubbed so logout does not run `git config --global` against the real + // machine's configuration. + let unregisterHelperSpy: Mock< + typeof gitCredentialConfig.unregisterGitCredentialHelper + >; let runLoginFlowSpy: Mock; let exitWithSpy: Mock; let trackLoginSpy: Mock; @@ -88,6 +94,10 @@ describe("login action handlers", () => { errorSpy = spyOn(console, "error").mockImplementation(() => {}); loadCredentialsSpy = spyOn(credentialStore, "loadCredentials"); clearCredentialsSpy = spyOn(credentialStore, "clearCredentials"); + unregisterHelperSpy = spyOn( + gitCredentialConfig, + "unregisterGitCredentialHelper" + ).mockResolvedValue(true); runLoginFlowSpy = spyOn(loginFlow, "runLoginFlow"); trackLoginSpy = spyOn(telemetry, "trackLoginResult").mockImplementation( () => {} @@ -106,6 +116,7 @@ describe("login action handlers", () => { errorSpy.mockRestore(); loadCredentialsSpy.mockRestore(); clearCredentialsSpy.mockRestore(); + unregisterHelperSpy.mockRestore(); runLoginFlowSpy.mockRestore(); exitWithSpy.mockRestore(); trackLoginSpy.mockRestore(); @@ -186,6 +197,16 @@ describe("login action handlers", () => { expect(allOutput).toContain("Logged out successfully"); }); + test("fails when the git credential helper entry cannot be removed", async () => { + clearCredentialsSpy.mockResolvedValueOnce(); + unregisterHelperSpy.mockResolvedValue(false); + + const program = makeProgram(); + expect( + program.parseAsync(["node", "test", "login", "logout"]) + ).rejects.toThrow("exitWith(1)"); + }); + test("exits with code 2 when clearCredentials throws (unexpected)", async () => { clearCredentialsSpy.mockRejectedValueOnce(new Error("clear failed")); diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts index bd3941b4..f98400f0 100644 --- a/tests/helpers/credential-store.test.ts +++ b/tests/helpers/credential-store.test.ts @@ -463,4 +463,62 @@ describe("credential-store", () => { } }); }); + + // Git runs the helper many times per operation, so processes can race to + // refresh the same expired token set. Logto rotates the refresh token, so + // every exchange after the first fails. + describe("concurrent renewal", () => { + const expired = { + accessToken: "ey.stale", + refreshToken: "refresh-old", + expiresAt: Date.now() - 1_000, + }; + const rotated = { + accessToken: "ey.fromWinner", + refreshToken: "refresh-rotated", + expiresAt: Date.now() + 3_600_000, + }; + + test("uses the token another process already saved", async () => { + let read = 0; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => { + read += 1; + return gitCredentialStub( + "octocat", + JSON.stringify(read === 1 ? expired : rotated) + ); + }); + const refreshSpy = spyOn( + logtoMod, + "refreshAccessToken" + ).mockRejectedValue(new UserError("Your session has expired.")); + try { + expect(await resolveAccessToken()).toEqual({ + token: "ey.fromWinner", + github_user: "octocat", + }); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); + + test("rethrows when the stored token set did not change", async () => { + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("octocat", JSON.stringify(expired)) + ); + const refreshSpy = spyOn( + logtoMod, + "refreshAccessToken" + ).mockRejectedValue(new UserError("Your session has expired.")); + try { + expect(await rejectionMessage(resolveAccessToken())).toContain( + "session has expired" + ); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); + }); }); diff --git a/tests/helpers/logto-auth.test.ts b/tests/helpers/logto-auth.test.ts index ad9f080d..16996373 100644 --- a/tests/helpers/logto-auth.test.ts +++ b/tests/helpers/logto-auth.test.ts @@ -175,6 +175,77 @@ describe("isExpired", () => { }); }); +describe("malformed provider responses", () => { + test("rejects a device-code response that is not the expected shape", async () => { + responses.push(Response.json({ nope: true })); + + expect(await rejectionMessage(requestDeviceCode())).toContain( + "unexpected response" + ); + }); + + test("rejects a poll error body that is not the expected shape", async () => { + responses.push(Response.json({ nope: true }, { status: 400 })); + + expect( + await rejectionMessage(pollForTokens("device-abc", 0, 60)) + ).toContain("unexpected response"); + }); + + test("backs off when the provider answers slow_down", async () => { + responses.push( + errorResponse("slow_down"), + Response.json({ + access_token: "ey.access", + refresh_token: "refresh-abc", + expires_in: 3600, + }) + ); + + const result = await pollForTokens("device-abc", 0, 60); + + expect(result.tokens.accessToken).toBe("ey.access"); + }); + + test("surfaces an unrecognised error with its description", async () => { + responses.push( + Response.json( + { error: "invalid_client", error_description: "unknown client" }, + { status: 400 } + ) + ); + + expect( + await rejectionMessage(pollForTokens("device-abc", 0, 60)) + ).toContain("unknown client"); + }); + + test("falls back to the error code when no description is given", async () => { + responses.push(Response.json({ error: "invalid_client" }, { status: 400 })); + + expect( + await rejectionMessage(pollForTokens("device-abc", 0, 60)) + ).toContain("invalid_client"); + }); + + // A proxy's HTML error page must not escape as a SyntaxError. + test("treats a non-JSON success body as an unexpected response", async () => { + responses.push(new Response("gateway", { status: 200 })); + + expect( + await rejectionMessage(pollForTokens("device-abc", 0, 60)) + ).toContain("unexpected response"); + }); + + test("rejects a refresh response that is not the expected shape", async () => { + responses.push(Response.json({ nope: true })); + + expect(await rejectionMessage(refreshAccessToken("refresh-old"))).toContain( + "unexpected response" + ); + }); +}); + describe("identityFromIdToken", () => { test.each([ [{ sub: "usr_1", username: "octocat" }, "octocat"], From 55f4291fa332a6604e6ab04da11f9788dac3c62d Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 6 Sep 2026 23:53:16 +0200 Subject: [PATCH 07/27] fix: drain both git streams in the test helper, fail the download on 401 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test helper piped `stderr` but read only `stdout`, and parsed that output without checking git's exit code. Both streams are now drained concurrently so neither can fill and block git, and a non-zero exit raises with stderr attached — exit code 1 stays an empty list, since that is how git reports an unset key (ARCH-007). The VS Code manual download invoked curl without `--fail`, so a 401 wrote the error body over `archgate.vsix` and the install proceeded against a corrupt file. Added `--fail --silent --show-error` in all three locales. Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 4 ++-- .../src/content/docs/guides/vscode-plugin.mdx | 4 ++-- .../content/docs/nb/guides/vscode-plugin.mdx | 4 ++-- .../docs/pt-br/guides/vscode-plugin.mdx | 4 ++-- tests/helpers/git-credential-config.test.ts | 21 ++++++++++++++++--- 5 files changed, 26 insertions(+), 11 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 2ca9b810..364f39e2 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -2391,12 +2391,12 @@ If you prefer not to let the CLI modify your user settings, you can set things u printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | - curl --config - --output archgate.vsix https://plugins.archgate.dev/api/vscode + curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode code --install-extension archgate.vsix rm archgate.vsix ``` -`archgate credential get` emits a git credential response, so the `password=` line is reshaped into a `curl --config` directive and piped in. The token never reaches the command line, where it would be visible in the process list and your shell history. +`archgate credential get` emits a git credential response, so the `password=` line is reshaped into a `curl --config` directive and piped in. The token never reaches the command line, where it would be visible in the process list and your shell history. `--fail` makes curl exit non-zero on a 401 rather than writing the error body over `archgate.vsix`. Prefer the marketplace setup above where you can: it lets git ask the helper on every request, so no token is handled by hand at all. diff --git a/docs/src/content/docs/guides/vscode-plugin.mdx b/docs/src/content/docs/guides/vscode-plugin.mdx index c7c2bfea..2a190a09 100644 --- a/docs/src/content/docs/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/guides/vscode-plugin.mdx @@ -102,12 +102,12 @@ If you prefer not to let the CLI modify your user settings, you can set things u printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | - curl --config - --output archgate.vsix https://plugins.archgate.dev/api/vscode + curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode code --install-extension archgate.vsix rm archgate.vsix ``` -`archgate credential get` emits a git credential response, so the `password=` line is reshaped into a `curl --config` directive and piped in. The token never reaches the command line, where it would be visible in the process list and your shell history. +`archgate credential get` emits a git credential response, so the `password=` line is reshaped into a `curl --config` directive and piped in. The token never reaches the command line, where it would be visible in the process list and your shell history. `--fail` makes curl exit non-zero on a 401 rather than writing the error body over `archgate.vsix`. Prefer the marketplace setup above where you can: it lets git ask the helper on every request, so no token is handled by hand at all. diff --git a/docs/src/content/docs/nb/guides/vscode-plugin.mdx b/docs/src/content/docs/nb/guides/vscode-plugin.mdx index 60d78a37..28b4a805 100644 --- a/docs/src/content/docs/nb/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/nb/guides/vscode-plugin.mdx @@ -102,12 +102,12 @@ Hvis du foretrekker at CLI-en ikke endrer brukerinnstillingene dine, kan du sett printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | - curl --config - --output archgate.vsix https://plugins.archgate.dev/api/vscode + curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode code --install-extension archgate.vsix rm archgate.vsix ``` -`archgate credential get` skriver ut et git credential-svar, så `password=`-linjen formes om til et `curl --config`-direktiv og sendes inn via pipe. Tokenet havner aldri på kommandolinjen, der det ville vært synlig i prosesslisten og i shell-historikken. +`archgate credential get` skriver ut et git credential-svar, så `password=`-linjen formes om til et `curl --config`-direktiv og sendes inn via pipe. Tokenet havner aldri på kommandolinjen, der det ville vært synlig i prosesslisten og i shell-historikken. `--fail` gjør at curl avslutter med feilkode ved 401 i stedet for å skrive feilmeldingen over `archgate.vsix`. Bruk heller marketplace-oppsettet over når du kan: da spør git helperen ved hver forespørsel, og du håndterer ikke noe token manuelt. diff --git a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx index 2bd23990..f9dc268e 100644 --- a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx @@ -102,12 +102,12 @@ Se preferir não deixar o CLI modificar suas configurações, você pode configu printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | - curl --config - --output archgate.vsix https://plugins.archgate.dev/api/vscode + curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode code --install-extension archgate.vsix rm archgate.vsix ``` -O `archgate credential get` emite uma resposta de credencial do git, então a linha `password=` é convertida em uma diretiva `curl --config` e enviada por pipe. O token nunca chega à linha de comando, onde ficaria visível na lista de processos e no histórico do shell. +O `archgate credential get` emite uma resposta de credencial do git, então a linha `password=` é convertida em uma diretiva `curl --config` e enviada por pipe. O token nunca chega à linha de comando, onde ficaria visível na lista de processos e no histórico do shell. O `--fail` faz o curl sair com erro em um 401, em vez de gravar o corpo do erro por cima de `archgate.vsix`. Prefira a configuração de marketplace acima quando possível: assim o git consulta o helper a cada requisição e nenhum token é manipulado à mão. diff --git a/tests/helpers/git-credential-config.test.ts b/tests/helpers/git-credential-config.test.ts index dee8c00e..b955d01e 100644 --- a/tests/helpers/git-credential-config.test.ts +++ b/tests/helpers/git-credential-config.test.ts @@ -18,14 +18,29 @@ let gitConfigPath: string; let originalNoSystem: string | undefined; let originalGlobal: string | undefined; -/** Read the helper entries git has recorded, in order. */ +/** + * Read the helper entries git has recorded, in order. + * + * Both piped streams are drained concurrently so neither can fill and block + * git, and the exit code is checked before the output is trusted (ARCH-007). + * Exit code 1 means the key is unset, which is an empty list rather than a + * failure. + */ async function configuredHelpers(): Promise { const proc = Bun.spawn( ["git", "config", "--global", "--get-all", HELPER_KEY], { stdout: "pipe", stderr: "pipe", env: { ...Bun.env } } ); - const stdout = await new Response(proc.stdout).text(); - await proc.exited; + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + + if (exitCode === 1) return []; + if (exitCode !== 0) { + throw new Error(`git config exited ${exitCode}: ${stderr.trim()}`); + } return stdout.split("\n").slice(0, -1); } From 41b642eb680c93641dd8d8589cc2dd4d13000ac0 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Sun, 6 Sep 2026 23:58:37 +0200 Subject: [PATCH 08/27] docs: name the destination the Archgate platform throughout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `archgate login` logs in to the Archgate platform. The command description, the reference pages, the command tables and the guide headings each phrased this differently — "sign in to access editor plugins", "sign in", "log in with GitHub" — which left the destination unnamed and made plugin access look like the purpose rather than a consequence. "Archgate identity provider" is that same platform, so the term is gone: it named one component of a thing users know by another name, in prose that already had to explain the relationship. Applied across the CLI surface and all three locales. Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 26 +++++++++---------- .../docs/guides/claude-code-plugin.mdx | 6 ++--- .../docs/guides/copilot-cli-plugin.mdx | 6 ++--- .../src/content/docs/guides/vscode-plugin.mdx | 6 ++--- .../docs/nb/guides/claude-code-plugin.mdx | 4 +-- .../docs/nb/guides/copilot-cli-plugin.mdx | 4 +-- .../content/docs/nb/guides/vscode-plugin.mdx | 4 +-- .../content/docs/nb/reference/cli/index.mdx | 2 +- .../content/docs/nb/reference/cli/login.mdx | 6 ++--- .../docs/nb/reference/privacy-policy.mdx | 2 +- .../docs/pt-br/guides/claude-code-plugin.mdx | 4 +-- .../docs/pt-br/guides/copilot-cli-plugin.mdx | 4 +-- .../docs/pt-br/guides/vscode-plugin.mdx | 4 +-- .../docs/pt-br/reference/cli/index.mdx | 2 +- .../docs/pt-br/reference/cli/login.mdx | 6 ++--- .../docs/pt-br/reference/privacy-policy.mdx | 2 +- docs/src/content/docs/reference/cli/index.mdx | 2 +- docs/src/content/docs/reference/cli/login.mdx | 6 ++--- .../content/docs/reference/privacy-policy.mdx | 2 +- src/commands/init.ts | 2 +- src/commands/login.ts | 2 +- src/helpers/logto-auth.ts | 6 ++--- 22 files changed, 54 insertions(+), 54 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 364f39e2..18b23d4f 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -1330,15 +1330,15 @@ The `archgate:developer` agent is set as the default agent via `.claude/settings The Claude Code plugin is currently in beta. Run `archgate login` to sign up and authenticate. -### 1. Sign in +### 1. Log in to the Archgate platform -Sign in to obtain plugin access: +Log in to obtain plugin access: ```bash archgate login ``` -This starts an OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin @@ -1477,15 +1477,15 @@ Installation is declarative: the CLI writes the marketplace and plugin declarati The GitHub Copilot plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. -### 1. Sign in +### 1. Log in to the Archgate platform -Sign in to obtain plugin access: +Log in to obtain plugin access: ```bash archgate login ``` -This starts an OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin @@ -2310,15 +2310,15 @@ Agent plugins require **VS Code 1.110 (February 2026 release) or later**. Earlie The VS Code plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. -### 1. Sign in +### 1. Log in to the Archgate platform -Sign in to obtain plugin access: +Log in to obtain plugin access: ```bash archgate login ``` -This starts an OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin @@ -4632,7 +4632,7 @@ archgate --log-level debug check | Command | Description | | ------------------------------------------------ | ----------------------------------------------------- | -| [`archgate login`](./login/) | Sign in to access editor plugins | +| [`archgate login`](./login/) | Log in to the Archgate platform | | [`archgate init`](./init/) | Set up linting and rules enforcement | | [`archgate plugin`](./plugin/) | Manage editor plugins | | [`archgate check`](./check/) | Run ADR compliance checks | @@ -4712,13 +4712,13 @@ Re-running `archgate init` does **not** overwrite a manually configured `baseBra Source: https://cli.archgate.dev/reference/cli/login/ -Sign in to access Archgate editor plugins. +Log in to the Archgate platform. Logging in is what grants access to the editor plugins. ```bash archgate login ``` -Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate identity provider. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. +Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate platform. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI falls back to any token issued before this flow existed; when none remains it reports that the session has expired, and `archgate login` re-establishes it. @@ -5413,7 +5413,7 @@ Data is transmitted via reverse proxies at `n.archgate.dev` (analytics) and `s.a ### Archgate Plugins Service -When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate identity provider** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing. On your machine, the resulting tokens are handed to your configured git credential helper; with an OS-backed helper they are kept encrypted, while git's built-in `store` helper writes them in plain text. +When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate platform** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing. On your machine, the resulting tokens are handed to your configured git credential helper; with an OS-backed helper they are kept encrypted, while git's built-in `store` helper writes them in plain text. By creating an account, you agree to the [Terms of Service](https://archgate.dev/terms-of-service). See the [full privacy policy](https://archgate.dev/privacy-policy) for complete details. diff --git a/docs/src/content/docs/guides/claude-code-plugin.mdx b/docs/src/content/docs/guides/claude-code-plugin.mdx index a53cdb7d..3bb82f84 100644 --- a/docs/src/content/docs/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/guides/claude-code-plugin.mdx @@ -34,15 +34,15 @@ The `archgate:developer` agent is set as the default agent via `.claude/settings The Claude Code plugin is currently in beta. Run `archgate login` to sign up and authenticate. ::: -### 1. Sign in +### 1. Log in to the Archgate platform -Sign in to obtain plugin access: +Log in to obtain plugin access: ```bash archgate login ``` -This starts an OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin diff --git a/docs/src/content/docs/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/guides/copilot-cli-plugin.mdx index b8e87344..b17060bf 100644 --- a/docs/src/content/docs/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/guides/copilot-cli-plugin.mdx @@ -19,15 +19,15 @@ Installation is declarative: the CLI writes the marketplace and plugin declarati The GitHub Copilot plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. ::: -### 1. Sign in +### 1. Log in to the Archgate platform -Sign in to obtain plugin access: +Log in to obtain plugin access: ```bash archgate login ``` -This starts an OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin diff --git a/docs/src/content/docs/guides/vscode-plugin.mdx b/docs/src/content/docs/guides/vscode-plugin.mdx index 2a190a09..362d0062 100644 --- a/docs/src/content/docs/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/guides/vscode-plugin.mdx @@ -21,15 +21,15 @@ Agent plugins require **VS Code 1.110 (February 2026 release) or later**. Earlie The VS Code plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. ::: -### 1. Sign in +### 1. Log in to the Archgate platform -Sign in to obtain plugin access: +Log in to obtain plugin access: ```bash archgate login ``` -This starts an OAuth 2.0 Device Authorization Grant against the Archgate identity provider. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. ### 2. Initialize your project with the plugin diff --git a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx index c073a279..871c1d37 100644 --- a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx @@ -34,7 +34,7 @@ Pluginen legger til agenter og rollebaserte ferdigheter i Claude Code. Utviklera Claude Code-pluginen er for tiden i beta. Kjør `archgate login` for å registrere deg og autentisere. ::: -### 1. Logg inn +### 1. Logg inn på Archgate-plattformen Logg inn for å få tilgang til pluginene: @@ -42,7 +42,7 @@ Logg inn for å få tilgang til pluginene: archgate login ``` -Dette starter en OAuth 2.0 Device Authorization Grant mot Archgates identitetsleverandør. CLI-et viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden og autoriser. Når det er ferdig, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. +Dette starter en OAuth 2.0 Device Authorization Grant mot Archgate-plattformen. CLI-et viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden og autoriser. Når det er ferdig, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. ### 2. Initialiser prosjektet ditt med pluginen diff --git a/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx index 4afcb2c4..db69730c 100644 --- a/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx @@ -19,7 +19,7 @@ Installasjonen er deklarativ: CLI-en skriver markedsplass- og plugin-deklarasjon GitHub Copilot-pluginet er for øyeblikket i beta. Kjør `archgate login` for å registrere deg og autentisere før du følger trinnene nedenfor. ::: -### 1. Logg inn +### 1. Logg inn på Archgate-plattformen Logg inn for å få tilgang til pluginene: @@ -27,7 +27,7 @@ Logg inn for å få tilgang til pluginene: archgate login ``` -Dette starter en OAuth 2.0 Device Authorization Grant mot Archgates identitetsleverandør. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. +Dette starter en OAuth 2.0 Device Authorization Grant mot Archgate-plattformen. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. ### 2. Initialiser prosjektet ditt med pluginet diff --git a/docs/src/content/docs/nb/guides/vscode-plugin.mdx b/docs/src/content/docs/nb/guides/vscode-plugin.mdx index 28b4a805..7a19a36e 100644 --- a/docs/src/content/docs/nb/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/nb/guides/vscode-plugin.mdx @@ -21,7 +21,7 @@ Agent-plugins krever **VS Code 1.110 (februar 2026-utgivelsen) eller nyere**. Ti VS Code-pluginet er for øyeblikket i beta. Kjør `archgate login` for å registrere deg og autentisere før du følger trinnene nedenfor. ::: -### 1. Logg inn +### 1. Logg inn på Archgate-plattformen Logg inn for å få tilgang til pluginene: @@ -29,7 +29,7 @@ Logg inn for å få tilgang til pluginene: archgate login ``` -Dette starter en OAuth 2.0 Device Authorization Grant mot Archgates identitetsleverandør. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. +Dette starter en OAuth 2.0 Device Authorization Grant mot Archgate-plattformen. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. ### 2. Initialiser prosjektet ditt med pluginet diff --git a/docs/src/content/docs/nb/reference/cli/index.mdx b/docs/src/content/docs/nb/reference/cli/index.mdx index fc91dc55..2db2f4be 100644 --- a/docs/src/content/docs/nb/reference/cli/index.mdx +++ b/docs/src/content/docs/nb/reference/cli/index.mdx @@ -23,7 +23,7 @@ archgate --log-level debug check | Kommando | Beskrivelse | | ------------------------------------------------ | --------------------------------------------------------------- | -| [`archgate login`](./login/) | Logg inn for tilgang til editor-plugins | +| [`archgate login`](./login/) | Logg inn på Archgate-plattformen | | [`archgate init`](./init/) | Sett opp linting og regelhåndhevelse | | [`archgate plugin`](./plugin/) | Administrer editor-plugins | | [`archgate check`](./check/) | Kjør ADR-samsvarskontroller | diff --git a/docs/src/content/docs/nb/reference/cli/login.mdx b/docs/src/content/docs/nb/reference/cli/login.mdx index 9b9da848..ab4324c8 100644 --- a/docs/src/content/docs/nb/reference/cli/login.mdx +++ b/docs/src/content/docs/nb/reference/cli/login.mdx @@ -1,15 +1,15 @@ --- title: archgate login -description: "Logg inn for å få tilgang til Archgate editor-plugins." +description: "Logg inn på Archgate-plattformen." --- -Logg inn for å få tilgang til Archgate editor-plugins. +Logg inn på Archgate-plattformen. Innloggingen er det som gir tilgang til editor-pluginene. ```bash archgate login ``` -Starter en OAuth 2.0 Device Authorization Grant (RFC 8628) mot Archgates identitetsleverandør. CLI-en viser en engangskode og en URL. Åpne URL-en i nettleseren din, skriv inn koden og velg hvordan du vil logge inn. Når autorisasjonen er fullført, gir CLI-en tokenene til git via `git credential approve`, så hvor de havner avhenger av hvilken credential-helper du har konfigurert. En helper som bruker operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager, Linux libsecret) holder dem krypterte; gits innebygde `store`-helper skriver dem til `~/.git-credentials` i klartekst. Sjekk din med `git config --global credential.helper`. +Starter en OAuth 2.0 Device Authorization Grant (RFC 8628) mot Archgate-plattformen. CLI-en viser en engangskode og en URL. Åpne URL-en i nettleseren din, skriv inn koden og velg hvordan du vil logge inn. Når autorisasjonen er fullført, gir CLI-en tokenene til git via `git credential approve`, så hvor de havner avhenger av hvilken credential-helper du har konfigurert. En helper som bruker operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager, Linux libsecret) holder dem krypterte; gits innebygde `store`-helper skriver dem til `~/.git-credentials` i klartekst. Sjekk din med `git config --global credential.helper`. Tilgangstokener er kortlivede. CLI-en beholder et refresh-token ved siden av dem og fornyer tilgangstokenet automatisk, så det holder normalt å logge inn én gang. Hvis selve refresh-tokenet utløper eller blir tilbakekalt, faller CLI-en tilbake på et token utstedt før denne flyten fantes; finnes ingen, melder den at økten er utløpt, og `archgate login` gjenoppretter den. diff --git a/docs/src/content/docs/nb/reference/privacy-policy.mdx b/docs/src/content/docs/nb/reference/privacy-policy.mdx index 4384f322..79770b80 100644 --- a/docs/src/content/docs/nb/reference/privacy-policy.mdx +++ b/docs/src/content/docs/nb/reference/privacy-policy.mdx @@ -31,7 +31,7 @@ Data overføres via reverseproksier på `n.archgate.dev` (analyse) og `s.archgat ### Archgate Plugins Service -Når du logger inn via `archgate login`, autentiserer du med GitHub gjennom **Archgates identitetsleverandør** (`auth.archgate.dev`), som mottar profilen GitHub deler med den: ditt **navn**, din **e-postadresse**, ditt **GitHub-brukernavn** og din **avatar**. Dette brukes til å identifisere kontoen din og sende en velkomst-e-post. CLI-en spør deg selv om ingenting. På din maskin gis tokenene til den credential-helperen du har konfigurert; med en helper som bruker operativsystemet holdes de krypterte, mens gits innebygde `store`-helper skriver dem i klartekst. +Når du logger inn via `archgate login`, autentiserer du med GitHub gjennom **Archgate-plattformen** (`auth.archgate.dev`), som mottar profilen GitHub deler med den: ditt **navn**, din **e-postadresse**, ditt **GitHub-brukernavn** og din **avatar**. Dette brukes til å identifisere kontoen din og sende en velkomst-e-post. CLI-en spør deg selv om ingenting. På din maskin gis tokenene til den credential-helperen du har konfigurert; med en helper som bruker operativsystemet holdes de krypterte, mens gits innebygde `store`-helper skriver dem i klartekst. Ved å opprette en konto godtar du [tjenestevilkårene](https://archgate.dev/terms-of-service). Se den [fullstendige personvernerklæringen](https://archgate.dev/privacy-policy) for komplette detaljer. diff --git a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx index c7624a27..97133ed8 100644 --- a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx @@ -34,7 +34,7 @@ O agente `archgate:developer` é definido como agente padrão via `.claude/setti O plugin para Claude Code está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar. ::: -### 1. Entre +### 1. Entre na plataforma Archgate Entre para obter acesso aos plugins: @@ -42,7 +42,7 @@ Entre para obter acesso aos plugins: archgate login ``` -Isso inicia um OAuth 2.0 Device Authorization Grant no provedor de identidade do Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. +Isso inicia um OAuth 2.0 Device Authorization Grant na plataforma Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. ### 2. Inicialize seu projeto com o plugin diff --git a/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx index 0193956c..fe0afa86 100644 --- a/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx @@ -19,7 +19,7 @@ A instalação é declarativa: o CLI escreve a declaração do marketplace e do O plugin para GitHub Copilot está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar antes de seguir os passos abaixo. ::: -### 1. Entre +### 1. Entre na plataforma Archgate Entre para obter acesso aos plugins: @@ -27,7 +27,7 @@ Entre para obter acesso aos plugins: archgate login ``` -Isso inicia um OAuth 2.0 Device Authorization Grant no provedor de identidade do Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. +Isso inicia um OAuth 2.0 Device Authorization Grant na plataforma Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. ### 2. Inicialize seu projeto com o plugin diff --git a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx index f9dc268e..aa7fa82b 100644 --- a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx @@ -21,7 +21,7 @@ Plugins de agente requerem **VS Code 1.110 (lançamento de fevereiro de 2026) ou O plugin para VS Code está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar antes de seguir os passos abaixo. ::: -### 1. Entre +### 1. Entre na plataforma Archgate Entre para obter acesso aos plugins: @@ -29,7 +29,7 @@ Entre para obter acesso aos plugins: archgate login ``` -Isso inicia um OAuth 2.0 Device Authorization Grant no provedor de identidade do Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. +Isso inicia um OAuth 2.0 Device Authorization Grant na plataforma Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. ### 2. Inicialize seu projeto com o plugin diff --git a/docs/src/content/docs/pt-br/reference/cli/index.mdx b/docs/src/content/docs/pt-br/reference/cli/index.mdx index 7b5115e6..e9784f70 100644 --- a/docs/src/content/docs/pt-br/reference/cli/index.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/index.mdx @@ -23,7 +23,7 @@ archgate --log-level debug check | Comando | Descrição | | ------------------------------------------------ | -------------------------------------------------------- | -| [`archgate login`](./login/) | Entre para acessar os plugins de editor | +| [`archgate login`](./login/) | Entre na plataforma Archgate | | [`archgate init`](./init/) | Configurar linting e aplicação de regras | | [`archgate plugin`](./plugin/) | Gerenciar plugins de editor | | [`archgate check`](./check/) | Executar verificações de conformidade com ADRs | diff --git a/docs/src/content/docs/pt-br/reference/cli/login.mdx b/docs/src/content/docs/pt-br/reference/cli/login.mdx index 5cf1139d..bc67ca2d 100644 --- a/docs/src/content/docs/pt-br/reference/cli/login.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/login.mdx @@ -1,15 +1,15 @@ --- title: archgate login -description: "Entre para acessar os plugins de editor do Archgate." +description: "Entre na plataforma Archgate." --- -Entre para acessar os plugins de editor do Archgate. +Entre na plataforma Archgate. É o login que concede acesso aos plugins de editor. ```bash archgate login ``` -Inicia um OAuth 2.0 Device Authorization Grant (RFC 8628) no provedor de identidade do Archgate. A CLI exibe um código de uso único e uma URL. Abra a URL no seu navegador, insira o código e escolha como deseja entrar. Após a autorização, a CLI entrega os tokens ao git via `git credential approve`, então onde eles ficam depende do credential helper que você tem configurado. Um helper apoiado no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager, libsecret no Linux) os mantém criptografados; o helper `store` embutido do git os grava em `~/.git-credentials` em texto puro. Verifique o seu com `git config --global credential.helper`. +Inicia um OAuth 2.0 Device Authorization Grant (RFC 8628) na plataforma Archgate. A CLI exibe um código de uso único e uma URL. Abra a URL no seu navegador, insira o código e escolha como deseja entrar. Após a autorização, a CLI entrega os tokens ao git via `git credential approve`, então onde eles ficam depende do credential helper que você tem configurado. Um helper apoiado no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager, libsecret no Linux) os mantém criptografados; o helper `store` embutido do git os grava em `~/.git-credentials` em texto puro. Verifique o seu com `git config --global credential.helper`. Os tokens de acesso têm vida curta. A CLI mantém um refresh token junto deles e renova o token de acesso automaticamente, então normalmente basta entrar uma vez. Se o próprio refresh token expirar ou for revogado, a CLI recorre a um token emitido antes deste fluxo existir; se não houver nenhum, informa que a sessão expirou, e o `archgate login` a restabelece. diff --git a/docs/src/content/docs/pt-br/reference/privacy-policy.mdx b/docs/src/content/docs/pt-br/reference/privacy-policy.mdx index f673af61..42a92aeb 100644 --- a/docs/src/content/docs/pt-br/reference/privacy-policy.mdx +++ b/docs/src/content/docs/pt-br/reference/privacy-policy.mdx @@ -17,7 +17,7 @@ O Archgate coleta **análises de uso anônimas** (via PostHog) e **relatórios d ### Serviço de Plugins do Archgate -Quando você entra via `archgate login`, você se autentica com o GitHub através do **provedor de identidade do Archgate** (`auth.archgate.dev`), que recebe o perfil que o GitHub compartilha com ele: seu **nome**, **endereço de email**, **nome de usuário GitHub** e **avatar**. Isso é usado para identificar sua conta e enviar um email de boas-vindas. A própria CLI não pede nada a você. Na sua máquina, os tokens resultantes são entregues ao credential helper do git que você configurou; com um helper apoiado no sistema operacional eles ficam criptografados, enquanto o helper `store` embutido do git os grava em texto puro. Consulte a [política de privacidade completa](https://archgate.dev/privacy-policy) para detalhes completos. +Quando você entra via `archgate login`, você se autentica com o GitHub através da **plataforma Archgate** (`auth.archgate.dev`), que recebe o perfil que o GitHub compartilha com ele: seu **nome**, **endereço de email**, **nome de usuário GitHub** e **avatar**. Isso é usado para identificar sua conta e enviar um email de boas-vindas. A própria CLI não pede nada a você. Na sua máquina, os tokens resultantes são entregues ao credential helper do git que você configurou; com um helper apoiado no sistema operacional eles ficam criptografados, enquanto o helper `store` embutido do git os grava em texto puro. Consulte a [política de privacidade completa](https://archgate.dev/privacy-policy) para detalhes completos. ### Como desativar diff --git a/docs/src/content/docs/reference/cli/index.mdx b/docs/src/content/docs/reference/cli/index.mdx index c3c9d064..86b38400 100644 --- a/docs/src/content/docs/reference/cli/index.mdx +++ b/docs/src/content/docs/reference/cli/index.mdx @@ -23,7 +23,7 @@ archgate --log-level debug check | Command | Description | | ------------------------------------------------ | ----------------------------------------------------- | -| [`archgate login`](./login/) | Sign in to access editor plugins | +| [`archgate login`](./login/) | Log in to the Archgate platform | | [`archgate init`](./init/) | Set up linting and rules enforcement | | [`archgate plugin`](./plugin/) | Manage editor plugins | | [`archgate check`](./check/) | Run ADR compliance checks | diff --git a/docs/src/content/docs/reference/cli/login.mdx b/docs/src/content/docs/reference/cli/login.mdx index 6385e555..a4e6573f 100644 --- a/docs/src/content/docs/reference/cli/login.mdx +++ b/docs/src/content/docs/reference/cli/login.mdx @@ -1,15 +1,15 @@ --- title: archgate login -description: "Sign in to access Archgate editor plugins." +description: "Log in to the Archgate platform." --- -Sign in to access Archgate editor plugins. +Log in to the Archgate platform. Logging in is what grants access to the editor plugins. ```bash archgate login ``` -Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate identity provider. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. +Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate platform. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI falls back to any token issued before this flow existed; when none remains it reports that the session has expired, and `archgate login` re-establishes it. diff --git a/docs/src/content/docs/reference/privacy-policy.mdx b/docs/src/content/docs/reference/privacy-policy.mdx index ad05c7d7..1a3e44db 100644 --- a/docs/src/content/docs/reference/privacy-policy.mdx +++ b/docs/src/content/docs/reference/privacy-policy.mdx @@ -31,7 +31,7 @@ Data is transmitted via reverse proxies at `n.archgate.dev` (analytics) and `s.a ### Archgate Plugins Service -When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate identity provider** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing. On your machine, the resulting tokens are handed to your configured git credential helper; with an OS-backed helper they are kept encrypted, while git's built-in `store` helper writes them in plain text. +When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate platform** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing. On your machine, the resulting tokens are handed to your configured git credential helper; with an OS-backed helper they are kept encrypted, while git's built-in `store` helper writes them in plain text. By creating an account, you agree to the [Terms of Service](https://archgate.dev/terms-of-service). See the [full privacy policy](https://archgate.dev/privacy-policy) for complete details. diff --git a/src/commands/init.ts b/src/commands/init.ts index f242692f..c2500699 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -94,7 +94,7 @@ export function registerInitCommand(program: Command) { type: "confirm", name: "wantPlugin", message: - "Would you like to install the Archgate editor plugin? (requires sign-in)", + "Would you like to install the Archgate editor plugin? (requires logging in to the Archgate platform)", default: true, }, ]) diff --git a/src/commands/login.ts b/src/commands/login.ts index 370551e6..8c65b3f7 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -17,7 +17,7 @@ import { UserError } from "../helpers/user-error"; export function registerLoginCommand(program: Command) { const login = program .command("login") - .description("Sign in to access archgate plugins"); + .description("Log in to the Archgate platform"); login.action(async () => { try { diff --git a/src/helpers/logto-auth.ts b/src/helpers/logto-auth.ts index 3cfd3133..1ac4c72b 100644 --- a/src/helpers/logto-auth.ts +++ b/src/helpers/logto-auth.ts @@ -3,7 +3,7 @@ /** * logto-auth.ts — Logto device authorization and token refresh. * - * Implements RFC 8628 against the Archgate identity provider. Access tokens + * Implements RFC 8628 against the Archgate platform. Access tokens * are short-lived; the refresh token obtained via `offline_access` is what * survives, and {@link refreshAccessToken} trades it for a new pair. */ @@ -96,7 +96,7 @@ export type TokenSetWithIdentity = z.infer; * Start a device authorization request. * * @returns The user code and verification URI to show the user. - * @throws {UserError} When the identity provider rejects the request. + * @throws {UserError} When the Archgate platform rejects the request. */ export async function requestDeviceCode(): Promise { logDebug("Requesting Logto device code from:", DEVICE_AUTH_URL); @@ -271,7 +271,7 @@ export function isExpired(expiresAt: number): boolean { /** * Read a JSON body without letting a non-JSON one escape as an internal error. * - * An identity-provider outage answers with an HTML error page from a proxy, + * An Archgate platform outage answers with an HTML error page from a proxy, * where `response.json()` rejects with a `SyntaxError` — not a `UserError`, so * it would surface as an internal fault and reach Sentry. * From b721ad431e3ad80ae2a1e75311847c65505f7227 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 00:07:02 +0200 Subject: [PATCH 09/27] feat(auth): open the browser and copy the device code on login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login opens the browser at the verification URI the platform returns with the code already embedded, so there is usually nothing to type, and copies the code to the clipboard for pages that still ask for it. Both are conveniences and neither is required. The URL and the code are printed either way — the browser may not open, and the user may be reading the terminal on a different machine than the one with the browser. Auto-open and copy are skipped entirely under CI, over SSH, and on Linux with no display server. Opening a browser at the far end of an SSH connection helps nobody, and a clipboard write there would fail noisily for no gain. WSL is exempt from the display check because it reaches the Windows desktop. Launchers are tried in order per platform, each with a timeout, so a missing or hanging one falls through instead of blocking login: `open` on macOS, `wslview` then PowerShell then cmd on WSL, `cmd /c start` on Windows, and `xdg-open` then `gio` elsewhere. Clipboards likewise: `pbcopy`, `clip.exe`, or `wl-copy`/`xclip`/`xsel`. The code goes to the tool on standard input rather than as an argument, so it never appears in the process list. No new dependency: this is `Bun.spawn` with argv arrays (ARCH-006, ARCH-007) and the existing platform helpers (ARCH-009). Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 8 +- .../content/docs/nb/reference/cli/login.mdx | 8 +- .../docs/pt-br/reference/cli/login.mdx | 8 +- docs/src/content/docs/reference/cli/login.mdx | 8 +- src/helpers/desktop.ts | 144 ++++++++++ src/helpers/login-flow.ts | 29 +- tests/helpers/desktop.test.ts | 271 ++++++++++++++++++ tests/helpers/login-flow.test.ts | 72 +++++ 8 files changed, 530 insertions(+), 18 deletions(-) create mode 100644 src/helpers/desktop.ts create mode 100644 tests/helpers/desktop.test.ts diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 18b23d4f..670d43c0 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4718,7 +4718,9 @@ Log in to the Archgate platform. Logging in is what grants access to the editor archgate login ``` -Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate platform. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. +Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate platform. The CLI opens your browser at a URL that already carries the one-time code, and copies that code to your clipboard in case the page asks for it. Both the URL and the code are printed as well, so you can finish on another machine or paste them yourself. + +Neither step is required. In CI, over SSH, or on a host with no display server the CLI skips both and just prints them. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI falls back to any token issued before this flow existed; when none remains it reports that the session has expired, and `archgate login` re-establishes it. @@ -4749,8 +4751,8 @@ archgate login By signing in, you agree to the Archgate Terms of Service: https://archgate.dev/terms-of-service -Open https://auth.archgate.dev/device in your browser -and enter the code: HZML-HXLB +Opened https://auth.archgate.dev/device?user_code=HZML-HXLB in your browser. +If it asks for a code, enter: HZML-HXLB (copied to your clipboard) Waiting for authorization... info: Authenticated as yourname. Plugin access is now available. diff --git a/docs/src/content/docs/nb/reference/cli/login.mdx b/docs/src/content/docs/nb/reference/cli/login.mdx index ab4324c8..6b3a8289 100644 --- a/docs/src/content/docs/nb/reference/cli/login.mdx +++ b/docs/src/content/docs/nb/reference/cli/login.mdx @@ -9,7 +9,9 @@ Logg inn på Archgate-plattformen. Innloggingen er det som gir tilgang til edito archgate login ``` -Starter en OAuth 2.0 Device Authorization Grant (RFC 8628) mot Archgate-plattformen. CLI-en viser en engangskode og en URL. Åpne URL-en i nettleseren din, skriv inn koden og velg hvordan du vil logge inn. Når autorisasjonen er fullført, gir CLI-en tokenene til git via `git credential approve`, så hvor de havner avhenger av hvilken credential-helper du har konfigurert. En helper som bruker operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager, Linux libsecret) holder dem krypterte; gits innebygde `store`-helper skriver dem til `~/.git-credentials` i klartekst. Sjekk din med `git config --global credential.helper`. +Starter en OAuth 2.0 Device Authorization Grant (RFC 8628) mot Archgate-plattformen. CLI-en åpner nettleseren på en URL som allerede inneholder engangskoden, og kopierer koden til utklippstavlen i tilfelle siden spør etter den. Både URL-en og koden skrives også ut, så du kan fullføre på en annen maskin eller lime dem inn selv. + +Ingen av delene er påkrevd. I CI, over SSH eller på en maskin uten skjermserver hopper CLI-en over begge og skriver dem bare ut. Når autorisasjonen er fullført, gir CLI-en tokenene til git via `git credential approve`, så hvor de havner avhenger av hvilken credential-helper du har konfigurert. En helper som bruker operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager, Linux libsecret) holder dem krypterte; gits innebygde `store`-helper skriver dem til `~/.git-credentials` i klartekst. Sjekk din med `git config --global credential.helper`. Tilgangstokener er kortlivede. CLI-en beholder et refresh-token ved siden av dem og fornyer tilgangstokenet automatisk, så det holder normalt å logge inn én gang. Hvis selve refresh-tokenet utløper eller blir tilbakekalt, faller CLI-en tilbake på et token utstedt før denne flyten fantes; finnes ingen, melder den at økten er utløpt, og `archgate login` gjenoppretter den. @@ -42,8 +44,8 @@ archgate login By signing in, you agree to the Archgate Terms of Service: https://archgate.dev/terms-of-service -Open https://auth.archgate.dev/device in your browser -and enter the code: HZML-HXLB +Opened https://auth.archgate.dev/device?user_code=HZML-HXLB in your browser. +If it asks for a code, enter: HZML-HXLB (copied to your clipboard) Waiting for authorization... info: Authenticated as yourname. Plugin access is now available. diff --git a/docs/src/content/docs/pt-br/reference/cli/login.mdx b/docs/src/content/docs/pt-br/reference/cli/login.mdx index bc67ca2d..fc6c337f 100644 --- a/docs/src/content/docs/pt-br/reference/cli/login.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/login.mdx @@ -9,7 +9,9 @@ Entre na plataforma Archgate. É o login que concede acesso aos plugins de edito archgate login ``` -Inicia um OAuth 2.0 Device Authorization Grant (RFC 8628) na plataforma Archgate. A CLI exibe um código de uso único e uma URL. Abra a URL no seu navegador, insira o código e escolha como deseja entrar. Após a autorização, a CLI entrega os tokens ao git via `git credential approve`, então onde eles ficam depende do credential helper que você tem configurado. Um helper apoiado no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager, libsecret no Linux) os mantém criptografados; o helper `store` embutido do git os grava em `~/.git-credentials` em texto puro. Verifique o seu com `git config --global credential.helper`. +Inicia um OAuth 2.0 Device Authorization Grant (RFC 8628) na plataforma Archgate. A CLI abre seu navegador em uma URL que já contém o código de uso único e copia esse código para a área de transferência, caso a página o solicite. A URL e o código também são exibidos, então você pode concluir em outra máquina ou colá-los manualmente. + +Nenhuma das duas etapas é obrigatória. Em CI, via SSH ou em uma máquina sem servidor gráfico, a CLI pula ambas e apenas os exibe. Após a autorização, a CLI entrega os tokens ao git via `git credential approve`, então onde eles ficam depende do credential helper que você tem configurado. Um helper apoiado no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager, libsecret no Linux) os mantém criptografados; o helper `store` embutido do git os grava em `~/.git-credentials` em texto puro. Verifique o seu com `git config --global credential.helper`. Os tokens de acesso têm vida curta. A CLI mantém um refresh token junto deles e renova o token de acesso automaticamente, então normalmente basta entrar uma vez. Se o próprio refresh token expirar ou for revogado, a CLI recorre a um token emitido antes deste fluxo existir; se não houver nenhum, informa que a sessão expirou, e o `archgate login` a restabelece. @@ -42,8 +44,8 @@ archgate login By signing in, you agree to the Archgate Terms of Service: https://archgate.dev/terms-of-service -Open https://auth.archgate.dev/device in your browser -and enter the code: HZML-HXLB +Opened https://auth.archgate.dev/device?user_code=HZML-HXLB in your browser. +If it asks for a code, enter: HZML-HXLB (copied to your clipboard) Waiting for authorization... info: Authenticated as yourname. Plugin access is now available. diff --git a/docs/src/content/docs/reference/cli/login.mdx b/docs/src/content/docs/reference/cli/login.mdx index a4e6573f..bc31136a 100644 --- a/docs/src/content/docs/reference/cli/login.mdx +++ b/docs/src/content/docs/reference/cli/login.mdx @@ -9,7 +9,9 @@ Log in to the Archgate platform. Logging in is what grants access to the editor archgate login ``` -Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate platform. The CLI displays a one-time code and a URL. Open the URL in your browser, enter the code, and choose how you want to sign in. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. +Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate platform. The CLI opens your browser at a URL that already carries the one-time code, and copies that code to your clipboard in case the page asks for it. Both the URL and the code are printed as well, so you can finish on another machine or paste them yourself. + +Neither step is required. In CI, over SSH, or on a host with no display server the CLI skips both and just prints them. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI falls back to any token issued before this flow existed; when none remains it reports that the session has expired, and `archgate login` re-establishes it. @@ -42,8 +44,8 @@ archgate login By signing in, you agree to the Archgate Terms of Service: https://archgate.dev/terms-of-service -Open https://auth.archgate.dev/device in your browser -and enter the code: HZML-HXLB +Opened https://auth.archgate.dev/device?user_code=HZML-HXLB in your browser. +If it asks for a code, enter: HZML-HXLB (copied to your clipboard) Waiting for authorization... info: Authenticated as yourname. Plugin access is now available. diff --git a/src/helpers/desktop.ts b/src/helpers/desktop.ts new file mode 100644 index 00000000..e52e5fe6 --- /dev/null +++ b/src/helpers/desktop.ts @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * desktop.ts — open a URL in the browser and copy text to the clipboard. + * + * Both are conveniences: every caller must still print what it would have + * opened or copied, because a headless host, an SSH session or a container + * has neither a browser nor a clipboard. + */ + +import { isLinux, isMacOS, isWindows, isWSL } from "./platform"; + +/** Give a launcher this long before deciding it is not going to answer. */ +const SPAWN_TIMEOUT_MS = 3_000; + +/** + * True when the process has no desktop session to open a browser into. + * + * CI and SSH are treated as headless even on a platform that would otherwise + * qualify: opening a browser on the far end of an SSH connection helps nobody. + * On Linux a session needs a display server; WSL is exempt because it reaches + * the Windows desktop through its own launcher. + */ +export function isHeadless(): boolean { + const set = (name: string): boolean => { + const value = Bun.env[name]; + return value !== undefined && value !== ""; + }; + + if (set("CI")) return true; + if (set("SSH_CONNECTION") || set("SSH_TTY")) return true; + if (isLinux() && !isWSL()) { + return !set("DISPLAY") && !set("WAYLAND_DISPLAY"); + } + return false; +} + +/** + * Run a command, resolving false rather than throwing when it is unavailable. + * + * Streams are ignored rather than piped: nothing reads them, and an unread + * pipe can fill and block the child (ARCH-007). + * + * @param command - Argv array. + * @param stdin - Optional payload written to the process's standard input. + */ +async function run(command: string[], stdin?: string): Promise { + try { + const proc = Bun.spawn(command, { + stdin: stdin === undefined ? "ignore" : new Blob([stdin]), + stdout: "ignore", + stderr: "ignore", + }); + + let timer: ReturnType | undefined; + const exited = await Promise.race([ + proc.exited, + new Promise<"timeout">((resolve) => { + timer = setTimeout(() => { + resolve("timeout"); + }, SPAWN_TIMEOUT_MS); + }), + ]).finally(() => { + if (timer) clearTimeout(timer); + }); + + if (exited === "timeout") { + proc.kill(); + return false; + } + return exited === 0; + } catch { + return false; + } +} + +/** Browser launchers to try, in order, for the current platform. */ +function browserCommands(url: string): string[][] { + if (isMacOS()) return [["open", url]]; + // WSL reaches the Windows desktop; wslview ships with wslu, and the two + // PowerShell binaries cover distributions that lack it. + if (isWSL()) { + return [ + ["wslview", url], + ["powershell.exe", "-NoProfile", "-Command", "Start-Process", url], + ["cmd.exe", "/c", "start", "", url], + ]; + } + if (isWindows()) { + // The empty string is `start`'s title argument; without it a quoted URL + // is taken as the window title and no browser opens. + return [["cmd", "/c", "start", "", url]]; + } + return [ + ["xdg-open", url], + ["gio", "open", url], + ]; +} + +/** Clipboard writers to try, in order, for the current platform. */ +function clipboardCommands(): string[][] { + if (isMacOS()) return [["pbcopy"]]; + if (isWSL() || isWindows()) return [["clip.exe"], ["clip"]]; + return [ + ["wl-copy"], + ["xclip", "-selection", "clipboard"], + ["xsel", "--clipboard", "--input"], + ]; +} + +/** + * Open a URL in the user's browser. + * + * @param url - Absolute URL to open. + * @returns `true` when a launcher accepted it; `false` when headless or none + * of the platform's launchers are installed. + */ +export async function openBrowser(url: string): Promise { + if (isHeadless()) return false; + + /* oxlint-disable no-await-in-loop -- launchers are tried in order */ + for (const command of browserCommands(url)) { + if (await run(command)) return true; + } + /* oxlint-enable no-await-in-loop */ + return false; +} + +/** + * Copy text to the system clipboard. + * + * @param text - Text to place on the clipboard. + * @returns `true` when a clipboard tool accepted it. + */ +export async function copyToClipboard(text: string): Promise { + if (isHeadless()) return false; + + /* oxlint-disable no-await-in-loop -- writers are tried in order */ + for (const command of clipboardCommands()) { + if (await run(command, text)) return true; + } + /* oxlint-enable no-await-in-loop */ + return false; +} diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index 74c02474..004f2312 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -7,6 +7,7 @@ import { styleText } from "node:util"; import { saveTokenSet } from "./credential-store"; +import { copyToClipboard, openBrowser } from "./desktop"; import { registerGitCredentialHelper } from "./git-credential-config"; import { logDebug, logInfo, logWarn } from "./log"; import { @@ -38,12 +39,28 @@ export async function runLoginFlow(): Promise { logDebug("Starting Logto device flow"); const deviceCode = await requestDeviceCode(); - console.log( - `Open ${styleText("bold", deviceCode.verification_uri)} in your browser` - ); - console.log( - `and enter the code: ${styleText(["bold", "green"], deviceCode.user_code)}\n` - ); + const code = styleText(["bold", "green"], deviceCode.user_code); + const copied = await copyToClipboard(deviceCode.user_code); + + // The complete URI carries the code as a query parameter, so a browser that + // opens it leaves nothing to type. It is always printed as well: the browser + // may not open, and the user may be reading this on another machine. + const target = + deviceCode.verification_uri_complete ?? deviceCode.verification_uri; + const opened = await openBrowser(target); + + if (opened) { + console.log(`Opened ${styleText("bold", target)} in your browser.`); + console.log( + `If it asks for a code, enter: ${code}${copied ? " (copied to your clipboard)" : ""}\n` + ); + } else { + console.log(`Open ${styleText("bold", target)} in your browser`); + console.log( + `and enter the code: ${code}${copied ? " (copied to your clipboard)" : ""}\n` + ); + } + console.log("Waiting for authorization..."); const { tokens, idToken } = await pollForTokens( diff --git a/tests/helpers/desktop.test.ts b/tests/helpers/desktop.test.ts new file mode 100644 index 00000000..acb578f0 --- /dev/null +++ b/tests/helpers/desktop.test.ts @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { + afterEach, + beforeEach, + describe, + expect, + type Mock, + spyOn, + test, +} from "bun:test"; + +import { + copyToClipboard, + isHeadless, + openBrowser, +} from "../../src/helpers/desktop"; +import * as platform from "../../src/helpers/platform"; +import { restoreEnv } from "../test-utils"; + +let spawnSpy: Mock; +let platformSpies: Mock<() => boolean>[]; + +/** Env vars isHeadless reads, saved and restored around every test. */ +const ENV_KEYS = [ + "CI", + "SSH_CONNECTION", + "SSH_TTY", + "DISPLAY", + "WAYLAND_DISPLAY", +]; +let originalEnv: Record; + +/** Pin the platform predicates so a test does not depend on the host. */ +function onPlatform(which: "macos" | "windows" | "wsl" | "linux"): void { + platformSpies = [ + spyOn(platform, "isMacOS").mockReturnValue(which === "macos"), + spyOn(platform, "isWindows").mockReturnValue(which === "windows"), + spyOn(platform, "isWSL").mockReturnValue(which === "wsl"), + spyOn(platform, "isLinux").mockReturnValue(which === "linux"), + ]; +} + +/** Stub Bun.spawn so the nth attempt exits with the given code. */ +function spawnExits(...codes: number[]): void { + // Replacing an existing spy would nest one mock inside another. + spawnSpy.mockRestore(); + let call = 0; + spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + const code = codes[call] ?? 1; + call += 1; + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + return { + exited: Promise.resolve(code), + kill: () => { + // Nothing to kill: the stub has already settled. + }, + } as unknown as ReturnType; + }); +} + +/** Argv of each recorded spawn, in call order. */ +function captured(): string[][] { + return spawnSpy.mock.calls.map((call) => [...call[0]]); +} + +/** The `stdin` option passed to the nth spawn. */ +function stdinOf(index: number): unknown { + return (spawnSpy.mock.calls[index]?.[1] as { stdin?: unknown } | undefined) + ?.stdin; +} + +beforeEach(() => { + platformSpies = []; + // Installed unconditionally so no test can reach the real Bun.spawn, and so + // afterEach always has a spy to restore. + spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("unexpected spawn"); + }); + originalEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, Bun.env[k]])); + for (const key of ENV_KEYS) delete Bun.env[key]; + // A desktop Linux session by default; individual tests override. + Bun.env.DISPLAY = ":0"; + onPlatform("linux"); +}); + +afterEach(() => { + for (const spy of platformSpies) spy.mockRestore(); + spawnSpy.mockRestore(); + for (const key of ENV_KEYS) restoreEnv(key, originalEnv[key]); +}); + +describe("isHeadless", () => { + test.each(["CI", "SSH_CONNECTION", "SSH_TTY"])( + "treats %s as headless", + (key) => { + Bun.env[key] = "1"; + + expect(isHeadless()).toBe(true); + } + ); + + // An empty value is how a shell reports "unset" for an exported-but-blank var. + test("ignores an empty value", () => { + Bun.env.CI = ""; + + expect(isHeadless()).toBe(false); + }); + + test.each([ + ["DISPLAY", ":0"], + ["WAYLAND_DISPLAY", "wayland-0"], + ])("a Linux session with %s is not headless", (key, value) => { + delete Bun.env.DISPLAY; + Bun.env[key] = value; + + expect(isHeadless()).toBe(false); + }); + + test("Linux without any display server is headless", () => { + delete Bun.env.DISPLAY; + + expect(isHeadless()).toBe(true); + }); + + // WSL has no display server of its own but reaches the Windows desktop. + test("WSL is not headless despite having no display", () => { + delete Bun.env.DISPLAY; + for (const spy of platformSpies) spy.mockRestore(); + onPlatform("wsl"); + + expect(isHeadless()).toBe(false); + }); + + test.each(["macos", "windows"] as const)("%s is not headless", (which) => { + for (const spy of platformSpies) spy.mockRestore(); + onPlatform(which); + + expect(isHeadless()).toBe(false); + }); +}); + +describe("openBrowser", () => { + test.each([ + ["macos", ["open", "https://auth.archgate.dev/device"]], + ["linux", ["xdg-open", "https://auth.archgate.dev/device"]], + ["wsl", ["wslview", "https://auth.archgate.dev/device"]], + ] as const)("uses the %s launcher", async (which, expected) => { + for (const spy of platformSpies) spy.mockRestore(); + onPlatform(which); + spawnExits(0); + + expect(await openBrowser("https://auth.archgate.dev/device")).toBe(true); + expect(captured()[0]).toEqual([...expected]); + }); + + // `start`'s first quoted argument is the window title, so it must be empty + // or the URL is consumed as a title and no browser opens. + test("passes an empty title to Windows start", async () => { + for (const spy of platformSpies) spy.mockRestore(); + onPlatform("windows"); + spawnExits(0); + + await openBrowser("https://auth.archgate.dev/device"); + + expect(captured()[0]).toEqual([ + "cmd", + "/c", + "start", + "", + "https://auth.archgate.dev/device", + ]); + }); + + test("falls through to the next launcher when the first is missing", async () => { + spawnExits(1, 0); + + expect(await openBrowser("https://auth.archgate.dev/device")).toBe(true); + expect(captured()).toHaveLength(2); + expect(captured()[1]?.[0]).toBe("gio"); + }); + + test("returns false when no launcher works", async () => { + spawnExits(1, 1); + + expect(await openBrowser("https://auth.archgate.dev/device")).toBe(false); + }); + + test("spawns nothing when headless", async () => { + Bun.env.CI = "1"; + spawnExits(0); + + expect(await openBrowser("https://auth.archgate.dev/device")).toBe(false); + expect(captured()).toHaveLength(0); + }); + + // A launcher that never exits must not hang login behind it. + test("gives up on a launcher that never exits", async () => { + let killed = false; + spawnSpy.mockRestore(); + spawnSpy = spyOn(Bun, "spawn").mockImplementation( + () => + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + ({ + exited: new Promise(() => { + // never settles + }), + kill: () => { + killed = true; + }, + }) as unknown as ReturnType + ); + + expect(await openBrowser("https://auth.archgate.dev/device")).toBe(false); + expect(killed).toBe(true); + }, 20_000); + + test("returns false when spawning throws", async () => { + // The default stub from beforeEach already throws. + expect(await openBrowser("https://auth.archgate.dev/device")).toBe(false); + }); +}); + +describe("copyToClipboard", () => { + test.each([ + ["macos", "pbcopy"], + ["linux", "wl-copy"], + ["wsl", "clip.exe"], + ["windows", "clip.exe"], + ] as const)("uses the %s clipboard tool", async (which, expected) => { + for (const spy of platformSpies) spy.mockRestore(); + onPlatform(which); + spawnExits(0); + + expect(await copyToClipboard("HZML-HXLB")).toBe(true); + expect(captured()[0]?.[0]).toBe(expected); + }); + + test("writes the text on standard input, never as an argument", async () => { + spawnExits(0); + + await copyToClipboard("HZML-HXLB"); + + expect(captured()[0]).not.toContain("HZML-HXLB"); + const stdin = stdinOf(0); + expect(stdin).toBeInstanceOf(Blob); + expect(stdin instanceof Blob ? await stdin.text() : null).toBe("HZML-HXLB"); + }); + + test("falls through the Linux clipboard tools in order", async () => { + spawnExits(1, 1, 0); + + expect(await copyToClipboard("HZML-HXLB")).toBe(true); + expect(captured().map((c) => c[0])).toEqual(["wl-copy", "xclip", "xsel"]); + }); + + test("returns false when headless", async () => { + delete Bun.env.DISPLAY; + spawnExits(0); + + expect(await copyToClipboard("HZML-HXLB")).toBe(false); + expect(captured()).toHaveLength(0); + }); + + test("returns false when every clipboard tool is missing", async () => { + spawnExits(1, 1, 1); + + expect(await copyToClipboard("HZML-HXLB")).toBe(false); + expect(captured()).toHaveLength(3); + }); +}); diff --git a/tests/helpers/login-flow.test.ts b/tests/helpers/login-flow.test.ts index 4b01f664..9daea64e 100644 --- a/tests/helpers/login-flow.test.ts +++ b/tests/helpers/login-flow.test.ts @@ -11,6 +11,7 @@ import { } from "bun:test"; import * as credMod from "../../src/helpers/credential-store"; +import * as desktopMod from "../../src/helpers/desktop"; import * as gitConfigMod from "../../src/helpers/git-credential-config"; import { runLoginFlow } from "../../src/helpers/login-flow"; import * as logtoMod from "../../src/helpers/logto-auth"; @@ -22,6 +23,8 @@ let mockRequestDeviceCode: Mock; let mockPollForTokens: Mock; let mockSaveTokenSet: Mock; let mockRegisterHelper: Mock; +let mockOpenBrowser: Mock; +let mockCopyToClipboard: Mock; let logSpy: Mock; const DEVICE_CODE = { @@ -58,6 +61,10 @@ beforeEach(() => { gitConfigMod, "registerGitCredentialHelper" ).mockResolvedValue(true); + mockOpenBrowser = spyOn(desktopMod, "openBrowser").mockResolvedValue(false); + mockCopyToClipboard = spyOn(desktopMod, "copyToClipboard").mockResolvedValue( + false + ); logSpy = spyOn(console, "log").mockImplementation(() => {}); }); @@ -66,6 +73,8 @@ afterEach(() => { mockPollForTokens.mockRestore(); mockSaveTokenSet.mockRestore(); mockRegisterHelper.mockRestore(); + mockOpenBrowser.mockRestore(); + mockCopyToClipboard.mockRestore(); logSpy.mockRestore(); }); @@ -140,6 +149,69 @@ describe("runLoginFlow", () => { ); }); + test("opens the URL that already carries the code", async () => { + mockPollForTokens.mockResolvedValue({ + tokens: TOKENS, + idToken: idTokenFor({ sub: "usr_1", username: "octocat" }), + }); + mockRequestDeviceCode.mockResolvedValue({ + ...DEVICE_CODE, + verification_uri_complete: + "https://auth.archgate.dev/device?user_code=HZML-HXLB", + }); + + await runLoginFlow(); + + expect(mockOpenBrowser).toHaveBeenCalledWith( + "https://auth.archgate.dev/device?user_code=HZML-HXLB" + ); + }); + + test("falls back to the plain URL when the provider omits the complete one", async () => { + await runLoginFlow(); + + expect(mockOpenBrowser).toHaveBeenCalledWith( + "https://auth.archgate.dev/device" + ); + }); + + test("copies the user code to the clipboard", async () => { + await runLoginFlow(); + + expect(mockCopyToClipboard).toHaveBeenCalledWith("HZML-HXLB"); + }); + + // The URL and code are printed either way: the browser may not have opened, + // and the user may be reading this on a different machine. + test.each([true, false])( + "prints the URL and code when opened=%p", + async (opened) => { + mockOpenBrowser.mockResolvedValue(opened); + + await runLoginFlow(); + + const printed = logSpy.mock.calls.flat().join("\n"); + expect(printed).toContain("https://auth.archgate.dev/device"); + expect(printed).toContain("HZML-HXLB"); + } + ); + + test("mentions the clipboard only when the copy succeeded", async () => { + mockCopyToClipboard.mockResolvedValue(true); + + await runLoginFlow(); + + expect(logSpy.mock.calls.flat().join("\n")).toContain( + "copied to your clipboard" + ); + }); + + test("says nothing about the clipboard when the copy failed", async () => { + await runLoginFlow(); + + expect(logSpy.mock.calls.flat().join("\n")).not.toContain("clipboard"); + }); + test("propagates a failure from the device authorization request", async () => { mockRequestDeviceCode.mockRejectedValue(new Error("network down")); From ea6e29d7ab8b2be5534e7e83b635f55063191a89 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 00:18:06 +0200 Subject: [PATCH 10/27] fix(auth): keep the session when git erases a rejected credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `credential erase` cleared the whole token set. Git erases whenever the credentials it was handed are rejected, so a single 401 — an access token that slipped past the renewal skew, or a transient error from the plugins host — destroyed the refresh token too and cost the user a full device-flow login for a recoverable failure. Erase now expires the access token and keeps the refresh token, so the next lookup renews. `renew()` also ignored whether the rotated pair was persisted. The exchange invalidates the old refresh token, so a failed write left the stored one dead with nothing to replace it: the current request still succeeded and every later one failed with no explanation. The failure is now reported, on stderr so the credential protocol on stdout stays intact. Both reported by Cursor Bugbot. The credential-store suite passed 500 lines with the new cases, so the renewal ones move to their own file. Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 4 +- .../docs/nb/reference/cli/credential.mdx | 12 +- .../docs/pt-br/reference/cli/credential.mdx | 12 +- .../content/docs/reference/cli/credential.mdx | 12 +- src/commands/credential.ts | 6 +- src/helpers/credential-store.ts | 25 +++- tests/commands/credential.test.ts | 16 +-- .../helpers/credential-store-renewal.test.ts | 108 ++++++++++++++++++ 8 files changed, 168 insertions(+), 27 deletions(-) create mode 100644 tests/helpers/credential-store-renewal.test.ts diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 670d43c0..add02cdf 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4484,10 +4484,12 @@ The command speaks git's [credential helper protocol](https://git-scm.com/docs/g | --------------------------- | -------------------------------------------------------- | | `archgate credential get` | Print credentials for a plugin repository | | `archgate credential store` | Accept a credential from git (no-op) | -| `archgate credential erase` | Remove stored credentials when git reports them rejected | +| `archgate credential erase` | Drop the cached access token when git reports it rejected | `store` is deliberately a no-op: tokens come from `archgate login`, so there is nothing for git to teach the CLI. +`erase` drops only the cached access token and keeps your session. Git erases whenever the credentials it was given are rejected, and a single 401 — an access token that slipped past the renewal window, or a transient error from the plugins host — should not cost you a full sign-in. The next request renews from the refresh token. + ## Configuration `archgate login` writes the following to your global git config: diff --git a/docs/src/content/docs/nb/reference/cli/credential.mdx b/docs/src/content/docs/nb/reference/cli/credential.mdx index 49fae16a..39eb4139 100644 --- a/docs/src/content/docs/nb/reference/cli/credential.mdx +++ b/docs/src/content/docs/nb/reference/cli/credential.mdx @@ -15,14 +15,16 @@ Kommandoen snakker gits [credential helper-protokoll](https://git-scm.com/docs/g ## Underkommandoer -| Underkommando | Beskrivelse | -| --------------------------- | ------------------------------------------------------------ | -| `archgate credential get` | Skriver ut legitimasjon for et plugin-repositorium | -| `archgate credential store` | Tar imot legitimasjon fra git (no-op) | -| `archgate credential erase` | Fjerner lagret legitimasjon når git melder at den ble avvist | +| Underkommando | Beskrivelse | +| --------------------------- | ---------------------------------------------------------------------------- | +| `archgate credential get` | Skriver ut legitimasjon for et plugin-repositorium | +| `archgate credential store` | Tar imot legitimasjon fra git (no-op) | +| `archgate credential erase` | Forkaster det mellomlagrede tilgangstokenet når git melder at det ble avvist | `store` er bevisst en no-op: tokener kommer fra `archgate login`, så git har ingenting å lære CLI-en. +`erase` forkaster bare det mellomlagrede tilgangstokenet og beholder økten. Git sletter legitimasjon hver gang den blir avvist, og én enkelt 401 — et tilgangstoken som så vidt passerte fornyelsesvinduet, eller en forbigående feil fra plugins-verten — skal ikke koste deg en full innlogging. Neste forespørsel fornyer fra refresh-tokenet. + ## Konfigurasjon `archgate login` skriver følgende til den globale git-konfigurasjonen din: diff --git a/docs/src/content/docs/pt-br/reference/cli/credential.mdx b/docs/src/content/docs/pt-br/reference/cli/credential.mdx index 693af38c..021366be 100644 --- a/docs/src/content/docs/pt-br/reference/cli/credential.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/credential.mdx @@ -15,14 +15,16 @@ O comando fala o [protocolo de credential helper do git](https://git-scm.com/doc ## Subcomandos -| Subcomando | Descrição | -| --------------------------- | ------------------------------------------------------------- | -| `archgate credential get` | Exibe as credenciais de um repositório de plugin | -| `archgate credential store` | Aceita uma credencial do git (no-op) | -| `archgate credential erase` | Remove as credenciais quando o git as reporta como rejeitadas | +| Subcomando | Descrição | +| --------------------------- | ------------------------------------------------------------------------- | +| `archgate credential get` | Exibe as credenciais de um repositório de plugin | +| `archgate credential store` | Aceita uma credencial do git (no-op) | +| `archgate credential erase` | Descarta o token de acesso em cache quando o git o reporta como rejeitado | `store` é intencionalmente um no-op: os tokens vêm do `archgate login`, então não há nada que o git possa ensinar à CLI. +O `erase` descarta apenas o token de acesso em cache e mantém sua sessão. O git apaga credenciais sempre que elas são rejeitadas, e um único 401 — um token de acesso que passou da janela de renovação, ou um erro transitório do host de plugins — não deve custar um novo login completo. A próxima requisição renova a partir do refresh token. + ## Configuração O `archgate login` grava o seguinte na sua configuração global do git: diff --git a/docs/src/content/docs/reference/cli/credential.mdx b/docs/src/content/docs/reference/cli/credential.mdx index 5dc392d3..225d52e1 100644 --- a/docs/src/content/docs/reference/cli/credential.mdx +++ b/docs/src/content/docs/reference/cli/credential.mdx @@ -15,14 +15,16 @@ The command speaks git's [credential helper protocol](https://git-scm.com/docs/g ## Subcommands -| Subcommand | Description | -| --------------------------- | -------------------------------------------------------- | -| `archgate credential get` | Print credentials for a plugin repository | -| `archgate credential store` | Accept a credential from git (no-op) | -| `archgate credential erase` | Remove stored credentials when git reports them rejected | +| Subcommand | Description | +| --------------------------- | --------------------------------------------------------- | +| `archgate credential get` | Print credentials for a plugin repository | +| `archgate credential store` | Accept a credential from git (no-op) | +| `archgate credential erase` | Drop the cached access token when git reports it rejected | `store` is deliberately a no-op: tokens come from `archgate login`, so there is nothing for git to teach the CLI. +`erase` drops only the cached access token and keeps your session. Git erases whenever the credentials it was given are rejected, and a single 401 — an access token that slipped past the renewal window, or a transient error from the plugins host — should not cost you a full sign-in. The next request renews from the refresh token. + ## Configuration `archgate login` writes the following to your global git config: diff --git a/src/commands/credential.ts b/src/commands/credential.ts index cdd9dda1..3e4ff108 100644 --- a/src/commands/credential.ts +++ b/src/commands/credential.ts @@ -3,7 +3,7 @@ import type { Command } from "@commander-js/extra-typings"; import { - clearCredentials, + invalidateAccessToken, PLUGINS_HOST, resolveAccessToken, } from "../helpers/credential-store"; @@ -79,14 +79,14 @@ export function registerCredentialCommand(program: Command) { credential .command("erase") - .description("Remove stored credentials when git reports them rejected") + .description("Drop the cached access token when git reports it rejected") .action(async () => { try { const request = await readRequest(); if (request.protocol !== "https" || request.host !== PLUGINS_HOST) { return; } - await clearCredentials(); + await invalidateAccessToken(); } catch (err) { await handleCommandError(err); } diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index d0fd10b3..527d0a49 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -331,7 +331,15 @@ async function renew( ): Promise { try { const renewed = await refreshAccessToken(refreshToken); - await saveTokenSet(user, renewed); + if (!(await saveTokenSet(user, renewed))) { + // The exchange rotated the refresh token, so the one still on disk is + // already dead. This request can finish with the token in hand, but the + // session cannot be renewed again. + logWarn( + "Renewed credentials could not be stored.", + "Run `archgate login` to sign in again." + ); + } return { token: renewed.accessToken, github_user: user }; } catch (error) { const { stored } = await readTokenSet(); @@ -409,6 +417,21 @@ export async function clearCredentials(): Promise { await cleanupLegacyMetadata(); } +/** + * Mark the stored access token as lapsed, keeping the refresh token. + * + * Git erases credentials on any rejection, so discarding the refresh token + * here would turn a single recoverable 401 into a full device-flow login. + * The next lookup renews instead. + */ +export async function invalidateAccessToken(): Promise { + const stored = await loadTokenSet(); + if (!stored) return; + + await saveTokenSet(stored.user, { ...stored.tokens, expiresAt: 0 }); + logDebug("access token marked for renewal"); +} + /** Parse JSON, returning `null` rather than throwing on malformed input. */ function safeJsonParse(raw: string): unknown { try { diff --git a/tests/commands/credential.test.ts b/tests/commands/credential.test.ts index 93678b62..218775cc 100644 --- a/tests/commands/credential.test.ts +++ b/tests/commands/credential.test.ts @@ -20,7 +20,7 @@ import { rejectionMessage } from "../test-utils"; let stdinSpy: Mock; let stdoutSpy: Mock; let resolveSpy: Mock; -let clearSpy: Mock; +let invalidateSpy: Mock; /** Everything the command wrote to stdout during one run. */ function written(): string { @@ -44,14 +44,14 @@ beforeEach(() => { token: "ey.access", github_user: "octocat", }); - clearSpy = spyOn(credMod, "clearCredentials").mockResolvedValue(); + invalidateSpy = spyOn(credMod, "invalidateAccessToken").mockResolvedValue(); }); afterEach(() => { stdinSpy.mockRestore(); stdoutSpy.mockRestore(); resolveSpy.mockRestore(); - clearSpy.mockRestore(); + invalidateSpy.mockRestore(); }); describe("archgate credential get", () => { @@ -89,10 +89,12 @@ describe("archgate credential get", () => { }); describe("archgate credential erase", () => { - test("clears stored credentials for the plugins host", async () => { + // Git erases on any rejection, so this must cost the access token only — + // dropping the refresh token would turn a 401 into a full re-login. + test("drops only the access token, keeping the session", async () => { await run("erase", PLUGIN_REQUEST); - expect(clearSpy).toHaveBeenCalled(); + expect(invalidateSpy).toHaveBeenCalled(); }); test.each([ @@ -101,7 +103,7 @@ describe("archgate credential erase", () => { ])("leaves credentials alone for %p", async (request) => { await run("erase", request); - expect(clearSpy).not.toHaveBeenCalled(); + expect(invalidateSpy).not.toHaveBeenCalled(); }); }); @@ -110,7 +112,7 @@ describe("archgate credential store", () => { await run("store", PLUGIN_REQUEST); expect(written()).toBe(""); - expect(clearSpy).not.toHaveBeenCalled(); + expect(invalidateSpy).not.toHaveBeenCalled(); expect(resolveSpy).not.toHaveBeenCalled(); }); }); diff --git a/tests/helpers/credential-store-renewal.test.ts b/tests/helpers/credential-store-renewal.test.ts new file mode 100644 index 00000000..1a82d279 --- /dev/null +++ b/tests/helpers/credential-store-renewal.test.ts @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, spyOn, test } from "bun:test"; + +import * as credentialStore from "../../src/helpers/credential-store"; +import { + invalidateAccessToken, + resolveAccessToken, +} from "../../src/helpers/credential-store"; +import * as logMod from "../../src/helpers/log"; +import * as logtoMod from "../../src/helpers/logto-auth"; + +/** A `Bun.spawn` stand-in that answers `git credential fill` with one record. */ +function gitCredentialStub(username: string, password: string) { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + return { + stdout: new Response(`username=${username}\npassword=${password}\n`).body, + exited: Promise.resolve(0), + kill: () => { + // Nothing to kill: the stub has already settled. + }, + } as unknown as ReturnType; +} + +describe("credential renewal", () => { + describe("invalidateAccessToken", () => { + const tokens = { + accessToken: "ey.rejected", + refreshToken: "refresh-keepme", + expiresAt: Date.now() + 3_600_000, + }; + + // The refresh token must survive: git erases on any rejection, and losing + // it would turn a recoverable 401 into a full device-flow login. + test("expires the access token but keeps the refresh token", async () => { + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("octocat", JSON.stringify(tokens)) + ); + try { + await invalidateAccessToken(); + + const approved = spawnSpy.mock.calls + .filter((call) => [...call[0]].includes("approve")) + .map((call) => (call[1] as { stdin?: unknown } | undefined)?.stdin) + .filter((stdin): stdin is Blob => stdin instanceof Blob); + const written = ( + await Promise.all(approved.map(async (b) => b.text())) + ).join(""); + expect(written).toContain("refresh-keepme"); + expect(written).toContain('"expiresAt":0'); + } finally { + spawnSpy.mockRestore(); + } + }); + + test("writes nothing when no token set is stored", async () => { + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("", "") + ); + try { + await invalidateAccessToken(); + + const approvals = spawnSpy.mock.calls.filter((call) => + [...call[0]].includes("approve") + ); + expect(approvals).toHaveLength(0); + } finally { + spawnSpy.mockRestore(); + } + }); + }); + + // A rotated refresh token that cannot be written leaves the stored one dead, + // so the failure has to be surfaced rather than swallowed. + test("warns when renewed credentials cannot be persisted", async () => { + const stale = { + accessToken: "ey.stale", + refreshToken: "refresh-old", + expiresAt: Date.now() - 1_000, + }; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("octocat", JSON.stringify(stale)) + ); + const saveSpy = spyOn(credentialStore, "saveTokenSet").mockResolvedValue( + false + ); + const refreshSpy = spyOn(logtoMod, "refreshAccessToken").mockResolvedValue({ + accessToken: "ey.fresh", + refreshToken: "refresh-new", + expiresAt: Date.now() + 3_600_000, + }); + const warnSpy = spyOn(logMod, "logWarn").mockImplementation(() => { + // Captured, not printed. + }); + try { + expect(await resolveAccessToken()).toEqual({ + token: "ey.fresh", + github_user: "octocat", + }); + expect(warnSpy.mock.calls.flat().join(" ")).toContain("archgate login"); + } finally { + warnSpy.mockRestore(); + refreshSpy.mockRestore(); + saveSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); +}); From 240b0112f92651d237d1aa6163139680f6600526 Mon Sep 17 00:00:00 2001 From: rhuanbarreto <283004+rhuanbarreto@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:18:39 +0000 Subject: [PATCH 11/27] docs: regenerate llms-full.txt Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index add02cdf..6ab60f17 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4480,10 +4480,10 @@ The command speaks git's [credential helper protocol](https://git-scm.com/docs/g ## Subcommands -| Subcommand | Description | -| --------------------------- | -------------------------------------------------------- | -| `archgate credential get` | Print credentials for a plugin repository | -| `archgate credential store` | Accept a credential from git (no-op) | +| Subcommand | Description | +| --------------------------- | --------------------------------------------------------- | +| `archgate credential get` | Print credentials for a plugin repository | +| `archgate credential store` | Accept a credential from git (no-op) | | `archgate credential erase` | Drop the cached access token when git reports it rejected | `store` is deliberately a no-op: tokens come from `archgate login`, so there is nothing for git to teach the CLI. From fc1a6006aaffce99ef52d4dcd8b2d0a05a6e6d69 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 00:46:42 +0200 Subject: [PATCH 12/27] refactor(auth): split the platform sign-in contract from its OAuth device-flow adapter The CLI now reaches the platform's sign-in service only through the PlatformAuth contract in platform-auth.ts; oauth-device-flow.ts is one implementation of it, configured with endpoints rather than knowing whose they are. No provider is named anywhere in the code. Behaviour-preserving tidy-ups on the way: one form-POST helper for the three token requests, one runner for git credential approve/reject, one launcher loop shared by the browser and clipboard helpers, and one sign-in tail shared by login and login refresh. Signed-off-by: Rhuan Barreto --- src/commands/credential.ts | 30 +- src/commands/login.ts | 85 ++--- src/helpers/credential-store.ts | 216 +++++------ src/helpers/desktop.ts | 33 +- src/helpers/git-credential-config.ts | 28 +- src/helpers/login-flow.ts | 47 +-- src/helpers/logto-auth.ts | 317 ---------------- src/helpers/oauth-device-flow.ts | 274 ++++++++++++++ src/helpers/platform-auth.ts | 97 +++++ tests/commands/credential.test.ts | 4 +- .../helpers/credential-store-renewal.test.ts | 28 +- tests/helpers/credential-store.test.ts | 18 +- tests/helpers/login-flow.test.ts | 64 +--- tests/helpers/logto-auth.test.ts | 267 -------------- tests/helpers/oauth-device-flow.test.ts | 342 ++++++++++++++++++ tests/helpers/platform-auth.test.ts | 64 ++++ tests/test-utils.ts | 40 ++ 17 files changed, 1074 insertions(+), 880 deletions(-) delete mode 100644 src/helpers/logto-auth.ts create mode 100644 src/helpers/oauth-device-flow.ts create mode 100644 src/helpers/platform-auth.ts delete mode 100644 tests/helpers/logto-auth.test.ts create mode 100644 tests/helpers/oauth-device-flow.test.ts create mode 100644 tests/helpers/platform-auth.test.ts diff --git a/src/commands/credential.ts b/src/commands/credential.ts index 3e4ff108..70b167ce 100644 --- a/src/commands/credential.ts +++ b/src/commands/credential.ts @@ -4,24 +4,35 @@ import type { Command } from "@commander-js/extra-typings"; import { invalidateAccessToken, - PLUGINS_HOST, resolveAccessToken, } from "../helpers/credential-store"; import { handleCommandError } from "../helpers/exit"; import { + type CredentialRequest, formatCredentialResponse, parseCredentialRequest, } from "../helpers/git-credential-protocol"; import { logDebug } from "../helpers/log"; +import { PLUGINS_HOST } from "../helpers/platform-auth"; /** * Read the credential request git writes to stdin. * * @returns The parsed request, or an empty one when stdin is closed. */ -async function readRequest(): Promise> { - const raw = await Bun.stdin.text(); - return parseCredentialRequest(raw); +async function readRequest(): Promise { + return parseCredentialRequest(await Bun.stdin.text()); +} + +/** + * True for a request this helper answers. + * + * Git uses the requested protocol for the exchange itself, so an `http://` + * request would carry the token in cleartext; only `https` to the plugins + * host qualifies, and the response cannot upgrade the protocol. + */ +function isPluginsRequest(request: CredentialRequest): boolean { + return request.protocol === "https" && request.host === PLUGINS_HOST; } export function registerCredentialCommand(program: Command) { @@ -35,10 +46,7 @@ export function registerCredentialCommand(program: Command) { .action(async () => { try { const request = await readRequest(); - // Git uses the requested protocol for the exchange itself, so an - // http:// request would carry the token in cleartext. Answering only - // https keeps that from happening; the response cannot upgrade it. - if (request.protocol !== "https" || request.host !== PLUGINS_HOST) { + if (!isPluginsRequest(request)) { logDebug( "Ignoring credential request for:", `${request.protocol}://${request.host}` @@ -82,11 +90,9 @@ export function registerCredentialCommand(program: Command) { .description("Drop the cached access token when git reports it rejected") .action(async () => { try { - const request = await readRequest(); - if (request.protocol !== "https" || request.host !== PLUGINS_HOST) { - return; + if (isPluginsRequest(await readRequest())) { + await invalidateAccessToken(); } - await invalidateAccessToken(); } catch (err) { await handleCommandError(err); } diff --git a/src/commands/login.ts b/src/commands/login.ts index 8c65b3f7..01307827 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -29,28 +29,9 @@ export function registerLoginCommand(program: Command) { ); return; } - - const result = await runLoginFlow(); - trackLoginResult({ subcommand: "login", success: result.ok }); - if (result.ok) { - printNextStep(); - } else { - await exitWith(1); - } + await signIn("login"); } catch (err) { - if (err instanceof Error && err.name === "ExitPromptError") throw err; - const failureReason = isTlsError(err) ? "tls" : "other"; - trackLoginResult({ - subcommand: "login", - success: false, - failure_reason: failureReason, - }); - if (isTlsError(err)) { - logError(tlsHintMessage()); - await exitWith(1); - return; - } - await handleCommandError(err); + await handleSignInError("login", err); } }); @@ -96,31 +77,53 @@ export function registerLoginCommand(program: Command) { .action(async () => { try { await clearCredentials(); - const result = await runLoginFlow(); - trackLoginResult({ subcommand: "refresh", success: result.ok }); - if (result.ok) { - printNextStep(); - } else { - await exitWith(1); - } + await signIn("refresh"); } catch (err) { - if (err instanceof Error && err.name === "ExitPromptError") throw err; - const failureReason = isTlsError(err) ? "tls" : "other"; - trackLoginResult({ - subcommand: "refresh", - success: false, - failure_reason: failureReason, - }); - if (isTlsError(err)) { - logError(tlsHintMessage()); - await exitWith(1); - return; - } - await handleCommandError(err); + await handleSignInError("refresh", err); } }); } +/** + * Run the sign-in flow and report its outcome. + * + * @param subcommand - Reported to telemetry with the outcome. + */ +async function signIn(subcommand: "login" | "refresh"): Promise { + const result = await runLoginFlow(); + trackLoginResult({ subcommand, success: result.ok }); + if (result.ok) { + printNextStep(); + } else { + await exitWith(1); + } +} + +/** + * Report a failed sign-in, with a dedicated hint for TLS interception. + * + * @param subcommand - Reported to telemetry with the failure. + * @param err - Whatever the action threw; prompt cancellations propagate. + */ +async function handleSignInError( + subcommand: "login" | "refresh", + err: unknown +): Promise { + if (err instanceof Error && err.name === "ExitPromptError") throw err; + const tls = isTlsError(err); + trackLoginResult({ + subcommand, + success: false, + failure_reason: tls ? "tls" : "other", + }); + if (tls) { + logError(tlsHintMessage()); + await exitWith(1); + return; + } + await handleCommandError(err); +} + function printNextStep(): void { const projectRoot = findProjectRoot(); if (projectRoot !== null && projectRoot !== "") { diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index 527d0a49..c6c8f40b 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -3,8 +3,9 @@ /** * Secure credential storage in the OS credential manager (macOS Keychain, * Windows Credential Manager, libsecret) via `git credential - * approve/fill/reject` — nothing is written to disk. In the protocol, - * `username` carries the GitHub username and `password` the plugin token. + * approve/fill/reject` — nothing is written to disk. The platform token set + * is filed under {@link AUTH_HOST} as a JSON `password`; a token issued + * before platform sign-in sits under {@link PLUGINS_HOST} as-is. * * @see https://git-scm.com/docs/git-credential */ @@ -14,26 +15,18 @@ import { unlinkSync } from "node:fs"; import { z } from "zod"; import { logDebug, logWarn } from "./log"; +import { internalPath } from "./paths"; import { + AUTH_HOST, isExpired, - refreshAccessToken, + PLUGINS_HOST, + platformAuth, + type Session, type TokenSet, TokenSetSchema, -} from "./logto-auth"; -import { internalPath } from "./paths"; +} from "./platform-auth"; import { UserError } from "./user-error"; -/** Host git itself authenticates against for plugin repositories. */ -export const PLUGINS_HOST = "plugins.archgate.dev"; - -/** - * Host the Logto token set is filed under. - * - * archgate registers itself as git's credential helper for {@link PLUGINS_HOST}, - * so storing its own tokens there would make every read re-enter this process. - */ -export const AUTH_HOST = "auth.archgate.dev"; - const CREDENTIAL_TIMEOUT_MS = 3_000; /** @@ -76,15 +69,22 @@ function credentialInput( return lines.join("\n"); } -async function gitCredentialApprove( +/** + * Run `git credential approve` or `reject` for one record. + * + * @returns `true` when git exited 0. Output is ignored: nothing reads it, and + * an unread pipe can fill and block git (ARCH-007). + */ +async function gitCredential( + action: "approve" | "reject", host: string, username: string, password: string ): Promise { - const proc = Bun.spawn(["git", "credential", "approve"], { + const proc = Bun.spawn(["git", "credential", action], { stdin: new Blob([credentialInput(host, username, password)]), - stdout: "pipe", - stderr: "pipe", + stdout: "ignore", + stderr: "ignore", env: gitCredentialEnv(), }); return (await proc.exited) === 0; @@ -108,17 +108,18 @@ function noCredentials(timedOut: boolean): FillResult { async function gitCredentialFill(host: string): Promise { try { + // stderr is ignored rather than piped: nothing reads it, and an unread + // pipe can fill and block the helper (ARCH-007). const proc = Bun.spawn(["git", "credential", "fill"], { stdin: new Blob([credentialInput(host)]), stdout: "pipe", - stderr: "pipe", + stderr: "ignore", env: gitCredentialEnv(), }); - // The timeout MUST be cancelled when the spawn wins the race — - // `Bun.sleep` / `setTimeout` both keep the event loop alive for their - // full duration, adding 3s of latency to every `loadCredentials()` - // caller (e.g. `archgate doctor`) if left running. + // The timer is cleared when git answers first: a pending `setTimeout` + // keeps the event loop alive for its full duration, which would add 3s of + // latency to every caller. let timer: ReturnType | undefined; const result = await Promise.race([ (async () => { @@ -157,42 +158,21 @@ async function gitCredentialFill(host: string): Promise { } } -async function gitCredentialReject( - host: string, - username: string, - password: string -): Promise { - const proc = Bun.spawn(["git", "credential", "reject"], { - stdin: new Blob([credentialInput(host, username, password)]), - stdout: "pipe", - stderr: "pipe", - env: gitCredentialEnv(), - }); - await proc.exited; -} - // --------------------------------------------------------------------------- // Legacy metadata file cleanup // --------------------------------------------------------------------------- -/** Path to the legacy metadata file (~/.archgate/credentials). */ -function legacyMetadataPath(): string { - return internalPath("credentials"); -} - /** * Delete the legacy `~/.archgate/credentials` file if it exists. * * @returns `true` when a file was found and deleted, `false` when none existed. */ async function cleanupLegacyMetadata(): Promise { - const file = Bun.file(legacyMetadataPath()); - if (await file.exists()) { - unlinkSync(legacyMetadataPath()); - logDebug("Legacy credentials metadata file removed"); - return true; - } - return false; + const path = internalPath("credentials"); + if (!(await Bun.file(path).exists())) return false; + unlinkSync(path); + logDebug("Legacy credentials metadata file removed"); + return true; } // --------------------------------------------------------------------------- @@ -203,7 +183,7 @@ const CREDENTIAL_HELPER_HINT = "Run `git config --global credential.helper` to check your configuration."; /** - * Persist a Logto token set in the OS credential manager. + * Persist a platform token set in the OS credential manager. * * A verification round-trip (`git credential fill`) confirms the blob was * actually persisted — `git credential approve` exits 0 even without a @@ -219,12 +199,12 @@ export async function saveTokenSet( ): Promise { await cleanupLegacyMetadata(); - const stored = await gitCredentialApprove( + const stored = await gitCredential( + "approve", AUTH_HOST, user, JSON.stringify(tokens) ); - if (!stored) { logWarn( "git credential approve failed.", @@ -249,32 +229,29 @@ export async function saveTokenSet( } /** - * Read the stored Logto token set without renewing it. + * Read the stored platform session without renewing it. * - * @returns The token set and the user it belongs to, or `null` when absent. + * @returns The session, or `null` when none is stored. */ -export async function loadTokenSet(): Promise<{ - user: string; - tokens: TokenSet; -} | null> { - return (await readTokenSet()).stored; +export async function loadTokenSet(): Promise { + return (await readSession()).session; } /** {@link loadTokenSet}, also reporting whether the helper timed out. */ -async function readTokenSet(): Promise<{ - stored: { user: string; tokens: TokenSet } | null; +async function readSession(): Promise<{ + session: Session | null; timedOut: boolean; }> { const { credentials, timedOut } = await gitCredentialFill(AUTH_HOST); - if (!credentials) return { stored: null, timedOut }; + if (!credentials) return { session: null, timedOut }; const parsed = TokenSetSchema.safeParse(safeJsonParse(credentials.password)); if (!parsed.success) { logDebug("Stored token set is unreadable"); - return { stored: null, timedOut: false }; + return { session: null, timedOut: false }; } return { - stored: { user: credentials.username, tokens: parsed.data }, + session: { user: credentials.username, tokens: parsed.data }, timedOut: false, }; } @@ -293,63 +270,50 @@ async function resolveWithStatus(): Promise<{ credentials: StoredCredentials | null; timedOut: boolean; }> { - const { stored, timedOut } = await readTokenSet(); - if (!stored) return { credentials: null, timedOut }; - - if (!isExpired(stored.tokens.expiresAt)) { - return { - credentials: { - token: stored.tokens.accessToken, - github_user: stored.user, - }, - timedOut: false, - }; - } + const { session, timedOut } = await readSession(); + if (!session) return { credentials: null, timedOut }; - return { - credentials: await renew(stored.user, stored.tokens.refreshToken), - timedOut: false, - }; + const credentials = isExpired(session.tokens.expiresAt) + ? await renew(session) + : { token: session.tokens.accessToken, github_user: session.user }; + return { credentials, timedOut: false }; } /** * Exchange a refresh token, tolerating a sibling process having rotated it. * * Git invokes the credential helper many times for one operation, so several - * processes can read the same expired token set and refresh concurrently. - * Logto rotates the refresh token, which makes every exchange after the first - * fail. Re-reading the store recovers the token the winner just saved. + * processes can read the same expired session and refresh concurrently. The + * platform rotates the refresh token, which makes every exchange after the + * first fail; re-reading the store recovers the token the winner saved. * - * @param user - Account the token set belongs to. - * @param refreshToken - The refresh token read before renewal. + * @param stale - The expired session as read before renewal. * @returns Credentials from this renewal, or from whichever process won. - * @throws {UserError} When no usable token set exists afterwards. + * @throws {UserError} When no usable session exists afterwards. */ -async function renew( - user: string, - refreshToken: string -): Promise { +async function renew(stale: Session): Promise { try { - const renewed = await refreshAccessToken(refreshToken); - if (!(await saveTokenSet(user, renewed))) { - // The exchange rotated the refresh token, so the one still on disk is - // already dead. This request can finish with the token in hand, but the - // session cannot be renewed again. + const renewed = await platformAuth.refreshAccessToken( + stale.tokens.refreshToken + ); + if (!(await saveTokenSet(stale.user, renewed))) { + // The exchange rotated the refresh token, so the stored one is already + // dead: this request can finish, but the session cannot be renewed again. logWarn( "Renewed credentials could not be stored.", "Run `archgate login` to sign in again." ); } - return { token: renewed.accessToken, github_user: user }; + return { token: renewed.accessToken, github_user: stale.user }; } catch (error) { - const { stored } = await readTokenSet(); + const { session } = await readSession(); if ( - stored && - stored.tokens.refreshToken !== refreshToken && - !isExpired(stored.tokens.expiresAt) + session && + session.tokens.refreshToken !== stale.tokens.refreshToken && + !isExpired(session.tokens.expiresAt) ) { logDebug("token set was renewed by another process"); - return { token: stored.tokens.accessToken, github_user: stored.user }; + return { token: session.tokens.accessToken, github_user: session.user }; } throw error; } @@ -358,15 +322,15 @@ async function renew( /** * Load archgate credentials, renewing a lapsed access token on the way. * - * Tokens issued before Logto sign-in are filed under {@link PLUGINS_HOST} and - * are returned unchanged; they keep working until the user signs in again. + * Tokens issued before platform sign-in are filed under {@link PLUGINS_HOST} + * and are returned unchanged; they keep working until the user signs in again. * * @returns The stored credentials, or `null` when none are stored. */ export async function loadCredentials(): Promise { - // Delete legacy metadata file — force re-login for a clean slate. - const hadLegacy = await cleanupLegacyMetadata(); - if (hadLegacy) { + // A legacy metadata file means a pre-platform install: remove it and + // require a fresh sign-in. + if (await cleanupLegacyMetadata()) { logWarn( "Legacy credentials file removed.", "Run `archgate login` to re-authenticate." @@ -394,10 +358,9 @@ export async function loadCredentials(): Promise { if (authLookupTimedOut) return null; const { credentials: legacy } = await gitCredentialFill(PLUGINS_HOST); - if (legacy) { - return { token: legacy.password, github_user: legacy.username }; - } - return null; + return legacy + ? { token: legacy.password, github_user: legacy.username } + : null; } /** @@ -407,9 +370,14 @@ export async function loadCredentials(): Promise { export async function clearCredentials(): Promise { /* oxlint-disable no-await-in-loop -- two fixed hosts, cleared in order */ for (const host of [AUTH_HOST, PLUGINS_HOST]) { - const { credentials: stored } = await gitCredentialFill(host); - if (stored) { - await gitCredentialReject(host, stored.username, stored.password); + const { credentials } = await gitCredentialFill(host); + if (credentials) { + await gitCredential( + "reject", + host, + credentials.username, + credentials.password + ); logDebug("Credentials removed from git credential manager"); } } @@ -423,13 +391,21 @@ export async function clearCredentials(): Promise { * Git erases credentials on any rejection, so discarding the refresh token * here would turn a single recoverable 401 into a full device-flow login. * The next lookup renews instead. + * + * @returns `true` when the lapsed token set was persisted; `false` when there + * was nothing stored or the store could not be updated, in which case + * {@link saveTokenSet} has already warned. */ -export async function invalidateAccessToken(): Promise { - const stored = await loadTokenSet(); - if (!stored) return; +export async function invalidateAccessToken(): Promise { + const session = await loadTokenSet(); + if (!session) return false; - await saveTokenSet(stored.user, { ...stored.tokens, expiresAt: 0 }); - logDebug("access token marked for renewal"); + const saved = await saveTokenSet(session.user, { + ...session.tokens, + expiresAt: 0, + }); + if (saved) logDebug("access token marked for renewal"); + return saved; } /** Parse JSON, returning `null` rather than throwing on malformed input. */ diff --git a/src/helpers/desktop.ts b/src/helpers/desktop.ts index e52e5fe6..a88340a9 100644 --- a/src/helpers/desktop.ts +++ b/src/helpers/desktop.ts @@ -108,6 +108,21 @@ function clipboardCommands(): string[][] { ]; } +/** Try each command in order, stopping at the first that succeeds. */ +async function firstThatWorks( + commands: string[][], + stdin?: string +): Promise { + if (isHeadless()) return false; + + /* oxlint-disable no-await-in-loop -- candidates are tried in order */ + for (const command of commands) { + if (await run(command, stdin)) return true; + } + /* oxlint-enable no-await-in-loop */ + return false; +} + /** * Open a URL in the user's browser. * @@ -116,14 +131,7 @@ function clipboardCommands(): string[][] { * of the platform's launchers are installed. */ export async function openBrowser(url: string): Promise { - if (isHeadless()) return false; - - /* oxlint-disable no-await-in-loop -- launchers are tried in order */ - for (const command of browserCommands(url)) { - if (await run(command)) return true; - } - /* oxlint-enable no-await-in-loop */ - return false; + return firstThatWorks(browserCommands(url)); } /** @@ -133,12 +141,5 @@ export async function openBrowser(url: string): Promise { * @returns `true` when a clipboard tool accepted it. */ export async function copyToClipboard(text: string): Promise { - if (isHeadless()) return false; - - /* oxlint-disable no-await-in-loop -- writers are tried in order */ - for (const command of clipboardCommands()) { - if (await run(command, text)) return true; - } - /* oxlint-enable no-await-in-loop */ - return false; + return firstThatWorks(clipboardCommands(), text); } diff --git a/src/helpers/git-credential-config.ts b/src/helpers/git-credential-config.ts index ced1f558..9166927e 100644 --- a/src/helpers/git-credential-config.ts +++ b/src/helpers/git-credential-config.ts @@ -7,8 +7,8 @@ * using whatever helper the user already configured. */ -import { PLUGINS_HOST } from "./credential-store"; import { logDebug } from "./log"; +import { PLUGINS_HOST } from "./platform-auth"; /** Config key holding the helper list for the plugins host. */ const HELPER_KEY = `credential.https://${PLUGINS_HOST}.helper`; @@ -44,23 +44,15 @@ async function git(args: string[]): Promise { * @returns `true` when both config writes succeed. */ export async function registerGitCredentialHelper(): Promise { - const reset = await git([ - "config", - "--global", - "--replace-all", - HELPER_KEY, - "", - ]); - if (reset !== 0) return false; - - const added = await git([ - "config", - "--global", - "--add", - HELPER_KEY, - HELPER_VALUE, - ]); - if (added !== 0) return false; + const writes = [ + ["config", "--global", "--replace-all", HELPER_KEY, ""], + ["config", "--global", "--add", HELPER_KEY, HELPER_VALUE], + ]; + /* oxlint-disable no-await-in-loop -- the reset must land before the add */ + for (const args of writes) { + if ((await git(args)) !== 0) return false; + } + /* oxlint-enable no-await-in-loop */ logDebug("Registered archgate as git credential helper for", PLUGINS_HOST); return true; diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index 004f2312..feabebcd 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate /** - * login-flow.ts — Shared Logto device flow used by `login` and `init`. + * login-flow.ts — sign in to the Archgate platform; shared by `login` and `init`. */ import { styleText } from "node:util"; @@ -10,11 +10,7 @@ import { saveTokenSet } from "./credential-store"; import { copyToClipboard, openBrowser } from "./desktop"; import { registerGitCredentialHelper } from "./git-credential-config"; import { logDebug, logInfo, logWarn } from "./log"; -import { - identityFromIdToken, - pollForTokens, - requestDeviceCode, -} from "./logto-auth"; +import { platformAuth } from "./platform-auth"; import { UserError } from "./user-error"; export interface LoginFlowResult { @@ -25,7 +21,7 @@ export interface LoginFlowResult { } /** - * Run the Logto device authorization flow and store the resulting tokens. + * Sign in to the platform and store the resulting session. * * Registers archgate as git's credential helper for the plugins host so that * `git clone` of a plugin repository gets a freshly minted access token. @@ -36,40 +32,31 @@ export async function runLoginFlow(): Promise { console.log("By signing in, you agree to the Archgate Terms of Service:"); console.log("https://archgate.dev/terms-of-service\n"); - logDebug("Starting Logto device flow"); - const deviceCode = await requestDeviceCode(); + logDebug("Starting platform sign-in"); + const authorization = await platformAuth.requestDeviceCode(); - const code = styleText(["bold", "green"], deviceCode.user_code); - const copied = await copyToClipboard(deviceCode.user_code); + const copied = await copyToClipboard(authorization.userCode); + const code = + styleText(["bold", "green"], authorization.userCode) + + (copied ? " (copied to your clipboard)" : ""); // The complete URI carries the code as a query parameter, so a browser that // opens it leaves nothing to type. It is always printed as well: the browser // may not open, and the user may be reading this on another machine. const target = - deviceCode.verification_uri_complete ?? deviceCode.verification_uri; - const opened = await openBrowser(target); - - if (opened) { - console.log(`Opened ${styleText("bold", target)} in your browser.`); - console.log( - `If it asks for a code, enter: ${code}${copied ? " (copied to your clipboard)" : ""}\n` - ); + authorization.verificationUriComplete ?? authorization.verificationUri; + const url = styleText("bold", target); + if (await openBrowser(target)) { + console.log(`Opened ${url} in your browser.`); + console.log(`If it asks for a code, enter: ${code}\n`); } else { - console.log(`Open ${styleText("bold", target)} in your browser`); - console.log( - `and enter the code: ${code}${copied ? " (copied to your clipboard)" : ""}\n` - ); + console.log(`Open ${url} in your browser`); + console.log(`and enter the code: ${code}\n`); } console.log("Waiting for authorization..."); + const { user, tokens } = await platformAuth.pollForTokens(authorization); - const { tokens, idToken } = await pollForTokens( - deviceCode.device_code, - deviceCode.interval, - deviceCode.expires_in - ); - - const user = identityFromIdToken(idToken); if (!(await saveTokenSet(user, tokens))) { throw new UserError( "Signed in, but the credentials could not be stored. Configure a git credential helper and run `archgate login` again." diff --git a/src/helpers/logto-auth.ts b/src/helpers/logto-auth.ts deleted file mode 100644 index 1ac4c72b..00000000 --- a/src/helpers/logto-auth.ts +++ /dev/null @@ -1,317 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -/** - * logto-auth.ts — Logto device authorization and token refresh. - * - * Implements RFC 8628 against the Archgate platform. Access tokens - * are short-lived; the refresh token obtained via `offline_access` is what - * survives, and {@link refreshAccessToken} trades it for a new pair. - */ - -import { z } from "zod"; - -import { logDebug } from "./log"; -import { UserError } from "./user-error"; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -const LOGTO_ENDPOINT = "https://auth.archgate.dev"; -const DEVICE_AUTH_URL = `${LOGTO_ENDPOINT}/oidc/device/auth`; -const TOKEN_URL = `${LOGTO_ENDPOINT}/oidc/token`; - -/** Public client — a native app holds no secret. */ -const CLIENT_ID = "gciwm5x5fc2hfr5zjy4ve"; - -/** Audience the plugins backend validates access tokens against. */ -export const PLUGINS_RESOURCE = "https://plugins.archgate.dev"; - -const SCOPES = "openid profile email offline_access"; - -const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; - -/** RFC 8628 default when the server omits `interval`. */ -const DEFAULT_POLL_INTERVAL_SECONDS = 5; - -/** Renew this long before expiry so a token cannot lapse mid-request. */ -const EXPIRY_SKEW_SECONDS = 60; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -const DeviceCodeSchema = z.object({ - device_code: z.string(), - user_code: z.string(), - verification_uri: z.string(), - verification_uri_complete: z.string().optional(), - expires_in: z.number(), - interval: z.number().default(DEFAULT_POLL_INTERVAL_SECONDS), -}); - -export type DeviceCode = z.infer; - -const TokenSuccessSchema = z.object({ - access_token: z.string(), - refresh_token: z.string().optional(), - id_token: z.string().optional(), - expires_in: z.number(), -}); - -const IdTokenClaimsSchema = z.object({ - sub: z.string(), - username: z.string().nullish(), - name: z.string().nullish(), - email: z.string().nullish(), -}); - -const TokenErrorSchema = z.object({ - error: z.string(), - error_description: z.string().optional(), -}); - -/** A token pair plus the absolute epoch-ms instant the access token lapses. */ -export const TokenSetSchema = z.object({ - accessToken: z.string().min(1), - refreshToken: z.string().min(1), - expiresAt: z.number(), -}); - -export type TokenSet = z.infer; - -/** A token set plus the ID token from the exchange that produced it. */ -export const TokenSetWithIdentitySchema = z.object({ - tokens: TokenSetSchema, - idToken: z.string().optional(), -}); - -export type TokenSetWithIdentity = z.infer; - -// --------------------------------------------------------------------------- -// Device authorization -// --------------------------------------------------------------------------- - -/** - * Start a device authorization request. - * - * @returns The user code and verification URI to show the user. - * @throws {UserError} When the Archgate platform rejects the request. - */ -export async function requestDeviceCode(): Promise { - logDebug("Requesting Logto device code from:", DEVICE_AUTH_URL); - const response = await fetch(DEVICE_AUTH_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - scope: SCOPES, - resource: PLUGINS_RESOURCE, - }), - signal: AbortSignal.timeout(15_000), - }); - - if (!response.ok) { - throw new UserError( - `Could not start sign-in (HTTP ${response.status}). Please try again.` - ); - } - - const parsed = DeviceCodeSchema.safeParse(await response.json()); - if (!parsed.success) { - throw new UserError("Sign-in service returned an unexpected response."); - } - return parsed.data; -} - -/** - * Poll the token endpoint until the user authorizes, the code expires, or the - * provider reports a terminal error. - * - * @param deviceCode - The `device_code` from {@link requestDeviceCode}. - * @param intervalSeconds - Server-supplied poll interval. - * @param expiresInSeconds - Lifetime of the device code. - * @returns The token set once the user has authorized. - * @throws {UserError} On denial, expiry, or a terminal provider error. - */ -export async function pollForTokens( - deviceCode: string, - intervalSeconds: number, - expiresInSeconds: number -): Promise { - const deadline = Date.now() + expiresInSeconds * 1000; - let interval = intervalSeconds; - - /* oxlint-disable no-await-in-loop -- sequential polling is required by RFC 8628 */ - while (Date.now() < deadline) { - await Bun.sleep(interval * 1000); - - const response = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - grant_type: DEVICE_GRANT, - device_code: deviceCode, - }), - signal: AbortSignal.timeout(15_000), - }); - - const body: unknown = await jsonBody(response); - - if (response.ok) { - return { tokens: toTokenSet(body), idToken: idTokenFrom(body) }; - } - - const error = TokenErrorSchema.safeParse(body); - if (!error.success) { - throw new UserError("Sign-in service returned an unexpected response."); - } - - switch (error.data.error) { - case "authorization_pending": - break; - case "slow_down": - interval += DEFAULT_POLL_INTERVAL_SECONDS; - break; - case "access_denied": - throw new UserError("Sign-in was denied."); - case "expired_token": - throw new UserError( - "The sign-in code expired. Run `archgate login` to try again." - ); - default: - throw new UserError( - error.data.error_description ?? `Sign-in failed: ${error.data.error}` - ); - } - } - /* oxlint-enable no-await-in-loop */ - - throw new UserError( - "The sign-in code expired. Run `archgate login` to try again." - ); -} - -// --------------------------------------------------------------------------- -// Token renewal -// --------------------------------------------------------------------------- - -/** - * Trade a refresh token for a fresh access token. - * - * @param refreshToken - The stored refresh token. - * @returns A new token set; the refresh token rotates, so store the result. - * @throws {UserError} When the refresh token is expired or revoked. - */ -export async function refreshAccessToken( - refreshToken: string -): Promise { - logDebug("Refreshing Logto access token"); - const response = await fetch(TOKEN_URL, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: CLIENT_ID, - grant_type: "refresh_token", - refresh_token: refreshToken, - resource: PLUGINS_RESOURCE, - }), - signal: AbortSignal.timeout(15_000), - }); - - if (!response.ok) { - await response.body?.cancel(); - throw new UserError( - "Your session has expired. Run `archgate login` to sign in again." - ); - } - - return toTokenSet(await jsonBody(response), refreshToken); -} - -/** - * Read a display name out of an ID token. - * - * The claims are used for presentation only — the plugins backend verifies the - * access token independently, so nothing here is trusted for authorization. - * - * @param idToken - Compact JWS from the token endpoint. - * @returns A username, name, email or subject, in that order of preference. - */ -export function identityFromIdToken(idToken: string | undefined): string { - if (idToken === undefined || idToken === "") return "archgate"; - const parts = idToken.split("."); - if (parts.length < 3) return "archgate"; - const payload = parts[1]; - if (payload === "") return "archgate"; - - try { - const decoded: unknown = JSON.parse( - Buffer.from(payload, "base64url").toString("utf8") - ); - const claims = IdTokenClaimsSchema.safeParse(decoded); - if (!claims.success) return "archgate"; - return ( - claims.data.username ?? - claims.data.name ?? - claims.data.email ?? - claims.data.sub - ); - } catch { - return "archgate"; - } -} - -/** True when `expiresAt` is in the past or close enough to treat as lapsed. */ -export function isExpired(expiresAt: number): boolean { - return Date.now() >= expiresAt - EXPIRY_SKEW_SECONDS * 1000; -} - -/** - * Read a JSON body without letting a non-JSON one escape as an internal error. - * - * An Archgate platform outage answers with an HTML error page from a proxy, - * where `response.json()` rejects with a `SyntaxError` — not a `UserError`, so - * it would surface as an internal fault and reach Sentry. - * - * @returns The parsed body, or `null` when it is not JSON. - */ -async function jsonBody(response: Response): Promise { - try { - return await response.json(); - } catch { - return null; - } -} - -/** Pull the raw ID token out of a token response, when present. */ -function idTokenFrom(body: unknown): string | undefined { - return TokenSuccessSchema.safeParse(body).data?.id_token; -} - -/** - * Normalize a token response into a {@link TokenSet}. - * - * @param body - Parsed JSON from the token endpoint. - * @param fallbackRefreshToken - Kept when the response omits a rotated one. - */ -function toTokenSet(body: unknown, fallbackRefreshToken?: string): TokenSet { - const parsed = TokenSuccessSchema.safeParse(body); - if (!parsed.success) { - throw new UserError("Sign-in service returned an unexpected response."); - } - - const refreshToken = parsed.data.refresh_token ?? fallbackRefreshToken; - if (refreshToken === undefined || refreshToken === "") { - throw new UserError( - "Sign-in did not return a refresh token. Please try again." - ); - } - - return { - accessToken: parsed.data.access_token, - refreshToken, - expiresAt: Date.now() + parsed.data.expires_in * 1000, - }; -} diff --git a/src/helpers/oauth-device-flow.ts b/src/helpers/oauth-device-flow.ts new file mode 100644 index 00000000..659bc309 --- /dev/null +++ b/src/helpers/oauth-device-flow.ts @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * oauth-device-flow.ts — sign-in over the OAuth 2.0 device authorization + * grant (RFC 8628) with refresh-token renewal (RFC 6749 §6). + * + * Knows nothing about which service it talks to: every endpoint and client + * detail arrives through {@link DeviceFlowConfig}. + */ + +import { z } from "zod"; + +import type { + DeviceAuthorization, + PlatformAuth, + Session, + TokenSet, +} from "./platform-auth"; +import { UserError } from "./user-error"; + +/** Where and as whom to sign in. */ +export interface DeviceFlowConfig { + /** RFC 8628 device authorization endpoint. */ + deviceAuthorizationEndpoint: string; + /** OAuth 2.0 token endpoint, used for both grants. */ + tokenEndpoint: string; + /** Public client identifier; a native app holds no secret. */ + clientId: string; + /** Space-separated scopes; must include `offline_access` for a refresh token. */ + scope: string; + /** Resource indicator (RFC 8707) the access token is minted for. */ + resource: string; +} + +const REQUEST_TIMEOUT_MS = 15_000; + +/** RFC 8628 default when the server omits `interval`. */ +const DEFAULT_POLL_INTERVAL_SECONDS = 5; + +const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; + +/** Display name when the ID token offers nothing usable. */ +const FALLBACK_USER = "archgate"; + +const DeviceCodeSchema = z.object({ + device_code: z.string(), + user_code: z.string(), + verification_uri: z.string(), + verification_uri_complete: z.string().optional(), + expires_in: z.number(), + interval: z.number().default(DEFAULT_POLL_INTERVAL_SECONDS), +}); + +const TokenSuccessSchema = z.object({ + access_token: z.string(), + refresh_token: z.string().optional(), + id_token: z.string().optional(), + expires_in: z.number(), +}); + +type TokenSuccess = z.infer; + +const TokenErrorSchema = z.object({ + error: z.string(), + error_description: z.string().optional(), +}); + +const IdTokenClaimsSchema = z.object({ + sub: z.string(), + username: z.string().nullish(), + name: z.string().nullish(), + email: z.string().nullish(), +}); + +/** + * Build a {@link PlatformAuth} that signs in through the given endpoints. + * + * @param config - Endpoints and client identity of the sign-in service. + */ +export function deviceFlowAuth(config: DeviceFlowConfig): PlatformAuth { + return { + async requestDeviceCode(): Promise { + const response = await postForm(config.deviceAuthorizationEndpoint, { + client_id: config.clientId, + scope: config.scope, + resource: config.resource, + }); + if (!response.ok) { + throw new UserError( + `Could not start sign-in (HTTP ${response.status}). Please try again.` + ); + } + + const parsed = DeviceCodeSchema.safeParse(await response.json()); + if (!parsed.success) throw unexpectedResponse(); + return { + deviceCode: parsed.data.device_code, + userCode: parsed.data.user_code, + verificationUri: parsed.data.verification_uri, + verificationUriComplete: parsed.data.verification_uri_complete, + expiresIn: parsed.data.expires_in, + interval: parsed.data.interval, + }; + }, + + async pollForTokens(authorization: DeviceAuthorization): Promise { + const deadline = Date.now() + authorization.expiresIn * 1000; + let interval = authorization.interval; + + /* oxlint-disable no-await-in-loop -- sequential polling is required by RFC 8628 */ + while (Date.now() < deadline) { + await Bun.sleep(interval * 1000); + + const response = await postForm(config.tokenEndpoint, { + client_id: config.clientId, + grant_type: DEVICE_GRANT, + device_code: authorization.deviceCode, + }); + const body = await jsonBody(response); + + if (response.ok) { + const granted = parseTokenResponse(body); + return { + user: userFromIdToken(granted.id_token), + tokens: toTokenSet(granted), + }; + } + + const error = TokenErrorSchema.safeParse(body); + if (!error.success) throw unexpectedResponse(); + + switch (error.data.error) { + case "authorization_pending": + break; + case "slow_down": + interval += DEFAULT_POLL_INTERVAL_SECONDS; + break; + case "access_denied": + throw new UserError("Sign-in was denied."); + case "expired_token": + throw codeExpired(); + default: + throw new UserError( + error.data.error_description ?? + `Sign-in failed: ${error.data.error}` + ); + } + } + /* oxlint-enable no-await-in-loop */ + + throw codeExpired(); + }, + + async refreshAccessToken(refreshToken: string): Promise { + const response = await postForm(config.tokenEndpoint, { + client_id: config.clientId, + grant_type: "refresh_token", + refresh_token: refreshToken, + resource: config.resource, + }); + if (!response.ok) { + await response.body?.cancel(); + throw new UserError( + "Your session has expired. Run `archgate login` to sign in again." + ); + } + return toTokenSet( + parseTokenResponse(await jsonBody(response)), + refreshToken + ); + }, + }; +} + +/** + * Send a form-encoded POST, giving up after {@link REQUEST_TIMEOUT_MS}. + * + * Redirects are refused: a 307 or 308 would replay the body, which carries a + * device code or refresh token, to whatever host the `Location` names. + */ +async function postForm( + url: string, + fields: Record +): Promise { + return fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(fields), + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); +} + +/** + * Read a JSON body, yielding `null` for one that is not JSON. + * + * A proxy answering with an HTML error page would otherwise surface as a + * `SyntaxError` — an internal fault that reaches Sentry — instead of the + * user-facing error the caller raises for an unexpected body. + */ +async function jsonBody(response: Response): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +function unexpectedResponse(): UserError { + return new UserError("Sign-in service returned an unexpected response."); +} + +function codeExpired(): UserError { + return new UserError( + "The sign-in code expired. Run `archgate login` to try again." + ); +} + +/** @throws {UserError} When the body is not a token grant. */ +function parseTokenResponse(body: unknown): TokenSuccess { + const parsed = TokenSuccessSchema.safeParse(body); + if (!parsed.success) throw unexpectedResponse(); + return parsed.data; +} + +/** + * Turn a token grant into a {@link TokenSet}. + * + * @param granted - The parsed token response. + * @param fallbackRefreshToken - Kept when the grant omits a rotated one. + * @throws {UserError} When no refresh token is available from either source. + */ +function toTokenSet( + granted: TokenSuccess, + fallbackRefreshToken?: string +): TokenSet { + const refreshToken = granted.refresh_token ?? fallbackRefreshToken; + if (refreshToken === undefined || refreshToken === "") { + throw new UserError( + "Sign-in did not return a refresh token. Please try again." + ); + } + return { + accessToken: granted.access_token, + refreshToken, + expiresAt: Date.now() + granted.expires_in * 1000, + }; +} + +/** + * Pick a display name out of an ID token: username, name, email or subject, + * in that order. Presentation only — the access token is what the backend + * verifies, so nothing here is trusted for authorization. + */ +function userFromIdToken(idToken: string | undefined): string { + const parts = idToken?.split(".") ?? []; + const payload = parts.length >= 3 ? parts[1] : undefined; + if (payload === undefined || payload === "") return FALLBACK_USER; + + try { + const claims = IdTokenClaimsSchema.safeParse( + JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) + ); + if (!claims.success) return FALLBACK_USER; + return ( + claims.data.username ?? + claims.data.name ?? + claims.data.email ?? + claims.data.sub + ); + } catch { + return FALLBACK_USER; + } +} diff --git a/src/helpers/platform-auth.ts b/src/helpers/platform-auth.ts new file mode 100644 index 00000000..54527e3f --- /dev/null +++ b/src/helpers/platform-auth.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +/** + * platform-auth.ts — how the CLI signs in to the Archgate platform. + * + * The rest of the CLI reaches the platform's sign-in service only through + * {@link platformAuth}. The mechanism behind it is an implementation detail: + * changing providers means wiring a different {@link PlatformAuth} here, not + * touching the login flow or the credential store. + */ + +import { z } from "zod"; + +import { deviceFlowAuth } from "./oauth-device-flow"; + +/** Host of the platform's sign-in service. */ +export const AUTH_HOST = "auth.archgate.dev"; + +/** Host git authenticates against for plugin repositories. */ +export const PLUGINS_HOST = "plugins.archgate.dev"; + +/** Renew this long before expiry so a token cannot lapse mid-request. */ +const EXPIRY_SKEW_SECONDS = 60; + +/** A token pair plus the absolute epoch-ms instant the access token lapses. */ +export const TokenSetSchema = z.object({ + accessToken: z.string().min(1), + refreshToken: z.string().min(1), + expiresAt: z.number(), +}); + +export type TokenSet = z.infer; + +/** A signed-in account and the tokens that prove it. */ +export interface Session { + /** Display name of the account, shown by `archgate login status`. */ + user: string; + tokens: TokenSet; +} + +/** What the user must do to approve a sign-in started on this device. */ +export interface DeviceAuthorization { + /** Opaque handle polled until the user approves. */ + deviceCode: string; + /** Short code the user types at {@link verificationUri}. */ + userCode: string; + verificationUri: string; + /** {@link verificationUri} with the code already filled in, when offered. */ + verificationUriComplete?: string; + /** Seconds until the codes expire. */ + expiresIn: number; + /** Seconds to wait between polls. */ + interval: number; +} + +/** The sign-in operations the CLI needs from the platform. */ +export interface PlatformAuth { + /** + * Start a sign-in on this device. + * + * @throws {UserError} When the platform rejects the request. + */ + requestDeviceCode(): Promise; + + /** + * Wait until the user approves the sign-in. + * + * @param authorization - The pending sign-in from {@link requestDeviceCode}. + * @throws {UserError} On denial, expiry, or a terminal platform error. + */ + pollForTokens(authorization: DeviceAuthorization): Promise; + + /** + * Trade a refresh token for a fresh token set. + * + * @param refreshToken - The stored refresh token. + * @returns A new token set; the refresh token rotates, so store the result. + * @throws {UserError} When the refresh token is expired or revoked. + */ + refreshAccessToken(refreshToken: string): Promise; +} + +/** The platform's sign-in service, reached over the OAuth 2.0 device flow. */ +export const platformAuth: PlatformAuth = deviceFlowAuth({ + deviceAuthorizationEndpoint: `https://${AUTH_HOST}/oidc/device/auth`, + tokenEndpoint: `https://${AUTH_HOST}/oidc/token`, + // A public client: a native app holds no secret. + clientId: "gciwm5x5fc2hfr5zjy4ve", + scope: "openid profile email offline_access", + // The audience the plugins backend validates access tokens against. + resource: `https://${PLUGINS_HOST}`, +}); + +/** True when `expiresAt` is in the past or close enough to treat as lapsed. */ +export function isExpired(expiresAt: number): boolean { + return Date.now() >= expiresAt - EXPIRY_SKEW_SECONDS * 1000; +} diff --git a/tests/commands/credential.test.ts b/tests/commands/credential.test.ts index 218775cc..91044314 100644 --- a/tests/commands/credential.test.ts +++ b/tests/commands/credential.test.ts @@ -44,7 +44,9 @@ beforeEach(() => { token: "ey.access", github_user: "octocat", }); - invalidateSpy = spyOn(credMod, "invalidateAccessToken").mockResolvedValue(); + invalidateSpy = spyOn(credMod, "invalidateAccessToken").mockResolvedValue( + true + ); }); afterEach(() => { diff --git a/tests/helpers/credential-store-renewal.test.ts b/tests/helpers/credential-store-renewal.test.ts index 1a82d279..00902207 100644 --- a/tests/helpers/credential-store-renewal.test.ts +++ b/tests/helpers/credential-store-renewal.test.ts @@ -8,7 +8,7 @@ import { resolveAccessToken, } from "../../src/helpers/credential-store"; import * as logMod from "../../src/helpers/log"; -import * as logtoMod from "../../src/helpers/logto-auth"; +import { platformAuth } from "../../src/helpers/platform-auth"; /** A `Bun.spawn` stand-in that answers `git credential fill` with one record. */ function gitCredentialStub(username: string, password: string) { @@ -37,7 +37,7 @@ describe("credential renewal", () => { gitCredentialStub("octocat", JSON.stringify(tokens)) ); try { - await invalidateAccessToken(); + expect(await invalidateAccessToken()).toBe(true); const approved = spawnSpy.mock.calls .filter((call) => [...call[0]].includes("approve")) @@ -58,7 +58,7 @@ describe("credential renewal", () => { gitCredentialStub("", "") ); try { - await invalidateAccessToken(); + expect(await invalidateAccessToken()).toBe(false); const approvals = spawnSpy.mock.calls.filter((call) => [...call[0]].includes("approve") @@ -68,6 +68,23 @@ describe("credential renewal", () => { spawnSpy.mockRestore(); } }); + + // A store that cannot take the lapsed token set would hand git the same + // rejected access token on the next lookup, so the failure is reported. + test("reports when the lapsed token set cannot be stored", async () => { + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("octocat", JSON.stringify(tokens)) + ); + const saveSpy = spyOn(credentialStore, "saveTokenSet").mockResolvedValue( + false + ); + try { + expect(await invalidateAccessToken()).toBe(false); + } finally { + saveSpy.mockRestore(); + spawnSpy.mockRestore(); + } + }); }); // A rotated refresh token that cannot be written leaves the stored one dead, @@ -84,7 +101,10 @@ describe("credential renewal", () => { const saveSpy = spyOn(credentialStore, "saveTokenSet").mockResolvedValue( false ); - const refreshSpy = spyOn(logtoMod, "refreshAccessToken").mockResolvedValue({ + const refreshSpy = spyOn( + platformAuth, + "refreshAccessToken" + ).mockResolvedValue({ accessToken: "ey.fresh", refreshToken: "refresh-new", expiresAt: Date.now() + 3_600_000, diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts index f98400f0..80559830 100644 --- a/tests/helpers/credential-store.test.ts +++ b/tests/helpers/credential-store.test.ts @@ -12,7 +12,7 @@ import { resolveAccessToken, clearCredentials, } from "../../src/helpers/credential-store"; -import * as logtoMod from "../../src/helpers/logto-auth"; +import { platformAuth } from "../../src/helpers/platform-auth"; import { UserError } from "../../src/helpers/user-error"; import { rejectionMessage, restoreEnv } from "../test-utils"; @@ -367,7 +367,7 @@ describe("credential-store", () => { const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => gitCredentialStub("octocat", JSON.stringify(tokens)) ); - const refreshSpy = spyOn(logtoMod, "refreshAccessToken"); + const refreshSpy = spyOn(platformAuth, "refreshAccessToken"); try { expect(await resolveAccessToken()).toEqual({ token: "ey.valid", @@ -390,7 +390,7 @@ describe("credential-store", () => { gitCredentialStub("octocat", JSON.stringify(stale)) ); const refreshSpy = spyOn( - logtoMod, + platformAuth, "refreshAccessToken" ).mockResolvedValue({ accessToken: "ey.fresh", @@ -428,7 +428,7 @@ describe("credential-store", () => { : gitCredentialStub("octocat", "ag_beta_legacy"); }); const refreshSpy = spyOn( - logtoMod, + platformAuth, "refreshAccessToken" ).mockRejectedValue(new UserError("Your session has expired.")); try { @@ -452,7 +452,7 @@ describe("credential-store", () => { gitCredentialStub("octocat", JSON.stringify(stale)) ); const refreshSpy = spyOn( - logtoMod, + platformAuth, "refreshAccessToken" ).mockRejectedValue(new TypeError("boom")); try { @@ -465,8 +465,8 @@ describe("credential-store", () => { }); // Git runs the helper many times per operation, so processes can race to - // refresh the same expired token set. Logto rotates the refresh token, so - // every exchange after the first fails. + // refresh the same expired token set. The platform rotates the refresh + // token, so every exchange after the first fails. describe("concurrent renewal", () => { const expired = { accessToken: "ey.stale", @@ -489,7 +489,7 @@ describe("credential-store", () => { ); }); const refreshSpy = spyOn( - logtoMod, + platformAuth, "refreshAccessToken" ).mockRejectedValue(new UserError("Your session has expired.")); try { @@ -508,7 +508,7 @@ describe("credential-store", () => { gitCredentialStub("octocat", JSON.stringify(expired)) ); const refreshSpy = spyOn( - logtoMod, + platformAuth, "refreshAccessToken" ).mockRejectedValue(new UserError("Your session has expired.")); try { diff --git a/tests/helpers/login-flow.test.ts b/tests/helpers/login-flow.test.ts index 9daea64e..70536766 100644 --- a/tests/helpers/login-flow.test.ts +++ b/tests/helpers/login-flow.test.ts @@ -14,24 +14,24 @@ import * as credMod from "../../src/helpers/credential-store"; import * as desktopMod from "../../src/helpers/desktop"; import * as gitConfigMod from "../../src/helpers/git-credential-config"; import { runLoginFlow } from "../../src/helpers/login-flow"; -import * as logtoMod from "../../src/helpers/logto-auth"; +import { platformAuth } from "../../src/helpers/platform-auth"; import { rejectionMessage } from "../test-utils"; // Stubs are installed per-test via spyOn, which is auto-restored and scoped to // this file; mock.module is process-global and would leak into sibling suites. -let mockRequestDeviceCode: Mock; -let mockPollForTokens: Mock; +let mockRequestDeviceCode: Mock; +let mockPollForTokens: Mock; let mockSaveTokenSet: Mock; let mockRegisterHelper: Mock; let mockOpenBrowser: Mock; let mockCopyToClipboard: Mock; let logSpy: Mock; -const DEVICE_CODE = { - device_code: "device-abc", - user_code: "HZML-HXLB", - verification_uri: "https://auth.archgate.dev/device", - expires_in: 600, +const AUTHORIZATION = { + deviceCode: "device-abc", + userCode: "HZML-HXLB", + verificationUri: "https://auth.archgate.dev/device", + expiresIn: 600, interval: 5, }; @@ -41,20 +41,14 @@ const TOKENS = { expiresAt: 1_800_000_000_000, }; -/** An ID token whose payload carries the given claims. */ -function idTokenFor(claims: Record): string { - const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); - return `header.${payload}.signature`; -} - beforeEach(() => { mockRequestDeviceCode = spyOn( - logtoMod, + platformAuth, "requestDeviceCode" - ).mockResolvedValue(DEVICE_CODE); - mockPollForTokens = spyOn(logtoMod, "pollForTokens").mockResolvedValue({ + ).mockResolvedValue(AUTHORIZATION); + mockPollForTokens = spyOn(platformAuth, "pollForTokens").mockResolvedValue({ + user: "octocat", tokens: TOKENS, - idToken: idTokenFor({ sub: "usr_1", username: "octocat" }), }); mockSaveTokenSet = spyOn(credMod, "saveTokenSet").mockResolvedValue(true); mockRegisterHelper = spyOn( @@ -94,10 +88,10 @@ describe("runLoginFlow", () => { expect(printed).toContain("HZML-HXLB"); }); - test("polls with the device code and server-supplied interval", async () => { + test("polls the pending authorization it was handed", async () => { await runLoginFlow(); - expect(mockPollForTokens).toHaveBeenCalledWith("device-abc", 5, 600); + expect(mockPollForTokens).toHaveBeenCalledWith(AUTHORIZATION); }); test("registers archgate as git's credential helper", async () => { @@ -117,28 +111,12 @@ describe("runLoginFlow", () => { expect(mockSaveTokenSet).toHaveBeenCalled(); }); - test.each([ - [{ sub: "usr_1", username: "octocat" }, "octocat"], - [{ sub: "usr_1", name: "Octo Cat" }, "Octo Cat"], - [{ sub: "usr_1", email: "octo@example.com" }, "octo@example.com"], - [{ sub: "usr_1" }, "usr_1"], - ])("names the account from %o", async (claims, expected) => { - mockPollForTokens.mockResolvedValue({ - tokens: TOKENS, - idToken: idTokenFor(claims), - }); - - const result = await runLoginFlow(); - - expect(result.githubUser).toBe(expected); - }); - - test("falls back to a generic name when no ID token is returned", async () => { - mockPollForTokens.mockResolvedValue({ tokens: TOKENS, idToken: undefined }); + test("reports the account name the platform resolved", async () => { + mockPollForTokens.mockResolvedValue({ user: "Octo Cat", tokens: TOKENS }); const result = await runLoginFlow(); - expect(result.githubUser).toBe("archgate"); + expect(result.githubUser).toBe("Octo Cat"); }); test("fails the login when the token set cannot be persisted", async () => { @@ -150,13 +128,9 @@ describe("runLoginFlow", () => { }); test("opens the URL that already carries the code", async () => { - mockPollForTokens.mockResolvedValue({ - tokens: TOKENS, - idToken: idTokenFor({ sub: "usr_1", username: "octocat" }), - }); mockRequestDeviceCode.mockResolvedValue({ - ...DEVICE_CODE, - verification_uri_complete: + ...AUTHORIZATION, + verificationUriComplete: "https://auth.archgate.dev/device?user_code=HZML-HXLB", }); diff --git a/tests/helpers/logto-auth.test.ts b/tests/helpers/logto-auth.test.ts deleted file mode 100644 index 16996373..00000000 --- a/tests/helpers/logto-auth.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2026 Archgate -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; - -import { - identityFromIdToken, - isExpired, - pollForTokens, - refreshAccessToken, - requestDeviceCode, -} from "../../src/helpers/logto-auth"; -import { rejectionMessage } from "../test-utils"; - -const originalFetch = globalThis.fetch; - -/** Queue of responses returned by the stubbed fetch, in order. */ -let responses: Response[]; - -/** A `globalThis.fetch` stand-in carrying the `preconnect` member its type requires. */ -function stubFetch(impl: () => Promise): typeof globalThis.fetch { - return Object.assign(impl, { - preconnect: () => { - // Present only to satisfy the fetch type; no test calls it. - }, - }); -} - -beforeEach(() => { - responses = []; - globalThis.fetch = stubFetch( - mock(async (): Promise => { - const next = responses.shift(); - if (!next) throw new Error("unexpected fetch call"); - return next; - }) - ); -}); - -afterEach(() => { - globalThis.fetch = originalFetch; -}); - -/** A token endpoint error body in the shape Logto returns. */ -function errorResponse(error: string): Response { - return Response.json({ error }, { status: 400 }); -} - -describe("requestDeviceCode", () => { - test("returns the user code and verification URI", async () => { - responses.push( - Response.json({ - device_code: "device-abc", - user_code: "HZML-HXLB", - verification_uri: "https://auth.archgate.dev/device", - expires_in: 600, - }) - ); - - const code = await requestDeviceCode(); - - expect(code.user_code).toBe("HZML-HXLB"); - expect(code.device_code).toBe("device-abc"); - }); - - // Logto omits `interval`; RFC 8628 makes 5 seconds the default. - test("defaults the poll interval when the server omits it", async () => { - responses.push( - Response.json({ - device_code: "device-abc", - user_code: "HZML-HXLB", - verification_uri: "https://auth.archgate.dev/device", - expires_in: 600, - }) - ); - - expect((await requestDeviceCode()).interval).toBe(5); - }); - - test("reports a non-OK response as a user-facing error", async () => { - responses.push(new Response("nope", { status: 503 })); - - expect(await rejectionMessage(requestDeviceCode())).toContain( - "Could not start sign-in" - ); - }); -}); - -describe("pollForTokens", () => { - test("keeps polling while authorization is pending", async () => { - responses.push( - errorResponse("authorization_pending"), - Response.json({ - access_token: "ey.access", - refresh_token: "refresh-abc", - expires_in: 3600, - }) - ); - - const result = await pollForTokens("device-abc", 0, 60); - - expect(result.tokens.accessToken).toBe("ey.access"); - expect(result.tokens.refreshToken).toBe("refresh-abc"); - }); - - test.each([ - ["access_denied", "Sign-in was denied."], - ["expired_token", "The sign-in code expired."], - ])("stops on %s", async (error, message) => { - responses.push(errorResponse(error)); - - expect( - await rejectionMessage(pollForTokens("device-abc", 0, 60)) - ).toContain(message); - }); - - test("gives up once the device code lifetime elapses", async () => { - expect( - await rejectionMessage(pollForTokens("device-abc", 0, -1)) - ).toContain("The sign-in code expired."); - }); - - test("fails when the exchange returns no refresh token", async () => { - responses.push( - Response.json({ access_token: "ey.access", expires_in: 60 }) - ); - - expect( - await rejectionMessage(pollForTokens("device-abc", 0, 60)) - ).toContain("did not return a refresh token"); - }); -}); - -describe("refreshAccessToken", () => { - test("returns the rotated token pair", async () => { - responses.push( - Response.json({ - access_token: "ey.new", - refresh_token: "refresh-new", - expires_in: 3600, - }) - ); - - const tokens = await refreshAccessToken("refresh-old"); - - expect(tokens.accessToken).toBe("ey.new"); - expect(tokens.refreshToken).toBe("refresh-new"); - }); - - // Logto rotates refresh tokens, but a response without one must not discard - // the token the caller already holds. - test("keeps the existing refresh token when none is returned", async () => { - responses.push(Response.json({ access_token: "ey.new", expires_in: 3600 })); - - expect((await refreshAccessToken("refresh-old")).refreshToken).toBe( - "refresh-old" - ); - }); - - test("asks the user to sign in again when the grant is rejected", async () => { - responses.push(errorResponse("invalid_grant")); - - expect(await rejectionMessage(refreshAccessToken("refresh-old"))).toContain( - "Your session has expired" - ); - }); -}); - -describe("isExpired", () => { - test.each([ - ["already past", -1_000, true], - ["inside the renewal skew", 30_000, true], - ["comfortably valid", 600_000, false], - ])("%s", (_label, offsetMs, expected) => { - expect(isExpired(Date.now() + offsetMs)).toBe(expected); - }); -}); - -describe("malformed provider responses", () => { - test("rejects a device-code response that is not the expected shape", async () => { - responses.push(Response.json({ nope: true })); - - expect(await rejectionMessage(requestDeviceCode())).toContain( - "unexpected response" - ); - }); - - test("rejects a poll error body that is not the expected shape", async () => { - responses.push(Response.json({ nope: true }, { status: 400 })); - - expect( - await rejectionMessage(pollForTokens("device-abc", 0, 60)) - ).toContain("unexpected response"); - }); - - test("backs off when the provider answers slow_down", async () => { - responses.push( - errorResponse("slow_down"), - Response.json({ - access_token: "ey.access", - refresh_token: "refresh-abc", - expires_in: 3600, - }) - ); - - const result = await pollForTokens("device-abc", 0, 60); - - expect(result.tokens.accessToken).toBe("ey.access"); - }); - - test("surfaces an unrecognised error with its description", async () => { - responses.push( - Response.json( - { error: "invalid_client", error_description: "unknown client" }, - { status: 400 } - ) - ); - - expect( - await rejectionMessage(pollForTokens("device-abc", 0, 60)) - ).toContain("unknown client"); - }); - - test("falls back to the error code when no description is given", async () => { - responses.push(Response.json({ error: "invalid_client" }, { status: 400 })); - - expect( - await rejectionMessage(pollForTokens("device-abc", 0, 60)) - ).toContain("invalid_client"); - }); - - // A proxy's HTML error page must not escape as a SyntaxError. - test("treats a non-JSON success body as an unexpected response", async () => { - responses.push(new Response("gateway", { status: 200 })); - - expect( - await rejectionMessage(pollForTokens("device-abc", 0, 60)) - ).toContain("unexpected response"); - }); - - test("rejects a refresh response that is not the expected shape", async () => { - responses.push(Response.json({ nope: true })); - - expect(await rejectionMessage(refreshAccessToken("refresh-old"))).toContain( - "unexpected response" - ); - }); -}); - -describe("identityFromIdToken", () => { - test.each([ - [{ sub: "usr_1", username: "octocat" }, "octocat"], - [{ sub: "usr_1", name: "Octo Cat" }, "Octo Cat"], - [{ sub: "usr_1", email: "octo@example.com" }, "octo@example.com"], - [{ sub: "usr_1" }, "usr_1"], - ])("prefers the most specific claim in %o", (claims, expected) => { - const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); - - expect(identityFromIdToken(`header.${payload}.sig`)).toBe(expected); - }); - - test.each([undefined, "", "not-a-jwt", "header.!!!notbase64!!!.sig"])( - "falls back to a generic name for %p", - (token) => { - expect(identityFromIdToken(token)).toBe("archgate"); - } - ); -}); diff --git a/tests/helpers/oauth-device-flow.test.ts b/tests/helpers/oauth-device-flow.test.ts new file mode 100644 index 00000000..88230e0d --- /dev/null +++ b/tests/helpers/oauth-device-flow.test.ts @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; + +import { deviceFlowAuth } from "../../src/helpers/oauth-device-flow"; +import type { DeviceAuthorization } from "../../src/helpers/platform-auth"; +import { + type RecordedRequest, + recordingFetch, + rejectionMessage, +} from "../test-utils"; + +const originalFetch = globalThis.fetch; + +/** Queue of responses returned by the stubbed fetch, in order. */ +let responses: Response[]; + +/** Every request the code under test sent, in order. */ +let requests: RecordedRequest[]; + +const auth = deviceFlowAuth({ + deviceAuthorizationEndpoint: "https://sso.example.test/device", + tokenEndpoint: "https://sso.example.test/token", + clientId: "client-1", + scope: "openid offline_access", + resource: "https://api.example.test", +}); + +const PENDING: DeviceAuthorization = { + deviceCode: "device-abc", + userCode: "HZML-HXLB", + verificationUri: "https://sso.example.test/activate", + expiresIn: 60, + interval: 0, +}; + +beforeEach(() => { + responses = []; + requests = []; + globalThis.fetch = recordingFetch(requests, () => { + const next = responses.shift(); + if (!next) throw new Error("unexpected fetch call"); + return next; + }); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +/** A token endpoint error body. */ +function errorResponse(error: string): Response { + return Response.json({ error }, { status: 400 }); +} + +function deviceCodeResponse(extra: Record = {}): Response { + return Response.json({ + device_code: "device-abc", + user_code: "HZML-HXLB", + verification_uri: "https://sso.example.test/activate", + expires_in: 600, + ...extra, + }); +} + +function grantResponse(extra: Record = {}): Response { + return Response.json({ + access_token: "ey.access", + refresh_token: "refresh-abc", + expires_in: 3600, + ...extra, + }); +} + +/** An ID token whose payload carries the given claims. */ +function idTokenFor(claims: Record): string { + const payload = Buffer.from(JSON.stringify(claims)).toString("base64url"); + return `header.${payload}.sig`; +} + +// A 307 or 308 would replay the body, which carries a device code or refresh +// token, to whatever host the `Location` header names. +describe("redirect policy", () => { + test.each([ + [ + "requestDeviceCode", + async () => auth.requestDeviceCode(), + deviceCodeResponse, + ], + ["pollForTokens", async () => auth.pollForTokens(PENDING), grantResponse], + [ + "refreshAccessToken", + async () => auth.refreshAccessToken("refresh-old"), + grantResponse, + ], + ])("%s refuses to follow redirects", async (_name, call, respond) => { + responses.push(respond()); + + await call(); + + expect(requests[0]?.init?.redirect).toBe("error"); + }); +}); + +describe("requestDeviceCode", () => { + test("asks the configured endpoint as the configured client", async () => { + responses.push(deviceCodeResponse()); + + await auth.requestDeviceCode(); + + expect(requests[0]?.url).toBe("https://sso.example.test/device"); + expect(Object.fromEntries(requests[0]?.fields ?? [])).toEqual({ + client_id: "client-1", + scope: "openid offline_access", + resource: "https://api.example.test", + }); + }); + + test("returns the codes and where to enter them", async () => { + responses.push( + deviceCodeResponse({ + verification_uri_complete: + "https://sso.example.test/activate?user_code=HZML-HXLB", + interval: 7, + }) + ); + + expect(await auth.requestDeviceCode()).toEqual({ + deviceCode: "device-abc", + userCode: "HZML-HXLB", + verificationUri: "https://sso.example.test/activate", + verificationUriComplete: + "https://sso.example.test/activate?user_code=HZML-HXLB", + expiresIn: 600, + interval: 7, + }); + }); + + // RFC 8628 makes 5 seconds the default when the server omits `interval`. + test("defaults the poll interval when the server omits it", async () => { + responses.push(deviceCodeResponse()); + + expect((await auth.requestDeviceCode()).interval).toBe(5); + }); + + test("reports a non-OK response as a user-facing error", async () => { + responses.push(new Response("nope", { status: 503 })); + + expect(await rejectionMessage(auth.requestDeviceCode())).toContain( + "Could not start sign-in" + ); + }); + + test("rejects a response that is not the expected shape", async () => { + responses.push(Response.json({ nope: true })); + + expect(await rejectionMessage(auth.requestDeviceCode())).toContain( + "unexpected response" + ); + }); +}); + +describe("pollForTokens", () => { + test("polls the token endpoint with the device grant", async () => { + responses.push(grantResponse()); + + await auth.pollForTokens(PENDING); + + expect(requests[0]?.url).toBe("https://sso.example.test/token"); + expect(Object.fromEntries(requests[0]?.fields ?? [])).toEqual({ + client_id: "client-1", + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + device_code: "device-abc", + }); + }); + + test("keeps polling while authorization is pending", async () => { + responses.push(errorResponse("authorization_pending"), grantResponse()); + + const session = await auth.pollForTokens(PENDING); + + expect(session.tokens.accessToken).toBe("ey.access"); + expect(session.tokens.refreshToken).toBe("refresh-abc"); + expect(requests).toHaveLength(2); + }); + + test("backs off by five seconds when the provider answers slow_down", async () => { + const sleepSpy = spyOn(Bun, "sleep").mockResolvedValue(); + responses.push(errorResponse("slow_down"), grantResponse()); + try { + expect((await auth.pollForTokens(PENDING)).tokens.accessToken).toBe( + "ey.access" + ); + expect(sleepSpy.mock.calls.map((call) => call[0])).toEqual([0, 5_000]); + } finally { + sleepSpy.mockRestore(); + } + }); + + test.each([ + ["access_denied", "Sign-in was denied."], + ["expired_token", "The sign-in code expired."], + ])("stops on %s", async (error, message) => { + responses.push(errorResponse(error)); + + expect(await rejectionMessage(auth.pollForTokens(PENDING))).toContain( + message + ); + }); + + test("gives up once the device code lifetime elapses", async () => { + expect( + await rejectionMessage(auth.pollForTokens({ ...PENDING, expiresIn: -1 })) + ).toContain("The sign-in code expired."); + }); + + test("fails when the grant carries no refresh token", async () => { + responses.push( + Response.json({ access_token: "ey.access", expires_in: 60 }) + ); + + expect(await rejectionMessage(auth.pollForTokens(PENDING))).toContain( + "did not return a refresh token" + ); + }); + + test("surfaces an unrecognised error with its description", async () => { + responses.push( + Response.json( + { error: "invalid_client", error_description: "unknown client" }, + { status: 400 } + ) + ); + + expect(await rejectionMessage(auth.pollForTokens(PENDING))).toContain( + "unknown client" + ); + }); + + test("falls back to the error code when no description is given", async () => { + responses.push(errorResponse("invalid_client")); + + expect(await rejectionMessage(auth.pollForTokens(PENDING))).toContain( + "invalid_client" + ); + }); + + test("rejects an error body that is not the expected shape", async () => { + responses.push(Response.json({ nope: true }, { status: 400 })); + + expect(await rejectionMessage(auth.pollForTokens(PENDING))).toContain( + "unexpected response" + ); + }); + + // A proxy's HTML error page must not escape as a SyntaxError. + test("treats a non-JSON success body as an unexpected response", async () => { + responses.push(new Response("gateway", { status: 200 })); + + expect(await rejectionMessage(auth.pollForTokens(PENDING))).toContain( + "unexpected response" + ); + }); + + describe("names the session", () => { + test.each([ + [{ sub: "usr_1", username: "octocat" }, "octocat"], + [{ sub: "usr_1", name: "Octo Cat" }, "Octo Cat"], + [{ sub: "usr_1", email: "octo@example.com" }, "octo@example.com"], + [{ sub: "usr_1" }, "usr_1"], + ])("from the most specific claim in %o", async (claims, expected) => { + responses.push(grantResponse({ id_token: idTokenFor(claims) })); + + expect((await auth.pollForTokens(PENDING)).user).toBe(expected); + }); + + test.each([ + ["no ID token", undefined], + ["an empty ID token", ""], + ["a token that is not a JWT", "not-a-jwt"], + ["a token with an empty payload", "header..sig"], + ["a payload that is not base64", "header.!!!notbase64!!!.sig"], + ["claims without a subject", idTokenFor({ name: "nobody" })], + ])("generically given %s", async (_label, idToken) => { + responses.push(grantResponse({ id_token: idToken })); + + expect((await auth.pollForTokens(PENDING)).user).toBe("archgate"); + }); + }); +}); + +describe("refreshAccessToken", () => { + test("sends the refresh grant for the configured resource", async () => { + responses.push(grantResponse()); + + await auth.refreshAccessToken("refresh-old"); + + expect(requests[0]?.url).toBe("https://sso.example.test/token"); + expect(Object.fromEntries(requests[0]?.fields ?? [])).toEqual({ + client_id: "client-1", + grant_type: "refresh_token", + refresh_token: "refresh-old", + resource: "https://api.example.test", + }); + }); + + test("returns the rotated token pair", async () => { + responses.push( + grantResponse({ access_token: "ey.new", refresh_token: "refresh-new" }) + ); + + const tokens = await auth.refreshAccessToken("refresh-old"); + + expect(tokens.accessToken).toBe("ey.new"); + expect(tokens.refreshToken).toBe("refresh-new"); + }); + + // A grant without a rotated refresh token must not discard the one held. + test("keeps the existing refresh token when none is returned", async () => { + responses.push(Response.json({ access_token: "ey.new", expires_in: 3600 })); + + expect((await auth.refreshAccessToken("refresh-old")).refreshToken).toBe( + "refresh-old" + ); + }); + + test("asks the user to sign in again when the grant is rejected", async () => { + responses.push(errorResponse("invalid_grant")); + + expect( + await rejectionMessage(auth.refreshAccessToken("refresh-old")) + ).toContain("Your session has expired"); + }); + + test("rejects a response that is not the expected shape", async () => { + responses.push(Response.json({ nope: true })); + + expect( + await rejectionMessage(auth.refreshAccessToken("refresh-old")) + ).toContain("unexpected response"); + }); +}); diff --git a/tests/helpers/platform-auth.test.ts b/tests/helpers/platform-auth.test.ts new file mode 100644 index 00000000..53ae00b1 --- /dev/null +++ b/tests/helpers/platform-auth.test.ts @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { + AUTH_HOST, + isExpired, + platformAuth, + PLUGINS_HOST, +} from "../../src/helpers/platform-auth"; +import { + type RecordedRequest, + recordingFetch, + rejectionMessage, +} from "../test-utils"; + +const originalFetch = globalThis.fetch; + +/** URL and form fields of every request sent through the stubbed fetch. */ +let requests: RecordedRequest[]; + +beforeEach(() => { + requests = []; + globalThis.fetch = recordingFetch( + requests, + () => new Response("unavailable", { status: 503 }) + ); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +describe("platformAuth", () => { + test("signs in against the platform's sign-in host", async () => { + await rejectionMessage(platformAuth.requestDeviceCode()); + + expect(new URL(requests[0]?.url ?? "").host).toBe(AUTH_HOST); + }); + + // The plugins backend validates the audience, so the access token must be + // minted for its host or every download is rejected. + test("requests tokens for the plugins host", async () => { + await rejectionMessage(platformAuth.requestDeviceCode()); + + expect(requests[0]?.fields.get("resource")).toBe(`https://${PLUGINS_HOST}`); + }); + + test("asks for a refresh token", async () => { + await rejectionMessage(platformAuth.requestDeviceCode()); + + expect(requests[0]?.fields.get("scope")).toContain("offline_access"); + }); +}); + +describe("isExpired", () => { + test.each([ + ["already past", -1_000, true], + ["inside the renewal skew", 30_000, true], + ["comfortably valid", 600_000, false], + ])("%s", (_label, offsetMs, expected) => { + expect(isExpired(Date.now() + offsetMs)).toBe(expected); + }); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 927a63e3..250490a0 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -122,3 +122,43 @@ export async function tarballOf( bytes.byteOffset + bytes.byteLength ); } + +/** One request captured by {@link recordingFetch}. */ +export interface RecordedRequest { + url: string; + /** Form fields of the body, empty for a body that is not form-encoded. */ + fields: URLSearchParams; + init: RequestInit | undefined; +} + +/** + * A `globalThis.fetch` stand-in that records each call and answers with + * whatever `respond` returns or throws. + * + * @param requests - Receives one entry per call, in order. + * @param respond - Produces the response for the next call. + */ +export function recordingFetch( + requests: RecordedRequest[], + respond: () => Response +): typeof globalThis.fetch { + const impl = async ( + input: string | URL | Request, + init?: RequestInit + ): Promise => { + requests.push({ + url: input instanceof Request ? input.url : input.toString(), + fields: + init?.body instanceof URLSearchParams + ? init.body + : new URLSearchParams(), + init, + }); + return respond(); + }; + return Object.assign(impl, { + preconnect: () => { + // Present only to satisfy the fetch type; no test calls it. + }, + }); +} From 05cc17ca78224baac44d11e673932db93327578f Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 00:46:42 +0200 Subject: [PATCH 13/27] fix(auth): refuse redirects on token requests and report failed invalidation - Token requests send redirect: "error" so a 307/308 cannot replay a device code or refresh token to another host. - git credential fill ignores stderr instead of leaving an unread pipe. - invalidateAccessToken reports whether the lapsed token set was stored. - Docs describe credential storage as depending on the configured git credential helper, chain the manual VS Code install on curl succeeding, and list archgate credential in the CLI index and sidebar. Signed-off-by: Rhuan Barreto --- docs/astro.config.mjs | 4 +++ docs/public/llms-full.txt | 15 +++++----- .../docs/guides/claude-code-plugin.mdx | 2 +- .../docs/guides/copilot-cli-plugin.mdx | 2 +- .../src/content/docs/guides/vscode-plugin.mdx | 10 +++---- .../docs/nb/guides/claude-code-plugin.mdx | 2 +- .../docs/nb/guides/copilot-cli-plugin.mdx | 2 +- .../content/docs/nb/guides/vscode-plugin.mdx | 10 +++---- .../content/docs/nb/reference/cli/index.mdx | 3 +- .../docs/pt-br/guides/claude-code-plugin.mdx | 2 +- .../docs/pt-br/guides/copilot-cli-plugin.mdx | 2 +- .../docs/pt-br/guides/vscode-plugin.mdx | 10 +++---- .../docs/pt-br/reference/cli/index.mdx | 29 ++++++++++--------- docs/src/content/docs/reference/cli/index.mdx | 29 ++++++++++--------- 14 files changed, 65 insertions(+), 57 deletions(-) diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 09d9ceeb..ad0f9b26 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -255,6 +255,10 @@ export default defineConfig({ items: [ { label: "Overview", slug: "reference/cli" }, { label: "archgate login", slug: "reference/cli/login" }, + { + label: "archgate credential", + slug: "reference/cli/credential", + }, { label: "archgate init", slug: "reference/cli/init" }, { label: "archgate plugin", slug: "reference/cli/plugin" }, { label: "archgate check", slug: "reference/cli/check" }, diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 6ab60f17..8686a7dc 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -1338,7 +1338,7 @@ Log in to obtain plugin access: archgate login ``` -This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, the resulting tokens are handed to your configured git credential helper via `git credential approve`; an OS-backed helper keeps them encrypted, while git's built-in `store` helper writes them in plain text. ### 2. Initialize your project with the plugin @@ -1485,7 +1485,7 @@ Log in to obtain plugin access: archgate login ``` -This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, the resulting tokens are handed to your configured git credential helper via `git credential approve`; an OS-backed helper keeps them encrypted, while git's built-in `store` helper writes them in plain text. ### 2. Initialize your project with the plugin @@ -2318,7 +2318,7 @@ Log in to obtain plugin access: archgate login ``` -This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, the resulting tokens are handed to your configured git credential helper via `git credential approve`; an OS-backed helper keeps them encrypted, while git's built-in `store` helper writes them in plain text. ### 2. Initialize your project with the plugin @@ -2391,12 +2391,12 @@ If you prefer not to let the CLI modify your user settings, you can set things u printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | - curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode -code --install-extension archgate.vsix -rm archgate.vsix + curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode && + code --install-extension archgate.vsix +rm -f archgate.vsix ``` -`archgate credential get` emits a git credential response, so the `password=` line is reshaped into a `curl --config` directive and piped in. The token never reaches the command line, where it would be visible in the process list and your shell history. `--fail` makes curl exit non-zero on a 401 rather than writing the error body over `archgate.vsix`. +`archgate credential get` emits a git credential response, so the `password=` line is reshaped into a `curl --config` directive and piped in. The token never reaches the command line, where it would be visible in the process list and your shell history. `--fail` makes curl exit non-zero on a 401 rather than writing the error body over `archgate.vsix`, and `&&` keeps `code` from installing anything curl did not finish downloading; the final `rm -f` cleans up either way. Prefer the marketplace setup above where you can: it lets git ask the helper on every request, so no token is handled by hand at all. @@ -4635,6 +4635,7 @@ archgate --log-level debug check | Command | Description | | ------------------------------------------------ | ----------------------------------------------------- | | [`archgate login`](./login/) | Log in to the Archgate platform | +| [`archgate credential`](./credential/) | Git credential helper for archgate plugin repositories| | [`archgate init`](./init/) | Set up linting and rules enforcement | | [`archgate plugin`](./plugin/) | Manage editor plugins | | [`archgate check`](./check/) | Run ADR compliance checks | diff --git a/docs/src/content/docs/guides/claude-code-plugin.mdx b/docs/src/content/docs/guides/claude-code-plugin.mdx index 3bb82f84..72839d7c 100644 --- a/docs/src/content/docs/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/guides/claude-code-plugin.mdx @@ -42,7 +42,7 @@ Log in to obtain plugin access: archgate login ``` -This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, the resulting tokens are handed to your configured git credential helper via `git credential approve`; an OS-backed helper keeps them encrypted, while git's built-in `store` helper writes them in plain text. ### 2. Initialize your project with the plugin diff --git a/docs/src/content/docs/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/guides/copilot-cli-plugin.mdx index b17060bf..20ff2384 100644 --- a/docs/src/content/docs/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/guides/copilot-cli-plugin.mdx @@ -27,7 +27,7 @@ Log in to obtain plugin access: archgate login ``` -This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, the resulting tokens are handed to your configured git credential helper via `git credential approve`; an OS-backed helper keeps them encrypted, while git's built-in `store` helper writes them in plain text. ### 2. Initialize your project with the plugin diff --git a/docs/src/content/docs/guides/vscode-plugin.mdx b/docs/src/content/docs/guides/vscode-plugin.mdx index 362d0062..b7f28f1e 100644 --- a/docs/src/content/docs/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/guides/vscode-plugin.mdx @@ -29,7 +29,7 @@ Log in to obtain plugin access: archgate login ``` -This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, credentials are stored securely in your OS credential manager via `git credential approve`. +This starts an OAuth 2.0 Device Authorization Grant against the Archgate platform. The CLI displays a one-time code and URL -- open the URL in your browser, enter the code, and authorize. Once complete, the resulting tokens are handed to your configured git credential helper via `git credential approve`; an OS-backed helper keeps them encrypted, while git's built-in `store` helper writes them in plain text. ### 2. Initialize your project with the plugin @@ -102,12 +102,12 @@ If you prefer not to let the CLI modify your user settings, you can set things u printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | - curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode -code --install-extension archgate.vsix -rm archgate.vsix + curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode && + code --install-extension archgate.vsix +rm -f archgate.vsix ``` -`archgate credential get` emits a git credential response, so the `password=` line is reshaped into a `curl --config` directive and piped in. The token never reaches the command line, where it would be visible in the process list and your shell history. `--fail` makes curl exit non-zero on a 401 rather than writing the error body over `archgate.vsix`. +`archgate credential get` emits a git credential response, so the `password=` line is reshaped into a `curl --config` directive and piped in. The token never reaches the command line, where it would be visible in the process list and your shell history. `--fail` makes curl exit non-zero on a 401 rather than writing the error body over `archgate.vsix`, and `&&` keeps `code` from installing anything curl did not finish downloading; the final `rm -f` cleans up either way. Prefer the marketplace setup above where you can: it lets git ask the helper on every request, so no token is handled by hand at all. diff --git a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx index 871c1d37..689f83ad 100644 --- a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx @@ -42,7 +42,7 @@ Logg inn for å få tilgang til pluginene: archgate login ``` -Dette starter en OAuth 2.0 Device Authorization Grant mot Archgate-plattformen. CLI-et viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden og autoriser. Når det er ferdig, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. +Dette starter en OAuth 2.0 Device Authorization Grant mot Archgate-plattformen. CLI-et viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden og autoriser. Når det er ferdig, gis tokenene til den git credential-helperen du har konfigurert via `git credential approve`; en helper som bruker operativsystemet holder dem krypterte, mens gits innebygde `store`-helper skriver dem i klartekst. ### 2. Initialiser prosjektet ditt med pluginen diff --git a/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx index db69730c..be20d577 100644 --- a/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx @@ -27,7 +27,7 @@ Logg inn for å få tilgang til pluginene: archgate login ``` -Dette starter en OAuth 2.0 Device Authorization Grant mot Archgate-plattformen. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. +Dette starter en OAuth 2.0 Device Authorization Grant mot Archgate-plattformen. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, gis tokenene til den git credential-helperen du har konfigurert via `git credential approve`; en helper som bruker operativsystemet holder dem krypterte, mens gits innebygde `store`-helper skriver dem i klartekst. ### 2. Initialiser prosjektet ditt med pluginet diff --git a/docs/src/content/docs/nb/guides/vscode-plugin.mdx b/docs/src/content/docs/nb/guides/vscode-plugin.mdx index 7a19a36e..7220e265 100644 --- a/docs/src/content/docs/nb/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/nb/guides/vscode-plugin.mdx @@ -29,7 +29,7 @@ Logg inn for å få tilgang til pluginene: archgate login ``` -Dette starter en OAuth 2.0 Device Authorization Grant mot Archgate-plattformen. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, lagres legitimasjonen sikkert i operativsystemets legitimasjonsbehandler via `git credential approve`. +Dette starter en OAuth 2.0 Device Authorization Grant mot Archgate-plattformen. CLI-en viser en engangskode og URL -- åpne URL-en i nettleseren din, skriv inn koden, og autoriser. Når det er fullført, gis tokenene til den git credential-helperen du har konfigurert via `git credential approve`; en helper som bruker operativsystemet holder dem krypterte, mens gits innebygde `store`-helper skriver dem i klartekst. ### 2. Initialiser prosjektet ditt med pluginet @@ -102,12 +102,12 @@ Hvis du foretrekker at CLI-en ikke endrer brukerinnstillingene dine, kan du sett printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | - curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode -code --install-extension archgate.vsix -rm archgate.vsix + curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode && + code --install-extension archgate.vsix +rm -f archgate.vsix ``` -`archgate credential get` skriver ut et git credential-svar, så `password=`-linjen formes om til et `curl --config`-direktiv og sendes inn via pipe. Tokenet havner aldri på kommandolinjen, der det ville vært synlig i prosesslisten og i shell-historikken. `--fail` gjør at curl avslutter med feilkode ved 401 i stedet for å skrive feilmeldingen over `archgate.vsix`. +`archgate credential get` skriver ut et git credential-svar, så `password=`-linjen formes om til et `curl --config`-direktiv og sendes inn via pipe. Tokenet havner aldri på kommandolinjen, der det ville vært synlig i prosesslisten og i shell-historikken. `--fail` gjør at curl avslutter med feilkode ved 401 i stedet for å skrive feilmeldingen over `archgate.vsix`, og `&&` hindrer `code` i å installere noe curl ikke lastet ferdig; den avsluttende `rm -f` rydder opp uansett. Bruk heller marketplace-oppsettet over når du kan: da spør git helperen ved hver forespørsel, og du håndterer ikke noe token manuelt. diff --git a/docs/src/content/docs/nb/reference/cli/index.mdx b/docs/src/content/docs/nb/reference/cli/index.mdx index 2db2f4be..31141420 100644 --- a/docs/src/content/docs/nb/reference/cli/index.mdx +++ b/docs/src/content/docs/nb/reference/cli/index.mdx @@ -1,6 +1,6 @@ --- title: CLI-kommandoer -description: "Komplett referanse for alle Archgate CLI-kommandoer: init, check, adr, login, plugin, review-context, session-context, upgrade, doctor, clean og telemetry." +description: "Komplett referanse for alle Archgate CLI-kommandoer: init, check, adr, login, credential, plugin, review-context, session-context, upgrade, doctor, clean og telemetry." --- ## Globale valg @@ -24,6 +24,7 @@ archgate --log-level debug check | Kommando | Beskrivelse | | ------------------------------------------------ | --------------------------------------------------------------- | | [`archgate login`](./login/) | Logg inn på Archgate-plattformen | +| [`archgate credential`](./credential/) | Git credential-helper for archgate-plugin-repositorier | | [`archgate init`](./init/) | Sett opp linting og regelhåndhevelse | | [`archgate plugin`](./plugin/) | Administrer editor-plugins | | [`archgate check`](./check/) | Kjør ADR-samsvarskontroller | diff --git a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx index 97133ed8..7cff4484 100644 --- a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx @@ -42,7 +42,7 @@ Entre para obter acesso aos plugins: archgate login ``` -Isso inicia um OAuth 2.0 Device Authorization Grant na plataforma Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. +Isso inicia um OAuth 2.0 Device Authorization Grant na plataforma Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, os tokens resultantes são entregues ao credential helper do git que você configurou via `git credential approve`; um helper apoiado no sistema operacional os mantém criptografados, enquanto o helper `store` embutido do git os grava em texto puro. ### 2. Inicialize seu projeto com o plugin diff --git a/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx index fe0afa86..4b5b8bcb 100644 --- a/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx @@ -27,7 +27,7 @@ Entre para obter acesso aos plugins: archgate login ``` -Isso inicia um OAuth 2.0 Device Authorization Grant na plataforma Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. +Isso inicia um OAuth 2.0 Device Authorization Grant na plataforma Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, os tokens resultantes são entregues ao credential helper do git que você configurou via `git credential approve`; um helper apoiado no sistema operacional os mantém criptografados, enquanto o helper `store` embutido do git os grava em texto puro. ### 2. Inicialize seu projeto com o plugin diff --git a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx index aa7fa82b..f01bffef 100644 --- a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx @@ -29,7 +29,7 @@ Entre para obter acesso aos plugins: archgate login ``` -Isso inicia um OAuth 2.0 Device Authorization Grant na plataforma Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, as credenciais são armazenadas de forma segura no gerenciador de credenciais do sistema operacional via `git credential approve`. +Isso inicia um OAuth 2.0 Device Authorization Grant na plataforma Archgate. O CLI exibe um código único e uma URL -- abra a URL no seu navegador, insira o código e autorize. Ao concluir, os tokens resultantes são entregues ao credential helper do git que você configurou via `git credential approve`; um helper apoiado no sistema operacional os mantém criptografados, enquanto o helper `store` embutido do git os grava em texto puro. ### 2. Inicialize seu projeto com o plugin @@ -102,12 +102,12 @@ Se preferir não deixar o CLI modificar suas configurações, você pode configu printf 'protocol=https\nhost=plugins.archgate.dev\n\n' | archgate credential get | sed -n 's|^password=\(.*\)|header = "Authorization: Bearer \1"|p' | - curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode -code --install-extension archgate.vsix -rm archgate.vsix + curl --config - --fail --silent --show-error --output archgate.vsix https://plugins.archgate.dev/api/vscode && + code --install-extension archgate.vsix +rm -f archgate.vsix ``` -O `archgate credential get` emite uma resposta de credencial do git, então a linha `password=` é convertida em uma diretiva `curl --config` e enviada por pipe. O token nunca chega à linha de comando, onde ficaria visível na lista de processos e no histórico do shell. O `--fail` faz o curl sair com erro em um 401, em vez de gravar o corpo do erro por cima de `archgate.vsix`. +O `archgate credential get` emite uma resposta de credencial do git, então a linha `password=` é convertida em uma diretiva `curl --config` e enviada por pipe. O token nunca chega à linha de comando, onde ficaria visível na lista de processos e no histórico do shell. O `--fail` faz o curl sair com erro em um 401, em vez de gravar o corpo do erro por cima de `archgate.vsix`, e o `&&` impede que o `code` instale algo que o curl não terminou de baixar; o `rm -f` final limpa em qualquer caso. Prefira a configuração de marketplace acima quando possível: assim o git consulta o helper a cada requisição e nenhum token é manipulado à mão. diff --git a/docs/src/content/docs/pt-br/reference/cli/index.mdx b/docs/src/content/docs/pt-br/reference/cli/index.mdx index e9784f70..48ed91ff 100644 --- a/docs/src/content/docs/pt-br/reference/cli/index.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/index.mdx @@ -1,6 +1,6 @@ --- title: Comandos da CLI -description: "Referência completa de todos os comandos da CLI do Archgate: init, check, adr, login, plugin, review-context, session-context, upgrade, doctor, clean e telemetria." +description: "Referência completa de todos os comandos da CLI do Archgate: init, check, adr, login, credential, plugin, review-context, session-context, upgrade, doctor, clean e telemetria." --- ## Opções globais @@ -21,16 +21,17 @@ archgate --log-level debug check ## Comandos -| Comando | Descrição | -| ------------------------------------------------ | -------------------------------------------------------- | -| [`archgate login`](./login/) | Entre na plataforma Archgate | -| [`archgate init`](./init/) | Configurar linting e aplicação de regras | -| [`archgate plugin`](./plugin/) | Gerenciar plugins de editor | -| [`archgate check`](./check/) | Executar verificações de conformidade com ADRs | -| [`archgate adr`](./adr/) | Criar, listar, exibir e atualizar ADRs | -| [`archgate review-context`](./review-context/) | Pré-computar contexto de revisão para CI/integrações | -| [`archgate session-context`](./session-context/) | Ler transcrições de sessão de editores de IA | -| [`archgate upgrade`](./upgrade/) | Atualizar o Archgate para a versão mais recente | -| [`archgate doctor`](./doctor/) | Verificar ambiente do sistema e integrações com editores | -| [`archgate clean`](./clean/) | Remover o diretório de cache da CLI | -| [`archgate telemetry`](./telemetry/) | Gerenciar a coleta anônima de dados de uso | +| Comando | Descrição | +| ------------------------------------------------ | -------------------------------------------------------------- | +| [`archgate login`](./login/) | Entre na plataforma Archgate | +| [`archgate credential`](./credential/) | Credential helper do git para repositórios de plugins archgate | +| [`archgate init`](./init/) | Configurar linting e aplicação de regras | +| [`archgate plugin`](./plugin/) | Gerenciar plugins de editor | +| [`archgate check`](./check/) | Executar verificações de conformidade com ADRs | +| [`archgate adr`](./adr/) | Criar, listar, exibir e atualizar ADRs | +| [`archgate review-context`](./review-context/) | Pré-computar contexto de revisão para CI/integrações | +| [`archgate session-context`](./session-context/) | Ler transcrições de sessão de editores de IA | +| [`archgate upgrade`](./upgrade/) | Atualizar o Archgate para a versão mais recente | +| [`archgate doctor`](./doctor/) | Verificar ambiente do sistema e integrações com editores | +| [`archgate clean`](./clean/) | Remover o diretório de cache da CLI | +| [`archgate telemetry`](./telemetry/) | Gerenciar a coleta anônima de dados de uso | diff --git a/docs/src/content/docs/reference/cli/index.mdx b/docs/src/content/docs/reference/cli/index.mdx index 86b38400..341a08de 100644 --- a/docs/src/content/docs/reference/cli/index.mdx +++ b/docs/src/content/docs/reference/cli/index.mdx @@ -1,6 +1,6 @@ --- title: CLI Commands -description: "Complete reference for all Archgate CLI commands: init, check, adr, login, plugin, review-context, session-context, upgrade, doctor, clean, and telemetry." +description: "Complete reference for all Archgate CLI commands: init, check, adr, login, credential, plugin, review-context, session-context, upgrade, doctor, clean, and telemetry." --- ## Global options @@ -21,16 +21,17 @@ archgate --log-level debug check ## Commands -| Command | Description | -| ------------------------------------------------ | ----------------------------------------------------- | -| [`archgate login`](./login/) | Log in to the Archgate platform | -| [`archgate init`](./init/) | Set up linting and rules enforcement | -| [`archgate plugin`](./plugin/) | Manage editor plugins | -| [`archgate check`](./check/) | Run ADR compliance checks | -| [`archgate adr`](./adr/) | Create, list, show, and update ADRs | -| [`archgate review-context`](./review-context/) | Pre-compute review context for CI/editor integrations | -| [`archgate session-context`](./session-context/) | Read AI editor session transcripts | -| [`archgate upgrade`](./upgrade/) | Upgrade Archgate to the latest version | -| [`archgate doctor`](./doctor/) | Check system environment and editor integrations | -| [`archgate clean`](./clean/) | Remove the CLI cache directory | -| [`archgate telemetry`](./telemetry/) | Manage anonymous usage data collection | +| Command | Description | +| ------------------------------------------------ | ------------------------------------------------------ | +| [`archgate login`](./login/) | Log in to the Archgate platform | +| [`archgate credential`](./credential/) | Git credential helper for archgate plugin repositories | +| [`archgate init`](./init/) | Set up linting and rules enforcement | +| [`archgate plugin`](./plugin/) | Manage editor plugins | +| [`archgate check`](./check/) | Run ADR compliance checks | +| [`archgate adr`](./adr/) | Create, list, show, and update ADRs | +| [`archgate review-context`](./review-context/) | Pre-compute review context for CI/editor integrations | +| [`archgate session-context`](./session-context/) | Read AI editor session transcripts | +| [`archgate upgrade`](./upgrade/) | Upgrade Archgate to the latest version | +| [`archgate doctor`](./doctor/) | Check system environment and editor integrations | +| [`archgate clean`](./clean/) | Remove the CLI cache directory | +| [`archgate telemetry`](./telemetry/) | Manage anonymous usage data collection | From 33203d45d9a239d63e3f664eddbb03a02e1d746c Mon Sep 17 00:00:00 2001 From: rhuanbarreto <283004+rhuanbarreto@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:47:14 +0000 Subject: [PATCH 14/27] docs: regenerate llms-full.txt Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 8686a7dc..3b38a141 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4632,20 +4632,20 @@ archgate --log-level debug check ## Commands -| Command | Description | -| ------------------------------------------------ | ----------------------------------------------------- | -| [`archgate login`](./login/) | Log in to the Archgate platform | -| [`archgate credential`](./credential/) | Git credential helper for archgate plugin repositories| -| [`archgate init`](./init/) | Set up linting and rules enforcement | -| [`archgate plugin`](./plugin/) | Manage editor plugins | -| [`archgate check`](./check/) | Run ADR compliance checks | -| [`archgate adr`](./adr/) | Create, list, show, and update ADRs | -| [`archgate review-context`](./review-context/) | Pre-compute review context for CI/editor integrations | -| [`archgate session-context`](./session-context/) | Read AI editor session transcripts | -| [`archgate upgrade`](./upgrade/) | Upgrade Archgate to the latest version | -| [`archgate doctor`](./doctor/) | Check system environment and editor integrations | -| [`archgate clean`](./clean/) | Remove the CLI cache directory | -| [`archgate telemetry`](./telemetry/) | Manage anonymous usage data collection | +| Command | Description | +| ------------------------------------------------ | ------------------------------------------------------ | +| [`archgate login`](./login/) | Log in to the Archgate platform | +| [`archgate credential`](./credential/) | Git credential helper for archgate plugin repositories | +| [`archgate init`](./init/) | Set up linting and rules enforcement | +| [`archgate plugin`](./plugin/) | Manage editor plugins | +| [`archgate check`](./check/) | Run ADR compliance checks | +| [`archgate adr`](./adr/) | Create, list, show, and update ADRs | +| [`archgate review-context`](./review-context/) | Pre-compute review context for CI/editor integrations | +| [`archgate session-context`](./session-context/) | Read AI editor session transcripts | +| [`archgate upgrade`](./upgrade/) | Upgrade Archgate to the latest version | +| [`archgate doctor`](./doctor/) | Check system environment and editor integrations | +| [`archgate clean`](./clean/) | Remove the CLI cache directory | +| [`archgate telemetry`](./telemetry/) | Manage anonymous usage data collection | --- From aedcb6ca7d2886fb7d2c9d107f4706d90bf21256 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 00:55:55 +0200 Subject: [PATCH 15/27] refactor(auth): make plugin-install.ts the owner of the plugins host The auth contract, credential store, helper config and credential command import PLUGINS_HOST from plugin-install.ts, which also derives its API and marketplace URLs from it. Sign-in no longer defines a host that belongs to the plugins service. Signed-off-by: Rhuan Barreto --- src/commands/credential.ts | 2 +- src/helpers/credential-store.ts | 2 +- src/helpers/git-credential-config.ts | 2 +- src/helpers/platform-auth.ts | 4 +--- src/helpers/plugin-install.ts | 13 +++++++------ tests/helpers/platform-auth.test.ts | 2 +- 6 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/commands/credential.ts b/src/commands/credential.ts index 70b167ce..510b1ef2 100644 --- a/src/commands/credential.ts +++ b/src/commands/credential.ts @@ -13,7 +13,7 @@ import { parseCredentialRequest, } from "../helpers/git-credential-protocol"; import { logDebug } from "../helpers/log"; -import { PLUGINS_HOST } from "../helpers/platform-auth"; +import { PLUGINS_HOST } from "../helpers/plugin-install"; /** * Read the credential request git writes to stdin. diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index c6c8f40b..81efea67 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -19,12 +19,12 @@ import { internalPath } from "./paths"; import { AUTH_HOST, isExpired, - PLUGINS_HOST, platformAuth, type Session, type TokenSet, TokenSetSchema, } from "./platform-auth"; +import { PLUGINS_HOST } from "./plugin-install"; import { UserError } from "./user-error"; const CREDENTIAL_TIMEOUT_MS = 3_000; diff --git a/src/helpers/git-credential-config.ts b/src/helpers/git-credential-config.ts index 9166927e..d3ecda64 100644 --- a/src/helpers/git-credential-config.ts +++ b/src/helpers/git-credential-config.ts @@ -8,7 +8,7 @@ */ import { logDebug } from "./log"; -import { PLUGINS_HOST } from "./platform-auth"; +import { PLUGINS_HOST } from "./plugin-install"; /** Config key holding the helper list for the plugins host. */ const HELPER_KEY = `credential.https://${PLUGINS_HOST}.helper`; diff --git a/src/helpers/platform-auth.ts b/src/helpers/platform-auth.ts index 54527e3f..54ff5334 100644 --- a/src/helpers/platform-auth.ts +++ b/src/helpers/platform-auth.ts @@ -12,13 +12,11 @@ import { z } from "zod"; import { deviceFlowAuth } from "./oauth-device-flow"; +import { PLUGINS_HOST } from "./plugin-install"; /** Host of the platform's sign-in service. */ export const AUTH_HOST = "auth.archgate.dev"; -/** Host git authenticates against for plugin repositories. */ -export const PLUGINS_HOST = "plugins.archgate.dev"; - /** Renew this long before expiry so a token cannot lapse mid-request. */ const EXPIRY_SKEW_SECONDS = 60; diff --git a/src/helpers/plugin-install.ts b/src/helpers/plugin-install.ts index 615aacf5..812a1710 100644 --- a/src/helpers/plugin-install.ts +++ b/src/helpers/plugin-install.ts @@ -18,16 +18,17 @@ import { import { resolveCommand } from "./platform"; import { UserError } from "./user-error"; -const PLUGINS_API = "https://plugins.archgate.dev"; +/** Host serving plugin downloads and plugin git repositories. */ +export const PLUGINS_HOST = "plugins.archgate.dev"; + +const PLUGINS_API = `https://${PLUGINS_HOST}`; /** Base marketplace URL — credentials are provided by the git credential manager. */ -const MARKETPLACE_URL = "https://plugins.archgate.dev/archgate.git"; +const MARKETPLACE_URL = `${PLUGINS_API}/archgate.git`; /** Base VS Code marketplace URL — credentials are provided by the git credential manager. */ -const VSCODE_MARKETPLACE_URL = - "https://plugins.archgate.dev/archgate/vscode.git"; +const VSCODE_MARKETPLACE_URL = `${PLUGINS_API}/archgate/vscode.git`; /** Cursor Team Marketplace URL — credentials are provided by the git credential manager. */ -const CURSOR_MARKETPLACE_URL = - "https://plugins.archgate.dev/archgate/cursor.git"; +const CURSOR_MARKETPLACE_URL = `${PLUGINS_API}/archgate/cursor.git`; /** * Run a command using `Bun.spawn` (cross-platform, no shell — ARCH-007). diff --git a/tests/helpers/platform-auth.test.ts b/tests/helpers/platform-auth.test.ts index 53ae00b1..eb9772e1 100644 --- a/tests/helpers/platform-auth.test.ts +++ b/tests/helpers/platform-auth.test.ts @@ -6,8 +6,8 @@ import { AUTH_HOST, isExpired, platformAuth, - PLUGINS_HOST, } from "../../src/helpers/platform-auth"; +import { PLUGINS_HOST } from "../../src/helpers/plugin-install"; import { type RecordedRequest, recordingFetch, From 8953e6f739bcf6f78fc5a4970a9acc94906d8c1d Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 01:10:40 +0200 Subject: [PATCH 16/27] fix(auth): refuse control characters in credential records and degrade without git - credentialInput throws a UserError for a host, username or password holding a line break or NUL, so an ID-token claim cannot forge a second host= line. - git() in git-credential-config returns 1 when git cannot be started, so login warns and logout hints instead of failing with an internal error. - The device-code body is read through jsonBody, keeping a proxy's HTML page a user-facing error. - LoginFlowResult.githubUser is accountName; the value is whatever the platform reports. - The helper-config test proves the reset entry lands after a system-scope helper, not only that a global entry is replaced. - The privacy policy states what the CLI itself reads from the sign-in result. Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 2 +- .../docs/nb/reference/privacy-policy.mdx | 2 +- .../docs/pt-br/reference/privacy-policy.mdx | 2 +- .../content/docs/reference/privacy-policy.mdx | 2 +- src/helpers/credential-store.ts | 23 +++++- src/helpers/git-credential-config.ts | 22 +++--- src/helpers/login-flow.ts | 4 +- src/helpers/oauth-device-flow.ts | 2 +- tests/commands/login.test.ts | 8 +-- tests/helpers/credential-store.test.ts | 37 +++++++--- tests/helpers/git-credential-config.test.ts | 72 ++++++++++++++++--- tests/helpers/login-flow.test.ts | 4 +- tests/helpers/oauth-device-flow.test.ts | 9 +++ 13 files changed, 146 insertions(+), 43 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 3b38a141..da17087a 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -5418,7 +5418,7 @@ Data is transmitted via reverse proxies at `n.archgate.dev` (analytics) and `s.a ### Archgate Plugins Service -When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate platform** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing. On your machine, the resulting tokens are handed to your configured git credential helper; with an OS-backed helper they are kept encrypted, while git's built-in `store` helper writes them in plain text. +When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate platform** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing, and reads only your username, name or email from the sign-in result to label the session. On your machine, the resulting tokens are handed to your configured git credential helper; with an OS-backed helper they are kept encrypted, while git's built-in `store` helper writes them in plain text. By creating an account, you agree to the [Terms of Service](https://archgate.dev/terms-of-service). See the [full privacy policy](https://archgate.dev/privacy-policy) for complete details. diff --git a/docs/src/content/docs/nb/reference/privacy-policy.mdx b/docs/src/content/docs/nb/reference/privacy-policy.mdx index 79770b80..993ea38d 100644 --- a/docs/src/content/docs/nb/reference/privacy-policy.mdx +++ b/docs/src/content/docs/nb/reference/privacy-policy.mdx @@ -31,7 +31,7 @@ Data overføres via reverseproksier på `n.archgate.dev` (analyse) og `s.archgat ### Archgate Plugins Service -Når du logger inn via `archgate login`, autentiserer du med GitHub gjennom **Archgate-plattformen** (`auth.archgate.dev`), som mottar profilen GitHub deler med den: ditt **navn**, din **e-postadresse**, ditt **GitHub-brukernavn** og din **avatar**. Dette brukes til å identifisere kontoen din og sende en velkomst-e-post. CLI-en spør deg selv om ingenting. På din maskin gis tokenene til den credential-helperen du har konfigurert; med en helper som bruker operativsystemet holdes de krypterte, mens gits innebygde `store`-helper skriver dem i klartekst. +Når du logger inn via `archgate login`, autentiserer du med GitHub gjennom **Archgate-plattformen** (`auth.archgate.dev`), som mottar profilen GitHub deler med den: ditt **navn**, din **e-postadresse**, ditt **GitHub-brukernavn** og din **avatar**. Dette brukes til å identifisere kontoen din og sende en velkomst-e-post. CLI-en spør deg selv om ingenting, og leser bare brukernavnet, navnet eller e-postadressen din fra innloggingsresultatet for å merke økten. På din maskin gis tokenene til den credential-helperen du har konfigurert; med en helper som bruker operativsystemet holdes de krypterte, mens gits innebygde `store`-helper skriver dem i klartekst. Ved å opprette en konto godtar du [tjenestevilkårene](https://archgate.dev/terms-of-service). Se den [fullstendige personvernerklæringen](https://archgate.dev/privacy-policy) for komplette detaljer. diff --git a/docs/src/content/docs/pt-br/reference/privacy-policy.mdx b/docs/src/content/docs/pt-br/reference/privacy-policy.mdx index 42a92aeb..78ea35c0 100644 --- a/docs/src/content/docs/pt-br/reference/privacy-policy.mdx +++ b/docs/src/content/docs/pt-br/reference/privacy-policy.mdx @@ -17,7 +17,7 @@ O Archgate coleta **análises de uso anônimas** (via PostHog) e **relatórios d ### Serviço de Plugins do Archgate -Quando você entra via `archgate login`, você se autentica com o GitHub através da **plataforma Archgate** (`auth.archgate.dev`), que recebe o perfil que o GitHub compartilha com ele: seu **nome**, **endereço de email**, **nome de usuário GitHub** e **avatar**. Isso é usado para identificar sua conta e enviar um email de boas-vindas. A própria CLI não pede nada a você. Na sua máquina, os tokens resultantes são entregues ao credential helper do git que você configurou; com um helper apoiado no sistema operacional eles ficam criptografados, enquanto o helper `store` embutido do git os grava em texto puro. Consulte a [política de privacidade completa](https://archgate.dev/privacy-policy) para detalhes completos. +Quando você entra via `archgate login`, você se autentica com o GitHub através da **plataforma Archgate** (`auth.archgate.dev`), que recebe o perfil que o GitHub compartilha com ele: seu **nome**, **endereço de email**, **nome de usuário GitHub** e **avatar**. Isso é usado para identificar sua conta e enviar um email de boas-vindas. A própria CLI não pede nada a você, e lê apenas seu nome de usuário, nome ou email do resultado do login para identificar a sessão. Na sua máquina, os tokens resultantes são entregues ao credential helper do git que você configurou; com um helper apoiado no sistema operacional eles ficam criptografados, enquanto o helper `store` embutido do git os grava em texto puro. Consulte a [política de privacidade completa](https://archgate.dev/privacy-policy) para detalhes completos. ### Como desativar diff --git a/docs/src/content/docs/reference/privacy-policy.mdx b/docs/src/content/docs/reference/privacy-policy.mdx index 1a3e44db..1ff1cc0a 100644 --- a/docs/src/content/docs/reference/privacy-policy.mdx +++ b/docs/src/content/docs/reference/privacy-policy.mdx @@ -31,7 +31,7 @@ Data is transmitted via reverse proxies at `n.archgate.dev` (analytics) and `s.a ### Archgate Plugins Service -When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate platform** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing. On your machine, the resulting tokens are handed to your configured git credential helper; with an OS-backed helper they are kept encrypted, while git's built-in `store` helper writes them in plain text. +When you sign in via `archgate login`, you authenticate with GitHub through the **Archgate platform** (`auth.archgate.dev`), which receives the profile GitHub shares with it: your **name**, **email address**, **GitHub username**, and **avatar**. This is used to identify your account and send a welcome email. The CLI itself asks you for nothing, and reads only your username, name or email from the sign-in result to label the session. On your machine, the resulting tokens are handed to your configured git credential helper; with an OS-backed helper they are kept encrypted, while git's built-in `store` helper writes them in plain text. By creating an account, you agree to the [Terms of Service](https://archgate.dev/terms-of-service). See the [full privacy policy](https://archgate.dev/privacy-policy) for complete details. diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index 81efea67..9c617c2b 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -55,16 +55,33 @@ export interface StoredCredentials { // Git credential protocol helpers // --------------------------------------------------------------------------- +/** + * Refuse a value that could forge protocol lines or truncate the record. + * + * A newline inside an account name would add a second `host=` line, and git + * takes the later one, so the token set would be filed under that host. + * + * @throws {UserError} When the value holds a line break or NUL. + */ +function protocolValue(field: string, value: string): string { + if (value.includes("\n") || value.includes("\r") || value.includes("\0")) { + throw new UserError( + `The credential ${field} contains a line break and cannot be stored.` + ); + } + return value; +} + function credentialInput( host: string, username?: string, password?: string ): string { - const lines = ["protocol=https", `host=${host}`]; + const lines = ["protocol=https", `host=${protocolValue("host", host)}`]; if (username !== undefined && username !== "") - lines.push(`username=${username}`); + lines.push(`username=${protocolValue("username", username)}`); if (password !== undefined && password !== "") - lines.push(`password=${password}`); + lines.push(`password=${protocolValue("password", password)}`); lines.push("", ""); return lines.join("\n"); } diff --git a/src/helpers/git-credential-config.ts b/src/helpers/git-credential-config.ts index d3ecda64..cdee8858 100644 --- a/src/helpers/git-credential-config.ts +++ b/src/helpers/git-credential-config.ts @@ -17,21 +17,25 @@ const HELPER_KEY = `credential.https://${PLUGINS_HOST}.helper`; const HELPER_VALUE = "!archgate credential"; /** - * Run a git command, resolving to its exit code. + * Run a git command, resolving to its exit code; 1 when git cannot start. * * The environment is passed explicitly: `Bun.spawn` snapshots the environment * at startup, so a later `Bun.env` assignment — which is how tests redirect * `GIT_CONFIG_GLOBAL` — would otherwise not reach git. */ async function git(args: string[]): Promise { - // Only the exit code matters; unread pipes can block git once it writes - // enough diagnostic output to fill them. - const proc = Bun.spawn(["git", ...args], { - stdout: "ignore", - stderr: "ignore", - env: { ...Bun.env }, - }); - return proc.exited; + try { + // Only the exit code matters; unread pipes can block git once it writes + // enough diagnostic output to fill them. + const proc = Bun.spawn(["git", ...args], { + stdout: "ignore", + stderr: "ignore", + env: { ...Bun.env }, + }); + return await proc.exited; + } catch { + return 1; + } } /** diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index feabebcd..ab5682aa 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -17,7 +17,7 @@ export interface LoginFlowResult { /** Whether credentials were obtained. */ ok: boolean; /** Signed-in account name, if login succeeded. */ - githubUser?: string; + accountName?: string; } /** @@ -73,5 +73,5 @@ export async function runLoginFlow(): Promise { logInfo( `Authenticated as ${styleText("bold", user)}. Plugin access is now available.` ); - return { ok: true, githubUser: user }; + return { ok: true, accountName: user }; } diff --git a/src/helpers/oauth-device-flow.ts b/src/helpers/oauth-device-flow.ts index 659bc309..b7159c78 100644 --- a/src/helpers/oauth-device-flow.ts +++ b/src/helpers/oauth-device-flow.ts @@ -91,7 +91,7 @@ export function deviceFlowAuth(config: DeviceFlowConfig): PlatformAuth { ); } - const parsed = DeviceCodeSchema.safeParse(await response.json()); + const parsed = DeviceCodeSchema.safeParse(await jsonBody(response)); if (!parsed.success) throw unexpectedResponse(); return { deviceCode: parsed.data.device_code, diff --git a/tests/commands/login.test.ts b/tests/commands/login.test.ts index 9078b320..4dd6e8ee 100644 --- a/tests/commands/login.test.ts +++ b/tests/commands/login.test.ts @@ -263,7 +263,7 @@ describe("login action handlers", () => { loadCredentialsSpy.mockResolvedValueOnce(null); runLoginFlowSpy.mockResolvedValueOnce({ ok: true, - githubUser: "octocat", + accountName: "octocat", }); const program = makeProgram(); @@ -280,7 +280,7 @@ describe("login action handlers", () => { loadCredentialsSpy.mockResolvedValueOnce(null); runLoginFlowSpy.mockResolvedValueOnce({ ok: true, - githubUser: "octocat", + accountName: "octocat", }); const rootSpy = spyOn(paths, "findProjectRoot").mockReturnValue( "/fake/project" @@ -303,7 +303,7 @@ describe("login action handlers", () => { loadCredentialsSpy.mockResolvedValueOnce(null); runLoginFlowSpy.mockResolvedValueOnce({ ok: true, - githubUser: "octocat", + accountName: "octocat", }); const rootSpy = spyOn(paths, "findProjectRoot").mockReturnValue(null); @@ -365,7 +365,7 @@ describe("login action handlers", () => { clearCredentialsSpy.mockResolvedValueOnce(); runLoginFlowSpy.mockResolvedValueOnce({ ok: true, - githubUser: "octocat", + accountName: "octocat", }); const program = makeProgram(); diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts index 80559830..a6bf23b7 100644 --- a/tests/helpers/credential-store.test.ts +++ b/tests/helpers/credential-store.test.ts @@ -133,6 +133,34 @@ describe("credential-store", () => { ); }); + // A newline in an account name would add a second `host=` line, and git + // takes the later one, so the token set would be filed under that host. + describe("credential protocol values", () => { + test.each([ + ["a line feed", "octo\nhost=evil.example.com"], + ["a carriage return", "octo\rhost=evil.example.com"], + ["a NUL", "octo\0cat"], + ])("refuses an account name holding %s", async (_label, user) => { + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("git must not be spawned"); + }); + try { + expect( + await rejectionMessage( + saveTokenSet(user, { + accessToken: "ey.access", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, + }) + ) + ).toContain("line break"); + expect(spawnSpy).not.toHaveBeenCalled(); + } finally { + spawnSpy.mockRestore(); + } + }); + }); + describe("loadCredentials", () => { test("returns null when no credentials exist anywhere", async () => { const result = await loadCredentials(); @@ -316,15 +344,6 @@ describe("credential-store", () => { }); }); - describe("public surface", () => { - test("exposes the store, load and clear entry points", () => { - expect(typeof saveTokenSet).toBe("function"); - expect(typeof loadTokenSet).toBe("function"); - expect(typeof loadCredentials).toBe("function"); - expect(typeof clearCredentials).toBe("function"); - }); - }); - describe("loadTokenSet", () => { test.each([ ["not JSON at all", "not-json"], diff --git a/tests/helpers/git-credential-config.test.ts b/tests/helpers/git-credential-config.test.ts index b955d01e..74aaa4be 100644 --- a/tests/helpers/git-credential-config.test.ts +++ b/tests/helpers/git-credential-config.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -16,6 +16,7 @@ const HELPER_KEY = "credential.https://plugins.archgate.dev.helper"; let tempDir: string; let gitConfigPath: string; let originalNoSystem: string | undefined; +let originalSystem: string | undefined; let originalGlobal: string | undefined; /** @@ -25,10 +26,21 @@ let originalGlobal: string | undefined; * git, and the exit code is checked before the output is trusted (ARCH-007). * Exit code 1 means the key is unset, which is an empty list rather than a * failure. + * + * @param scope - `global` reads the global file alone; `merged` reads every + * scope in the order git consults them. */ -async function configuredHelpers(): Promise { +async function configuredHelpers( + scope: "global" | "merged" = "global" +): Promise { const proc = Bun.spawn( - ["git", "config", "--global", "--get-all", HELPER_KEY], + [ + "git", + "config", + ...(scope === "global" ? ["--global"] : []), + "--get-all", + HELPER_KEY, + ], { stdout: "pipe", stderr: "pipe", env: { ...Bun.env } } ); const [stdout, stderr, exitCode] = await Promise.all([ @@ -47,6 +59,7 @@ async function configuredHelpers(): Promise { beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "archgate-gitcred-test-")); originalNoSystem = Bun.env.GIT_CONFIG_NOSYSTEM; + originalSystem = Bun.env.GIT_CONFIG_SYSTEM; originalGlobal = Bun.env.GIT_CONFIG_GLOBAL; gitConfigPath = join(tempDir, ".gitconfig"); writeFileSync(gitConfigPath, ""); @@ -56,6 +69,7 @@ beforeEach(() => { afterEach(() => { restoreEnv("GIT_CONFIG_NOSYSTEM", originalNoSystem); + restoreEnv("GIT_CONFIG_SYSTEM", originalSystem); restoreEnv("GIT_CONFIG_GLOBAL", originalGlobal); try { rmSync(tempDir, { recursive: true, force: true }); @@ -71,9 +85,7 @@ describe("registerGitCredentialHelper", () => { expect(await configuredHelpers()).toEqual(["", "!archgate credential"]); }); - // The empty entry resets helpers inherited from a broader config scope, so - // archgate is the only helper git consults for this host. - test("writes the reset entry ahead of its own", async () => { + test("replaces an entry already in the global scope", async () => { writeFileSync( gitConfigPath, `[credential "https://plugins.archgate.dev"]\n\thelper = store\n` @@ -81,9 +93,28 @@ describe("registerGitCredentialHelper", () => { await registerGitCredentialHelper(); - const helpers = await configuredHelpers(); - expect(helpers[0]).toBe(""); - expect(helpers).not.toContain("store"); + expect(await configuredHelpers()).toEqual(["", "!archgate credential"]); + }); + + // Git accumulates helpers across scopes and an empty entry discards the + // list so far, so the system helper must sit before the reset for archgate + // to be the only helper consulted for this host. + test("writes the reset entry after helpers inherited from the system scope", async () => { + const systemConfigPath = join(tempDir, "system.gitconfig"); + writeFileSync( + systemConfigPath, + `[credential "https://plugins.archgate.dev"]\n\thelper = store\n` + ); + delete Bun.env.GIT_CONFIG_NOSYSTEM; + Bun.env.GIT_CONFIG_SYSTEM = systemConfigPath; + + await registerGitCredentialHelper(); + + expect(await configuredHelpers("merged")).toEqual([ + "store", + "", + "!archgate credential", + ]); }); test("is idempotent across repeated logins", async () => { @@ -106,6 +137,18 @@ describe("registerGitCredentialHelper", () => { expect(await registerGitCredentialHelper()).toBe(false); }); + + // Login must degrade to a warning, not an internal fault, without git. + test("reports failure when git cannot be started", async () => { + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("spawn git ENOENT"); + }); + try { + expect(await registerGitCredentialHelper()).toBe(false); + } finally { + spawnSpy.mockRestore(); + } + }); }); describe("unregisterGitCredentialHelper", () => { @@ -120,4 +163,15 @@ describe("unregisterGitCredentialHelper", () => { test("succeeds when no helper is configured", async () => { expect(await unregisterGitCredentialHelper()).toBe(true); }); + + test("reports failure when git cannot be started", async () => { + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + throw new Error("spawn git ENOENT"); + }); + try { + expect(await unregisterGitCredentialHelper()).toBe(false); + } finally { + spawnSpy.mockRestore(); + } + }); }); diff --git a/tests/helpers/login-flow.test.ts b/tests/helpers/login-flow.test.ts index 70536766..058517fd 100644 --- a/tests/helpers/login-flow.test.ts +++ b/tests/helpers/login-flow.test.ts @@ -76,7 +76,7 @@ describe("runLoginFlow", () => { test("stores the token set under the signed-in identity", async () => { const result = await runLoginFlow(); - expect(result).toEqual({ ok: true, githubUser: "octocat" }); + expect(result).toEqual({ ok: true, accountName: "octocat" }); expect(mockSaveTokenSet).toHaveBeenCalledWith("octocat", TOKENS); }); @@ -116,7 +116,7 @@ describe("runLoginFlow", () => { const result = await runLoginFlow(); - expect(result.githubUser).toBe("Octo Cat"); + expect(result.accountName).toBe("Octo Cat"); }); test("fails the login when the token set cannot be persisted", async () => { diff --git a/tests/helpers/oauth-device-flow.test.ts b/tests/helpers/oauth-device-flow.test.ts index 88230e0d..26ebefa3 100644 --- a/tests/helpers/oauth-device-flow.test.ts +++ b/tests/helpers/oauth-device-flow.test.ts @@ -158,6 +158,15 @@ describe("requestDeviceCode", () => { "unexpected response" ); }); + + // A proxy's HTML error page must not escape as a SyntaxError. + test("treats a non-JSON body as an unexpected response", async () => { + responses.push(new Response("gateway", { status: 200 })); + + expect(await rejectionMessage(auth.requestDeviceCode())).toContain( + "unexpected response" + ); + }); }); describe("pollForTokens", () => { From 9b8c09371fece80237018dfc20a700e7bd228f03 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 01:23:26 +0200 Subject: [PATCH 17/27] fix(login): unregister the git helper before clearing credentials While archgate is the helper for the plugins host, the lookup for that host reaches archgate itself rather than the OS store, so a legacy token filed there was never rejected and resurfaced after logout. Logout and refresh now remove the helper entry first; logout still clears when that removal fails. Signed-off-by: Rhuan Barreto --- src/commands/login.ts | 5 +++- src/helpers/credential-store.ts | 6 +++- tests/commands/login.test.ts | 53 ++++++++++++++++++++++++++++----- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/commands/login.ts b/src/commands/login.ts index 01307827..740c9135 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -57,8 +57,10 @@ export function registerLoginCommand(program: Command) { .description("Remove stored credentials") .action(async () => { try { - await clearCredentials(); + // The helper goes first: while archgate answers for the plugins host, + // clearing cannot see a legacy token the OS store holds for it. const unregistered = await unregisterGitCredentialHelper(); + await clearCredentials(); trackLoginResult({ subcommand: "logout", success: unregistered }); if (!unregistered) { throw new UserError( @@ -76,6 +78,7 @@ export function registerLoginCommand(program: Command) { .description("Re-authenticate and claim a new token") .action(async () => { try { + await unregisterGitCredentialHelper(); await clearCredentials(); await signIn("refresh"); } catch (err) { diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index 9c617c2b..f066f3b4 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -382,7 +382,11 @@ export async function loadCredentials(): Promise { /** * Remove stored credentials (logout). - * Clears both credential entries and any legacy metadata file. + * + * Clears both credential entries and any legacy metadata file. Callers + * unregister the git credential helper first: while archgate answers for the + * plugins host, the lookup for that host reaches archgate itself rather than + * the store holding a legacy token, which would then outlive the logout. */ export async function clearCredentials(): Promise { /* oxlint-disable no-await-in-loop -- two fixed hosts, cleared in order */ diff --git a/tests/commands/login.test.ts b/tests/commands/login.test.ts index 4dd6e8ee..4ec1f676 100644 --- a/tests/commands/login.test.ts +++ b/tests/commands/login.test.ts @@ -26,6 +26,7 @@ import * as gitCredentialConfig from "../../src/helpers/git-credential-config"; import * as loginFlow from "../../src/helpers/login-flow"; import * as paths from "../../src/helpers/paths"; import * as telemetry from "../../src/helpers/telemetry"; +import { rejectionMessage } from "../test-utils"; // --------------------------------------------------------------------------- // Tests — Registration @@ -197,6 +198,38 @@ describe("login action handlers", () => { expect(allOutput).toContain("Logged out successfully"); }); + // While archgate is the helper for the plugins host, a legacy token in + // the OS store is invisible to clearing and would outlive the logout. + test("unregisters the git helper before clearing credentials", async () => { + const order: string[] = []; + unregisterHelperSpy.mockImplementation(async () => { + order.push("unregister"); + return true; + }); + clearCredentialsSpy.mockImplementation(async () => { + order.push("clear"); + }); + + const program = makeProgram(); + await program.parseAsync(["node", "test", "login", "logout"]); + + expect(order).toEqual(["unregister", "clear"]); + }); + + test("still clears credentials when the helper entry cannot be removed", async () => { + clearCredentialsSpy.mockResolvedValueOnce(); + unregisterHelperSpy.mockResolvedValue(false); + + const program = makeProgram(); + expect( + await rejectionMessage( + program.parseAsync(["node", "test", "login", "logout"]) + ) + ).toContain("exitWith(1)"); + + expect(clearCredentialsSpy).toHaveBeenCalled(); + }); + test("fails when the git credential helper entry cannot be removed", async () => { clearCredentialsSpy.mockResolvedValueOnce(); unregisterHelperSpy.mockResolvedValue(false); @@ -361,18 +394,24 @@ describe("login action handlers", () => { // ------------------------------------------------------------------------- describe("refresh", () => { - test("clears credentials then runs login flow", async () => { - clearCredentialsSpy.mockResolvedValueOnce(); - runLoginFlowSpy.mockResolvedValueOnce({ - ok: true, - accountName: "octocat", + test("unregisters the helper, clears credentials, then runs login flow", async () => { + const order: string[] = []; + unregisterHelperSpy.mockImplementation(async () => { + order.push("unregister"); + return true; + }); + clearCredentialsSpy.mockImplementation(async () => { + order.push("clear"); + }); + runLoginFlowSpy.mockImplementation(async () => { + order.push("login"); + return { ok: true, accountName: "octocat" }; }); const program = makeProgram(); await program.parseAsync(["node", "test", "login", "refresh"]); - expect(clearCredentialsSpy).toHaveBeenCalled(); - expect(runLoginFlowSpy).toHaveBeenCalled(); + expect(order).toEqual(["unregister", "clear", "login"]); }); test("exits with code 1 when refresh login flow fails", async () => { From b531edb8e5e92a0c1e7f1ab253f9e209cfb6777b Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 01:34:05 +0200 Subject: [PATCH 18/27] fix(auth): request the plugins resource on the device-code grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first access token is minted by the device-code exchange, which sent no resource indicator; only refreshed tokens carried the plugins audience. The exchange now repeats the resource (RFC 8707 §2.2). Test suites clean their temp directories through safeRmSync instead of a blanket catch. Signed-off-by: Rhuan Barreto --- src/helpers/oauth-device-flow.ts | 3 +++ tests/helpers/credential-store.test.ts | 10 +++------- tests/helpers/git-credential-config.test.ts | 10 +++------- tests/helpers/oauth-device-flow.test.ts | 5 ++++- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/helpers/oauth-device-flow.ts b/src/helpers/oauth-device-flow.ts index b7159c78..cc346888 100644 --- a/src/helpers/oauth-device-flow.ts +++ b/src/helpers/oauth-device-flow.ts @@ -111,10 +111,13 @@ export function deviceFlowAuth(config: DeviceFlowConfig): PlatformAuth { while (Date.now() < deadline) { await Bun.sleep(interval * 1000); + // The resource is repeated here (RFC 8707 §2.2) so the first access + // token is minted for the plugins audience, not only the refreshed ones. const response = await postForm(config.tokenEndpoint, { client_id: config.clientId, grant_type: DEVICE_GRANT, device_code: authorization.deviceCode, + resource: config.resource, }); const body = await jsonBody(response); diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts index a6bf23b7..66d5d256 100644 --- a/tests/helpers/credential-store.test.ts +++ b/tests/helpers/credential-store.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -14,7 +14,7 @@ import { } from "../../src/helpers/credential-store"; import { platformAuth } from "../../src/helpers/platform-auth"; import { UserError } from "../../src/helpers/user-error"; -import { rejectionMessage, restoreEnv } from "../test-utils"; +import { rejectionMessage, restoreEnv, safeRmSync } from "../test-utils"; /** * A `Bun.spawn` stand-in that answers `git credential fill` with one record. @@ -63,11 +63,7 @@ describe("credential-store", () => { restoreEnv("HOME", originalHome); restoreEnv("GIT_CONFIG_NOSYSTEM", originalGitConfigNoSystem); restoreEnv("GIT_CONFIG_GLOBAL", originalGitConfigGlobal); - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch { - /* temp dir cleanup best-effort */ - } + safeRmSync(tempDir); }); describe("saveTokenSet", () => { diff --git a/tests/helpers/git-credential-config.test.ts b/tests/helpers/git-credential-config.test.ts index 74aaa4be..779dfbe7 100644 --- a/tests/helpers/git-credential-config.test.ts +++ b/tests/helpers/git-credential-config.test.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -9,7 +9,7 @@ import { registerGitCredentialHelper, unregisterGitCredentialHelper, } from "../../src/helpers/git-credential-config"; -import { restoreEnv } from "../test-utils"; +import { restoreEnv, safeRmSync } from "../test-utils"; const HELPER_KEY = "credential.https://plugins.archgate.dev.helper"; @@ -71,11 +71,7 @@ afterEach(() => { restoreEnv("GIT_CONFIG_NOSYSTEM", originalNoSystem); restoreEnv("GIT_CONFIG_SYSTEM", originalSystem); restoreEnv("GIT_CONFIG_GLOBAL", originalGlobal); - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch { - /* temp dir cleanup best-effort */ - } + safeRmSync(tempDir); }); describe("registerGitCredentialHelper", () => { diff --git a/tests/helpers/oauth-device-flow.test.ts b/tests/helpers/oauth-device-flow.test.ts index 26ebefa3..a8bfe4d8 100644 --- a/tests/helpers/oauth-device-flow.test.ts +++ b/tests/helpers/oauth-device-flow.test.ts @@ -170,7 +170,9 @@ describe("requestDeviceCode", () => { }); describe("pollForTokens", () => { - test("polls the token endpoint with the device grant", async () => { + // The first access token must already carry the plugins audience; the + // resource is not implied by the device authorization alone. + test("polls the token endpoint with the device grant for the resource", async () => { responses.push(grantResponse()); await auth.pollForTokens(PENDING); @@ -180,6 +182,7 @@ describe("pollForTokens", () => { client_id: "client-1", grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: "device-abc", + resource: "https://api.example.test", }); }); From 552acdbc15d17d64449a9066b10d3f867c56f082 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 01:53:15 +0200 Subject: [PATCH 19/27] fix(login): register the git credential helper by absolute path Git started by an editor or a GUI client rarely inherits the shell PATH that holds archgate, and the reset entry leaves no other helper for the plugins host, so those clones got no credentials. The helper entry now names this executable by absolute path, quoted for the POSIX shell git runs it through, with backslashes turned into forward slashes for Git for Windows. Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 6 +-- .../docs/nb/reference/cli/credential.mdx | 6 +-- .../docs/pt-br/reference/cli/credential.mdx | 6 +-- .../content/docs/reference/cli/credential.mdx | 6 +-- src/helpers/git-credential-config.ts | 30 +++++++++++-- tests/helpers/git-credential-config.test.ts | 43 +++++++++++++++++-- 6 files changed, 78 insertions(+), 19 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index da17087a..c6e1cb00 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -4497,10 +4497,10 @@ The command speaks git's [credential helper protocol](https://git-scm.com/docs/g ```ini [credential "https://plugins.archgate.dev"] helper = - helper = !archgate credential + helper = !/home/you/.archgate/bin/archgate credential ``` -The empty first entry resets any helper inherited from a broader config scope, so `archgate` is the only helper consulted for this host. `archgate login logout` removes both entries. +The empty first entry resets any helper inherited from a broader config scope, so `archgate` is the only helper consulted for this host. The executable is recorded by absolute path, so git started by an editor or a GUI client finds it without your shell's PATH. `archgate login logout` removes both entries. ## Examples @@ -4538,7 +4538,7 @@ The helper is not registered. Run `archgate login` again, or check the configura git config --get-all credential.https://plugins.archgate.dev.helper ``` -The output should list an empty line followed by `!archgate credential`. If it does not, the CLI could not write your global git config -- confirm `git config --global --list` works. +The output should list an empty line followed by `!`, the absolute path of the archgate executable, and `credential`. If it does not, the CLI could not write your global git config -- confirm `git config --global --list` works. ### The helper is registered but credentials are still rejected diff --git a/docs/src/content/docs/nb/reference/cli/credential.mdx b/docs/src/content/docs/nb/reference/cli/credential.mdx index 39eb4139..198f99c9 100644 --- a/docs/src/content/docs/nb/reference/cli/credential.mdx +++ b/docs/src/content/docs/nb/reference/cli/credential.mdx @@ -32,10 +32,10 @@ Kommandoen snakker gits [credential helper-protokoll](https://git-scm.com/docs/g ```ini [credential "https://plugins.archgate.dev"] helper = - helper = !archgate credential + helper = !/home/deg/.archgate/bin/archgate credential ``` -Den tomme første oppføringen nullstiller enhver helper som er arvet fra et bredere konfigurasjonsnivå, slik at `archgate` er den eneste helperen git spør for denne verten. `archgate login logout` fjerner begge oppføringene. +Den tomme første oppføringen nullstiller enhver helper som er arvet fra et bredere konfigurasjonsnivå, slik at `archgate` er den eneste helperen git spør for denne verten. Den kjørbare filen registreres med absolutt sti, så git som startes fra en editor eller en GUI-klient finner den uten skallets PATH. `archgate login logout` fjerner begge oppføringene. ## Eksempler @@ -73,7 +73,7 @@ Helperen er ikke registrert. Kjør `archgate login` på nytt, eller sjekk konfig git config --get-all credential.https://plugins.archgate.dev.helper ``` -Utdataene skal vise en tom linje etterfulgt av `!archgate credential`. Hvis ikke, klarte ikke CLI-en å skrive den globale git-konfigurasjonen din -- bekreft at `git config --global --list` virker. +Utdataene skal vise en tom linje etterfulgt av `!`, den absolutte stien til den kjørbare archgate-filen og `credential`. Hvis ikke, klarte ikke CLI-en å skrive den globale git-konfigurasjonen din -- bekreft at `git config --global --list` virker. ### Helperen er registrert, men legitimasjonen avvises fortsatt diff --git a/docs/src/content/docs/pt-br/reference/cli/credential.mdx b/docs/src/content/docs/pt-br/reference/cli/credential.mdx index 021366be..67809527 100644 --- a/docs/src/content/docs/pt-br/reference/cli/credential.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/credential.mdx @@ -32,10 +32,10 @@ O `archgate login` grava o seguinte na sua configuração global do git: ```ini [credential "https://plugins.archgate.dev"] helper = - helper = !archgate credential + helper = !/home/voce/.archgate/bin/archgate credential ``` -A primeira entrada vazia redefine qualquer helper herdado de um escopo de configuração mais amplo, de modo que o `archgate` seja o único helper consultado para este host. O `archgate login logout` remove as duas entradas. +A primeira entrada vazia redefine qualquer helper herdado de um escopo de configuração mais amplo, de modo que o `archgate` seja o único helper consultado para este host. O executável é registrado pelo caminho absoluto, então o git iniciado por um editor ou cliente gráfico o encontra sem o PATH do seu shell. O `archgate login logout` remove as duas entradas. ## Exemplos @@ -73,7 +73,7 @@ O helper não está registrado. Execute `archgate login` novamente ou verifique git config --get-all credential.https://plugins.archgate.dev.helper ``` -A saída deve listar uma linha vazia seguida de `!archgate credential`. Se não listar, a CLI não conseguiu gravar sua configuração global do git -- confirme que `git config --global --list` funciona. +A saída deve listar uma linha vazia seguida de `!`, o caminho absoluto do executável archgate e `credential`. Se não listar, a CLI não conseguiu gravar sua configuração global do git -- confirme que `git config --global --list` funciona. ### O helper está registrado, mas as credenciais continuam sendo rejeitadas diff --git a/docs/src/content/docs/reference/cli/credential.mdx b/docs/src/content/docs/reference/cli/credential.mdx index 225d52e1..a6ef8dc5 100644 --- a/docs/src/content/docs/reference/cli/credential.mdx +++ b/docs/src/content/docs/reference/cli/credential.mdx @@ -32,10 +32,10 @@ The command speaks git's [credential helper protocol](https://git-scm.com/docs/g ```ini [credential "https://plugins.archgate.dev"] helper = - helper = !archgate credential + helper = !/home/you/.archgate/bin/archgate credential ``` -The empty first entry resets any helper inherited from a broader config scope, so `archgate` is the only helper consulted for this host. `archgate login logout` removes both entries. +The empty first entry resets any helper inherited from a broader config scope, so `archgate` is the only helper consulted for this host. The executable is recorded by absolute path, so git started by an editor or a GUI client finds it without your shell's PATH. `archgate login logout` removes both entries. ## Examples @@ -73,7 +73,7 @@ The helper is not registered. Run `archgate login` again, or check the configura git config --get-all credential.https://plugins.archgate.dev.helper ``` -The output should list an empty line followed by `!archgate credential`. If it does not, the CLI could not write your global git config -- confirm `git config --global --list` works. +The output should list an empty line followed by `!`, the absolute path of the archgate executable, and `credential`. If it does not, the CLI could not write your global git config -- confirm `git config --global --list` works. ### The helper is registered but credentials are still rejected diff --git a/src/helpers/git-credential-config.ts b/src/helpers/git-credential-config.ts index cdee8858..5dcc5eef 100644 --- a/src/helpers/git-credential-config.ts +++ b/src/helpers/git-credential-config.ts @@ -7,14 +7,38 @@ * using whatever helper the user already configured. */ +import { selfInvokeArgv } from "./install-info"; import { logDebug } from "./log"; import { PLUGINS_HOST } from "./plugin-install"; /** Config key holding the helper list for the plugins host. */ const HELPER_KEY = `credential.https://${PLUGINS_HOST}.helper`; -/** Shell form git invokes; `!` marks it as a command rather than a suffix. */ -const HELPER_VALUE = "!archgate credential"; +/** + * Quote one argument for the POSIX shell git runs `!` helpers through. + * + * Backslashes become forward slashes: Git for Windows runs helpers through + * its bundled `sh`, which treats a backslash inside quotes as an escape. + * + * @param arg - Executable path or argument. + */ +export function quoteForGitShell(arg: string): string { + const slashed = arg.replaceAll("\\", "/"); + if (/^[\w./:@%+=-]+$/u.test(slashed)) return slashed; + return `'${slashed.replaceAll("'", "'\\''")}'`; +} + +/** + * Shell form git invokes; `!` marks it as a command rather than a helper name. + * + * The executable is recorded by absolute path: git started by an editor or a + * GUI client rarely inherits the shell PATH that holds `archgate`. + */ +export function helperCommand(): string { + return `!${selfInvokeArgv(["credential"]) + .map((arg) => quoteForGitShell(arg)) + .join(" ")}`; +} /** * Run a git command, resolving to its exit code; 1 when git cannot start. @@ -50,7 +74,7 @@ async function git(args: string[]): Promise { export async function registerGitCredentialHelper(): Promise { const writes = [ ["config", "--global", "--replace-all", HELPER_KEY, ""], - ["config", "--global", "--add", HELPER_KEY, HELPER_VALUE], + ["config", "--global", "--add", HELPER_KEY, helperCommand()], ]; /* oxlint-disable no-await-in-loop -- the reset must land before the add */ for (const args of writes) { diff --git a/tests/helpers/git-credential-config.test.ts b/tests/helpers/git-credential-config.test.ts index 779dfbe7..db4fd47e 100644 --- a/tests/helpers/git-credential-config.test.ts +++ b/tests/helpers/git-credential-config.test.ts @@ -6,6 +6,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { + helperCommand, + quoteForGitShell, registerGitCredentialHelper, unregisterGitCredentialHelper, } from "../../src/helpers/git-credential-config"; @@ -74,11 +76,44 @@ afterEach(() => { safeRmSync(tempDir); }); +// Git started by an editor rarely inherits the shell PATH, so the helper +// must name the executable by absolute path rather than as `archgate`. +describe("helperCommand", () => { + test("invokes this executable by absolute path", () => { + const command = helperCommand(); + + expect(command.startsWith("!")).toBe(true); + expect(command.endsWith(" credential")).toBe(true); + expect(command).toContain( + quoteForGitShell(process.execPath).replaceAll(/^'|'$/gu, "") + ); + }); +}); + +describe("quoteForGitShell", () => { + test.each([ + ["a plain path", "/usr/local/bin/archgate", "/usr/local/bin/archgate"], + [ + "a path with spaces", + "/Users/Octo Cat/bin/archgate", + "'/Users/Octo Cat/bin/archgate'", + ], + [ + "a Windows path", + "C:\\Users\\octo\\.archgate\\bin\\archgate.exe", + "C:/Users/octo/.archgate/bin/archgate.exe", + ], + ["an embedded quote", "/tmp/o'neil/archgate", "'/tmp/o'\\''neil/archgate'"], + ])("handles %s", (_label, input, expected) => { + expect(quoteForGitShell(input)).toBe(expected); + }); +}); + describe("registerGitCredentialHelper", () => { test("records the helper for the plugins host", async () => { expect(await registerGitCredentialHelper()).toBe(true); - expect(await configuredHelpers()).toEqual(["", "!archgate credential"]); + expect(await configuredHelpers()).toEqual(["", helperCommand()]); }); test("replaces an entry already in the global scope", async () => { @@ -89,7 +124,7 @@ describe("registerGitCredentialHelper", () => { await registerGitCredentialHelper(); - expect(await configuredHelpers()).toEqual(["", "!archgate credential"]); + expect(await configuredHelpers()).toEqual(["", helperCommand()]); }); // Git accumulates helpers across scopes and an empty entry discards the @@ -109,7 +144,7 @@ describe("registerGitCredentialHelper", () => { expect(await configuredHelpers("merged")).toEqual([ "store", "", - "!archgate credential", + helperCommand(), ]); }); @@ -117,7 +152,7 @@ describe("registerGitCredentialHelper", () => { await registerGitCredentialHelper(); await registerGitCredentialHelper(); - expect(await configuredHelpers()).toEqual(["", "!archgate credential"]); + expect(await configuredHelpers()).toEqual(["", helperCommand()]); }); test("scopes the entry to the plugins host alone", async () => { From 24dc23aa74ca3bf40b39a5cea4a1e0551fd8f7a5 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 02:04:07 +0200 Subject: [PATCH 20/27] fix(auth): keep a failing sign-in service from reading as a sign-out Renewal now tells three outcomes apart: a refused grant is a SessionExpiredError, which the credential store treats as signed out; an unreachable service or a non-grant failure is a plain UserError that propagates with its cause, so the user is neither told to sign in again nor shown an internal error. A repeat login with a stored platform session re-registers the git credential helper in case the first write failed. Signed-off-by: Rhuan Barreto --- src/commands/login.ts | 14 +++- src/helpers/credential-store.ts | 10 ++- src/helpers/git-credential-config.ts | 16 +++- src/helpers/login-flow.ts | 11 +-- src/helpers/oauth-device-flow.ts | 44 +++++++--- src/helpers/session-expired-error.ts | 16 ++++ tests/commands/login.test.ts | 49 +++++++++++ .../helpers/credential-store-renewal.test.ts | 82 +++++++++++++++++++ tests/helpers/credential-store.test.ts | 60 +------------- tests/helpers/git-credential-config.test.ts | 32 ++++++++ tests/helpers/login-flow.test.ts | 23 ++---- tests/helpers/oauth-device-flow.test.ts | 34 +++++++- tests/helpers/session-expired-error.test.ts | 21 +++++ 13 files changed, 310 insertions(+), 102 deletions(-) create mode 100644 src/helpers/session-expired-error.ts create mode 100644 tests/helpers/session-expired-error.test.ts diff --git a/src/commands/login.ts b/src/commands/login.ts index 740c9135..e685f68a 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -4,9 +4,16 @@ import { styleText } from "node:util"; import type { Command } from "@commander-js/extra-typings"; -import { loadCredentials, clearCredentials } from "../helpers/credential-store"; +import { + clearCredentials, + loadCredentials, + loadTokenSet, +} from "../helpers/credential-store"; import { exitWith, handleCommandError } from "../helpers/exit"; -import { unregisterGitCredentialHelper } from "../helpers/git-credential-config"; +import { + ensureGitCredentialHelper, + unregisterGitCredentialHelper, +} from "../helpers/git-credential-config"; import { logError, logInfo } from "../helpers/log"; import { runLoginFlow } from "../helpers/login-flow"; import { findProjectRoot } from "../helpers/paths"; @@ -27,6 +34,9 @@ export function registerLoginCommand(program: Command) { `Already logged in as ${styleText("bold", existing.github_user)}.`, "Run `archgate login refresh` to sign in again." ); + // A platform session may have been stored while the helper write + // failed; a legacy token must not get the helper, which cannot serve it. + if (await loadTokenSet()) await ensureGitCredentialHelper(); return; } await signIn("login"); diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index f066f3b4..5b1e6858 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -25,6 +25,7 @@ import { TokenSetSchema, } from "./platform-auth"; import { PLUGINS_HOST } from "./plugin-install"; +import { SessionExpiredError } from "./session-expired-error"; import { UserError } from "./user-error"; const CREDENTIAL_TIMEOUT_MS = 3_000; @@ -355,9 +356,10 @@ export async function loadCredentials(): Promise { return null; } - // A rejected refresh token is an expected signed-out state, not a failure: + // A refused refresh token is an expected signed-out state, not a failure: // swallowing it here keeps the documented null contract and lets the legacy - // lookup below still answer. Anything else is a real fault and propagates. + // lookup below still answer. Anything else — the service unreachable or + // failing — propagates, so the user is not told to sign in again for it. let resolved: StoredCredentials | null = null; let authLookupTimedOut = false; try { @@ -365,8 +367,8 @@ export async function loadCredentials(): Promise { resolved = outcome.credentials; authLookupTimedOut = outcome.timedOut; } catch (error) { - if (!(error instanceof UserError)) throw error; - logDebug("stored session could not be renewed", { reason: error.message }); + if (!(error instanceof SessionExpiredError)) throw error; + logDebug("stored session is signed out", { reason: error.message }); } if (resolved) return resolved; diff --git a/src/helpers/git-credential-config.ts b/src/helpers/git-credential-config.ts index 5dcc5eef..3095b594 100644 --- a/src/helpers/git-credential-config.ts +++ b/src/helpers/git-credential-config.ts @@ -8,7 +8,7 @@ */ import { selfInvokeArgv } from "./install-info"; -import { logDebug } from "./log"; +import { logDebug, logWarn } from "./log"; import { PLUGINS_HOST } from "./plugin-install"; /** Config key holding the helper list for the plugins host. */ @@ -86,6 +86,20 @@ export async function registerGitCredentialHelper(): Promise { return true; } +/** + * Register the helper, warning rather than failing when git refuses. + * + * Downloads use the access token directly, so a missing helper entry costs + * only non-interactive `git clone` of plugin repositories. + */ +export async function ensureGitCredentialHelper(): Promise { + if (await registerGitCredentialHelper()) return; + logWarn( + "Could not register archgate as a git credential helper.", + "Plugin downloads still work; `git clone` of a plugin repository may prompt for credentials." + ); +} + /** * Remove archgate from git's credential configuration for the plugins host. * diff --git a/src/helpers/login-flow.ts b/src/helpers/login-flow.ts index ab5682aa..14a411a8 100644 --- a/src/helpers/login-flow.ts +++ b/src/helpers/login-flow.ts @@ -8,8 +8,8 @@ import { styleText } from "node:util"; import { saveTokenSet } from "./credential-store"; import { copyToClipboard, openBrowser } from "./desktop"; -import { registerGitCredentialHelper } from "./git-credential-config"; -import { logDebug, logInfo, logWarn } from "./log"; +import { ensureGitCredentialHelper } from "./git-credential-config"; +import { logDebug, logInfo } from "./log"; import { platformAuth } from "./platform-auth"; import { UserError } from "./user-error"; @@ -63,12 +63,7 @@ export async function runLoginFlow(): Promise { ); } - if (!(await registerGitCredentialHelper())) { - logWarn( - "Could not register archgate as a git credential helper.", - "Plugin downloads still work; `git clone` of a plugin repository may prompt for credentials." - ); - } + await ensureGitCredentialHelper(); logInfo( `Authenticated as ${styleText("bold", user)}. Plugin access is now available.` diff --git a/src/helpers/oauth-device-flow.ts b/src/helpers/oauth-device-flow.ts index cc346888..b832c301 100644 --- a/src/helpers/oauth-device-flow.ts +++ b/src/helpers/oauth-device-flow.ts @@ -16,6 +16,7 @@ import type { Session, TokenSet, } from "./platform-auth"; +import { SessionExpiredError } from "./session-expired-error"; import { UserError } from "./user-error"; /** Where and as whom to sign in. */ @@ -155,21 +156,36 @@ export function deviceFlowAuth(config: DeviceFlowConfig): PlatformAuth { }, async refreshAccessToken(refreshToken: string): Promise { - const response = await postForm(config.tokenEndpoint, { - client_id: config.clientId, - grant_type: "refresh_token", - refresh_token: refreshToken, - resource: config.resource, - }); - if (!response.ok) { - await response.body?.cancel(); + let response: Response; + try { + response = await postForm(config.tokenEndpoint, { + client_id: config.clientId, + grant_type: "refresh_token", + refresh_token: refreshToken, + resource: config.resource, + }); + } catch (error) { + // The cause is kept in the message: TLS interception is detected by + // matching it, and the hint depends on that. throw new UserError( - "Your session has expired. Run `archgate login` to sign in again." + "Could not reach the sign-in service to renew your session.", + `Check your connection and try again. (${errorMessage(error)})` ); } - return toTokenSet( - parseTokenResponse(await jsonBody(response)), - refreshToken + const body = await jsonBody(response); + if (response.ok) + return toTokenSet(parseTokenResponse(body), refreshToken); + + // Only a refused grant means the session is over; anything else is the + // service failing to answer, which the caller should not treat as a + // sign-out. + const error = TokenErrorSchema.safeParse(body); + if (error.success && error.data.error === "invalid_grant") { + throw new SessionExpiredError(); + } + throw new UserError( + `The sign-in service could not renew your session (HTTP ${response.status}).`, + "Try again later." ); }, }; @@ -209,6 +225,10 @@ async function jsonBody(response: Response): Promise { } } +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function unexpectedResponse(): UserError { return new UserError("Sign-in service returned an unexpected response."); } diff --git a/src/helpers/session-expired-error.ts b/src/helpers/session-expired-error.ts new file mode 100644 index 00000000..279a7172 --- /dev/null +++ b/src/helpers/session-expired-error.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { UserError } from "./user-error"; + +/** + * The platform refused the stored refresh token, so the user is signed out. + * + * Distinct from other {@link UserError}s so callers can treat it as an + * expected state rather than a failure to report. + */ +export class SessionExpiredError extends UserError { + constructor() { + super("Your session has expired. Run `archgate login` to sign in again."); + this.name = "SessionExpiredError"; + } +} diff --git a/tests/commands/login.test.ts b/tests/commands/login.test.ts index 4ec1f676..5a06d266 100644 --- a/tests/commands/login.test.ts +++ b/tests/commands/login.test.ts @@ -80,7 +80,11 @@ describe("login action handlers", () => { let logSpy: Mock; let errorSpy: Mock; let loadCredentialsSpy: Mock; + let loadTokenSetSpy: Mock; let clearCredentialsSpy: Mock; + let ensureHelperSpy: Mock< + typeof gitCredentialConfig.ensureGitCredentialHelper + >; // Stubbed so logout does not run `git config --global` against the real // machine's configuration. let unregisterHelperSpy: Mock< @@ -94,7 +98,14 @@ describe("login action handlers", () => { logSpy = spyOn(console, "log").mockImplementation(() => {}); errorSpy = spyOn(console, "error").mockImplementation(() => {}); loadCredentialsSpy = spyOn(credentialStore, "loadCredentials"); + loadTokenSetSpy = spyOn(credentialStore, "loadTokenSet").mockResolvedValue( + null + ); clearCredentialsSpy = spyOn(credentialStore, "clearCredentials"); + ensureHelperSpy = spyOn( + gitCredentialConfig, + "ensureGitCredentialHelper" + ).mockResolvedValue(); unregisterHelperSpy = spyOn( gitCredentialConfig, "unregisterGitCredentialHelper" @@ -116,7 +127,9 @@ describe("login action handlers", () => { logSpy.mockRestore(); errorSpy.mockRestore(); loadCredentialsSpy.mockRestore(); + loadTokenSetSpy.mockRestore(); clearCredentialsSpy.mockRestore(); + ensureHelperSpy.mockRestore(); unregisterHelperSpy.mockRestore(); runLoginFlowSpy.mockRestore(); exitWithSpy.mockRestore(); @@ -279,6 +292,42 @@ describe("login action handlers", () => { expect(runLoginFlowSpy).not.toHaveBeenCalled(); }); + // A first sign-in can store the session yet fail the git config write, so + // a repeat login re-registers the helper instead of being a no-op. + test("re-registers the git helper for a stored platform session", async () => { + loadCredentialsSpy.mockResolvedValueOnce({ + token: "ey.access", + github_user: "octocat", + }); + loadTokenSetSpy.mockResolvedValueOnce({ + user: "octocat", + tokens: { + accessToken: "ey.access", + refreshToken: "refresh-abc", + expiresAt: Date.now() + 3_600_000, + }, + }); + + const program = makeProgram(); + await program.parseAsync(["node", "test", "login"]); + + expect(ensureHelperSpy).toHaveBeenCalled(); + }); + + // The helper serves platform sessions only; registering it for a legacy + // token would hide that token from git. + test("leaves the git helper alone for a legacy token", async () => { + loadCredentialsSpy.mockResolvedValueOnce({ + token: "ag_beta_legacy", + github_user: "octocat", + }); + + const program = makeProgram(); + await program.parseAsync(["node", "test", "login"]); + + expect(ensureHelperSpy).not.toHaveBeenCalled(); + }); + test("exits with code 1 when login flow fails", async () => { loadCredentialsSpy.mockResolvedValueOnce(null); runLoginFlowSpy.mockResolvedValueOnce({ ok: false }); diff --git a/tests/helpers/credential-store-renewal.test.ts b/tests/helpers/credential-store-renewal.test.ts index 00902207..9d17934c 100644 --- a/tests/helpers/credential-store-renewal.test.ts +++ b/tests/helpers/credential-store-renewal.test.ts @@ -5,10 +5,14 @@ import { describe, expect, spyOn, test } from "bun:test"; import * as credentialStore from "../../src/helpers/credential-store"; import { invalidateAccessToken, + loadCredentials, resolveAccessToken, } from "../../src/helpers/credential-store"; import * as logMod from "../../src/helpers/log"; import { platformAuth } from "../../src/helpers/platform-auth"; +import { SessionExpiredError } from "../../src/helpers/session-expired-error"; +import { UserError } from "../../src/helpers/user-error"; +import { rejectionMessage } from "../test-utils"; /** A `Bun.spawn` stand-in that answers `git credential fill` with one record. */ function gitCredentialStub(username: string, password: string) { @@ -126,3 +130,81 @@ describe("credential renewal", () => { } }); }); + +// A rejected refresh token is a signed-out state, so loadCredentials keeps +// its null contract and still reaches the legacy lookup. +describe("loadCredentials with an unrenewable session", () => { + test("falls through to a legacy token", async () => { + const stale = { + accessToken: "ey.stale", + refreshToken: "refresh-old", + expiresAt: Date.now() - 1_000, + }; + let call = 0; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => { + call += 1; + // First fill answers for AUTH_HOST, the second for PLUGINS_HOST. + return call === 1 + ? gitCredentialStub("octocat", JSON.stringify(stale)) + : gitCredentialStub("octocat", "ag_beta_legacy"); + }); + const refreshSpy = spyOn( + platformAuth, + "refreshAccessToken" + ).mockRejectedValue(new SessionExpiredError()); + try { + expect(await loadCredentials()).toEqual({ + token: "ag_beta_legacy", + github_user: "octocat", + }); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); + + // The service being down must not read as "not logged in". + test("propagates a renewal failure that is not a sign-out", async () => { + const stale = { + accessToken: "ey.stale", + refreshToken: "refresh-old", + expiresAt: Date.now() - 1_000, + }; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("octocat", JSON.stringify(stale)) + ); + const refreshSpy = spyOn( + platformAuth, + "refreshAccessToken" + ).mockRejectedValue(new UserError("Could not reach the sign-in service.")); + try { + expect(await rejectionMessage(loadCredentials())).toContain( + "Could not reach" + ); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); + + test("propagates a fault that is not a signed-out state", async () => { + const stale = { + accessToken: "ey.stale", + refreshToken: "refresh-old", + expiresAt: Date.now() - 1_000, + }; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("octocat", JSON.stringify(stale)) + ); + const refreshSpy = spyOn( + platformAuth, + "refreshAccessToken" + ).mockRejectedValue(new TypeError("boom")); + try { + expect(await rejectionMessage(loadCredentials())).toContain("boom"); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); +}); diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts index 66d5d256..d498f5df 100644 --- a/tests/helpers/credential-store.test.ts +++ b/tests/helpers/credential-store.test.ts @@ -13,7 +13,7 @@ import { clearCredentials, } from "../../src/helpers/credential-store"; import { platformAuth } from "../../src/helpers/platform-auth"; -import { UserError } from "../../src/helpers/user-error"; +import { SessionExpiredError } from "../../src/helpers/session-expired-error"; import { rejectionMessage, restoreEnv, safeRmSync } from "../test-utils"; /** @@ -425,60 +425,6 @@ describe("credential-store", () => { }); }); - // A rejected refresh token is a signed-out state, so loadCredentials keeps - // its null contract and still reaches the legacy lookup. - describe("loadCredentials with an unrenewable session", () => { - test("falls through to a legacy token", async () => { - const stale = { - accessToken: "ey.stale", - refreshToken: "refresh-old", - expiresAt: Date.now() - 1_000, - }; - let call = 0; - const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => { - call += 1; - // First fill answers for AUTH_HOST, the second for PLUGINS_HOST. - return call === 1 - ? gitCredentialStub("octocat", JSON.stringify(stale)) - : gitCredentialStub("octocat", "ag_beta_legacy"); - }); - const refreshSpy = spyOn( - platformAuth, - "refreshAccessToken" - ).mockRejectedValue(new UserError("Your session has expired.")); - try { - expect(await loadCredentials()).toEqual({ - token: "ag_beta_legacy", - github_user: "octocat", - }); - } finally { - refreshSpy.mockRestore(); - fillSpy.mockRestore(); - } - }); - - test("propagates a fault that is not a signed-out state", async () => { - const stale = { - accessToken: "ey.stale", - refreshToken: "refresh-old", - expiresAt: Date.now() - 1_000, - }; - const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => - gitCredentialStub("octocat", JSON.stringify(stale)) - ); - const refreshSpy = spyOn( - platformAuth, - "refreshAccessToken" - ).mockRejectedValue(new TypeError("boom")); - try { - expect(await rejectionMessage(loadCredentials())).toContain("boom"); - } finally { - refreshSpy.mockRestore(); - fillSpy.mockRestore(); - } - }); - }); - // Git runs the helper many times per operation, so processes can race to // refresh the same expired token set. The platform rotates the refresh // token, so every exchange after the first fails. @@ -506,7 +452,7 @@ describe("credential-store", () => { const refreshSpy = spyOn( platformAuth, "refreshAccessToken" - ).mockRejectedValue(new UserError("Your session has expired.")); + ).mockRejectedValue(new SessionExpiredError()); try { expect(await resolveAccessToken()).toEqual({ token: "ey.fromWinner", @@ -525,7 +471,7 @@ describe("credential-store", () => { const refreshSpy = spyOn( platformAuth, "refreshAccessToken" - ).mockRejectedValue(new UserError("Your session has expired.")); + ).mockRejectedValue(new SessionExpiredError()); try { expect(await rejectionMessage(resolveAccessToken())).toContain( "session has expired" diff --git a/tests/helpers/git-credential-config.test.ts b/tests/helpers/git-credential-config.test.ts index db4fd47e..dc5ed47b 100644 --- a/tests/helpers/git-credential-config.test.ts +++ b/tests/helpers/git-credential-config.test.ts @@ -6,11 +6,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { + ensureGitCredentialHelper, helperCommand, quoteForGitShell, registerGitCredentialHelper, unregisterGitCredentialHelper, } from "../../src/helpers/git-credential-config"; +import * as logMod from "../../src/helpers/log"; import { restoreEnv, safeRmSync } from "../test-utils"; const HELPER_KEY = "credential.https://plugins.archgate.dev.helper"; @@ -182,6 +184,36 @@ describe("registerGitCredentialHelper", () => { }); }); +// Downloads use the access token directly, so a failed git config write must +// not fail the login — only clones lose their non-interactive credentials. +describe("ensureGitCredentialHelper", () => { + test("stays quiet when the helper is registered", async () => { + const warnSpy = spyOn(logMod, "logWarn").mockImplementation(() => {}); + try { + await ensureGitCredentialHelper(); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(await configuredHelpers()).toEqual(["", helperCommand()]); + } finally { + warnSpy.mockRestore(); + } + }); + + test("warns instead of failing when git cannot write the config", async () => { + Bun.env.GIT_CONFIG_GLOBAL = tempDir; + const warnSpy = spyOn(logMod, "logWarn").mockImplementation(() => {}); + try { + await ensureGitCredentialHelper(); + + expect(warnSpy.mock.calls.flat().join(" ")).toContain( + "Could not register archgate" + ); + } finally { + warnSpy.mockRestore(); + } + }); +}); + describe("unregisterGitCredentialHelper", () => { test("removes the entries login added", async () => { await registerGitCredentialHelper(); diff --git a/tests/helpers/login-flow.test.ts b/tests/helpers/login-flow.test.ts index 058517fd..ec8ea868 100644 --- a/tests/helpers/login-flow.test.ts +++ b/tests/helpers/login-flow.test.ts @@ -22,7 +22,7 @@ import { rejectionMessage } from "../test-utils"; let mockRequestDeviceCode: Mock; let mockPollForTokens: Mock; let mockSaveTokenSet: Mock; -let mockRegisterHelper: Mock; +let mockEnsureHelper: Mock; let mockOpenBrowser: Mock; let mockCopyToClipboard: Mock; let logSpy: Mock; @@ -51,10 +51,10 @@ beforeEach(() => { tokens: TOKENS, }); mockSaveTokenSet = spyOn(credMod, "saveTokenSet").mockResolvedValue(true); - mockRegisterHelper = spyOn( + mockEnsureHelper = spyOn( gitConfigMod, - "registerGitCredentialHelper" - ).mockResolvedValue(true); + "ensureGitCredentialHelper" + ).mockResolvedValue(); mockOpenBrowser = spyOn(desktopMod, "openBrowser").mockResolvedValue(false); mockCopyToClipboard = spyOn(desktopMod, "copyToClipboard").mockResolvedValue( false @@ -66,7 +66,7 @@ afterEach(() => { mockRequestDeviceCode.mockRestore(); mockPollForTokens.mockRestore(); mockSaveTokenSet.mockRestore(); - mockRegisterHelper.mockRestore(); + mockEnsureHelper.mockRestore(); mockOpenBrowser.mockRestore(); mockCopyToClipboard.mockRestore(); logSpy.mockRestore(); @@ -97,18 +97,7 @@ describe("runLoginFlow", () => { test("registers archgate as git's credential helper", async () => { await runLoginFlow(); - expect(mockRegisterHelper).toHaveBeenCalled(); - }); - - // Downloads use the Bearer token directly, so a failed git config write must - // not fail the login — only clones lose their non-interactive credentials. - test("still succeeds when the git config write fails", async () => { - mockRegisterHelper.mockResolvedValue(false); - - const result = await runLoginFlow(); - - expect(result.ok).toBe(true); - expect(mockSaveTokenSet).toHaveBeenCalled(); + expect(mockEnsureHelper).toHaveBeenCalled(); }); test("reports the account name the platform resolved", async () => { diff --git a/tests/helpers/oauth-device-flow.test.ts b/tests/helpers/oauth-device-flow.test.ts index a8bfe4d8..23c3956c 100644 --- a/tests/helpers/oauth-device-flow.test.ts +++ b/tests/helpers/oauth-device-flow.test.ts @@ -336,7 +336,7 @@ describe("refreshAccessToken", () => { ); }); - test("asks the user to sign in again when the grant is rejected", async () => { + test("asks the user to sign in again when the grant is refused", async () => { responses.push(errorResponse("invalid_grant")); expect( @@ -344,6 +344,38 @@ describe("refreshAccessToken", () => { ).toContain("Your session has expired"); }); + // A failing service is not a sign-out: the user must not be told to log in + // again for a 5xx, and a network fault must stay a user-facing error. + test.each([ + ["a server error", new Response("bad gateway", { status: 502 })], + ["a non-JSON client error", new Response("nope", { status: 400 })], + ])( + "reports %s as the service failing, not as a sign-out", + async (_label, response) => { + responses.push(response); + + const message = await rejectionMessage( + auth.refreshAccessToken("refresh-old") + ); + + expect(message).toContain("could not renew your session"); + expect(message).not.toContain("sign in again"); + } + ); + + test("reports an unreachable service with the underlying cause", async () => { + globalThis.fetch = recordingFetch(requests, () => { + throw new TypeError("unable to connect: CERT_HAS_EXPIRED"); + }); + + const message = await rejectionMessage( + auth.refreshAccessToken("refresh-old") + ); + + expect(message).toContain("Could not reach the sign-in service"); + expect(message).toContain("CERT_HAS_EXPIRED"); + }); + test("rejects a response that is not the expected shape", async () => { responses.push(Response.json({ nope: true })); diff --git a/tests/helpers/session-expired-error.test.ts b/tests/helpers/session-expired-error.test.ts new file mode 100644 index 00000000..aca688e0 --- /dev/null +++ b/tests/helpers/session-expired-error.test.ts @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Archgate +import { describe, expect, test } from "bun:test"; + +import { SessionExpiredError } from "../../src/helpers/session-expired-error"; +import { UserError } from "../../src/helpers/user-error"; + +describe("SessionExpiredError", () => { + // The credential store swallows this one as a signed-out state while every + // other UserError propagates, so the subclass must remain distinguishable. + test("is a UserError with its own name", () => { + const error = new SessionExpiredError(); + + expect(error).toBeInstanceOf(UserError); + expect(error.name).toBe("SessionExpiredError"); + }); + + test("tells the user to sign in again", () => { + expect(new SessionExpiredError().message).toContain("archgate login"); + }); +}); From 597f14061b3e23ea1ef2dc97428d68917ade3142 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 02:19:01 +0200 Subject: [PATCH 21/27] fix(auth): verify what was stored, report partial logout, open URLs without a shell - saveTokenSet reads back the record for the account it wrote and accepts it only when the blob matches; a helper answering an older or another user's record no longer counts as persisted. - clearCredentials reports whether every record was removed; logout fails with a hint instead of reporting success over a leftover token. - A refused platform session ends the credential lookup; the legacy store is consulted only while no platform session is stored. - TLS failures during sign-in go through handleCommandError as a UserError. - Windows and WSL open URLs through rundll32 rather than cmd or PowerShell, which parse ? and & out of the verification URL. - Suites created by this change restore spies with mock.restore(). - Docs: storage wording depends on the configured helper everywhere, headless wording names native Linux, and the legacy-token behaviour is stated as it is. Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 12 ++-- .../docs/guides/claude-code-plugin.mdx | 2 +- docs/src/content/docs/guides/security.mdx | 4 +- .../docs/nb/guides/claude-code-plugin.mdx | 2 +- docs/src/content/docs/nb/guides/security.mdx | 4 +- .../docs/nb/reference/cli/credential.mdx | 2 +- .../content/docs/nb/reference/cli/login.mdx | 4 +- .../docs/pt-br/guides/claude-code-plugin.mdx | 2 +- .../content/docs/pt-br/guides/security.mdx | 4 +- .../docs/pt-br/reference/cli/credential.mdx | 2 +- .../docs/pt-br/reference/cli/login.mdx | 4 +- .../content/docs/reference/cli/credential.mdx | 2 +- docs/src/content/docs/reference/cli/login.mdx | 4 +- src/commands/login.ts | 24 ++++--- src/helpers/credential-store.ts | 68 ++++++++++++------- src/helpers/desktop.ts | 16 ++--- tests/commands/credential.test.ts | 13 ++-- tests/commands/login.test.ts | 34 +++++++--- .../helpers/credential-store-renewal.test.ts | 45 +++++++----- tests/helpers/credential-store.test.ts | 68 ++++++++++++++++++- tests/helpers/desktop.test.ts | 31 ++++++--- tests/helpers/login-flow.test.ts | 9 +-- 22 files changed, 236 insertions(+), 120 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index c6e1cb00..fbb54169 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -1363,7 +1363,7 @@ If the `claude` CLI is on your PATH, the plugin is installed automatically via: 1. `claude plugin marketplace add https://plugins.archgate.dev/archgate.git` (registers the Archgate marketplace) 2. `claude plugin install archgate@archgate` (installs the plugin) -The marketplace URL carries no credentials -- git authenticates against `plugins.archgate.dev` using the token `archgate login` stored in your OS credential manager. +The marketplace URL carries no credentials -- git authenticates against `plugins.archgate.dev` using the token `archgate login` handed to your git credential helper. If the `claude` CLI is not found, the command prints the manual commands for you to run. @@ -2277,9 +2277,9 @@ When cloning or forking a repository that uses Archgate, the security scanner au ### Credentials -The `archgate login` command stores your authentication token in the operating system's credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. The token is used for plugin installation and is never sent to third parties beyond the Archgate plugins service. +The `archgate login` command hands your tokens to the git credential helper you have configured, via `git credential approve`. A helper backed by the operating system's credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text, so check yours with `git config --global credential.helper`. The token is used for plugin installation and is never sent to third parties beyond the Archgate plugins service. -- Plugin installation commands never embed the token in a URL or pass it as a command-line argument: git-based installs authenticate through the OS credential manager, and API downloads send the token in an `Authorization` header. Neither is visible in process listings. +- Plugin installation commands never embed the token in a URL or pass it as a command-line argument: git-based installs authenticate through your git credential helper, and API downloads send the token in an `Authorization` header. Neither is visible in process listings. - To revoke access, run `archgate login logout`. ### Self-update integrity @@ -4542,7 +4542,7 @@ The output should list an empty line followed by `!`, the absolute path of the a ### The helper is registered but credentials are still rejected -Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, the helper returns no credentials and reports the error rather than staying silent. It reads only the token set stored by `archgate login`, so the legacy-token fallback other commands have does not apply here. Run `archgate login` again. +Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, the helper returns no credentials and reports the error rather than staying silent. It reads only the token set stored by `archgate login`; a token issued before this flow existed is never served to git. Run `archgate login` again. --- @@ -4723,9 +4723,9 @@ archgate login Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate platform. The CLI opens your browser at a URL that already carries the one-time code, and copies that code to your clipboard in case the page asks for it. Both the URL and the code are printed as well, so you can finish on another machine or paste them yourself. -Neither step is required. In CI, over SSH, or on a host with no display server the CLI skips both and just prints them. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. +Neither step is required. In CI, over SSH, or on a native Linux host with no display server (WSL reaches the Windows desktop) the CLI skips both and just prints them. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. -Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI falls back to any token issued before this flow existed; when none remains it reports that the session has expired, and `archgate login` re-establishes it. +Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI reports that the session has expired, and `archgate login` re-establishes it. A token issued before this flow existed keeps working only until you sign in to the platform; from then on the platform session alone decides. Signing in also registers `archgate credential` as a git credential helper for `plugins.archgate.dev`, which is what lets `git clone` of a plugin repository authenticate without prompting. See [`archgate credential`](/reference/cli/credential/). diff --git a/docs/src/content/docs/guides/claude-code-plugin.mdx b/docs/src/content/docs/guides/claude-code-plugin.mdx index 72839d7c..c9c0aed8 100644 --- a/docs/src/content/docs/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/guides/claude-code-plugin.mdx @@ -67,7 +67,7 @@ If the `claude` CLI is on your PATH, the plugin is installed automatically via: 1. `claude plugin marketplace add https://plugins.archgate.dev/archgate.git` (registers the Archgate marketplace) 2. `claude plugin install archgate@archgate` (installs the plugin) -The marketplace URL carries no credentials -- git authenticates against `plugins.archgate.dev` using the token `archgate login` stored in your OS credential manager. +The marketplace URL carries no credentials -- git authenticates against `plugins.archgate.dev` using the token `archgate login` handed to your git credential helper. If the `claude` CLI is not found, the command prints the manual commands for you to run. diff --git a/docs/src/content/docs/guides/security.mdx b/docs/src/content/docs/guides/security.mdx index 55ede7e4..15e339c7 100644 --- a/docs/src/content/docs/guides/security.mdx +++ b/docs/src/content/docs/guides/security.mdx @@ -138,9 +138,9 @@ When cloning or forking a repository that uses Archgate, the security scanner au ### Credentials -The `archgate login` command stores your authentication token in the operating system's credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) via `git credential approve`. No credentials are written to disk as plain-text files. The token is used for plugin installation and is never sent to third parties beyond the Archgate plugins service. +The `archgate login` command hands your tokens to the git credential helper you have configured, via `git credential approve`. A helper backed by the operating system's credential manager (macOS Keychain, Windows Credential Manager, or Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text, so check yours with `git config --global credential.helper`. The token is used for plugin installation and is never sent to third parties beyond the Archgate plugins service. -- Plugin installation commands never embed the token in a URL or pass it as a command-line argument: git-based installs authenticate through the OS credential manager, and API downloads send the token in an `Authorization` header. Neither is visible in process listings. +- Plugin installation commands never embed the token in a URL or pass it as a command-line argument: git-based installs authenticate through your git credential helper, and API downloads send the token in an `Authorization` header. Neither is visible in process listings. - To revoke access, run `archgate login logout`. ### Self-update integrity diff --git a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx index 689f83ad..e41d96e0 100644 --- a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx @@ -67,7 +67,7 @@ Hvis `claude`-CLI-et er på PATH, installeres pluginen automatisk via: 1. `claude plugin marketplace add https://plugins.archgate.dev/archgate.git` (registrerer Archgate-markedsplassen) 2. `claude plugin install archgate@archgate` (installerer pluginen) -Markedsplass-URL-en inneholder ingen legitimasjon -- git autentiserer mot `plugins.archgate.dev` med tokenet `archgate login` lagret i operativsystemets legitimasjonsbehandler. +Markedsplass-URL-en inneholder ingen legitimasjon -- git autentiserer mot `plugins.archgate.dev` med tokenet `archgate login` ga til git credential-helperen din. Hvis `claude`-CLI-et ikke finnes, skriver kommandoen ut de manuelle kommandoene du må kjøre. diff --git a/docs/src/content/docs/nb/guides/security.mdx b/docs/src/content/docs/nb/guides/security.mdx index c1799629..9f354f93 100644 --- a/docs/src/content/docs/nb/guides/security.mdx +++ b/docs/src/content/docs/nb/guides/security.mdx @@ -138,9 +138,9 @@ Når du kloner eller forker et repositorium som bruker Archgate, håndhever sikk ### Legitimasjon -Kommandoen `archgate login` lagrer autentiseringstokenet ditt i operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager eller Linux libsecret) via `git credential approve`. Ingen legitimasjon skrives til disk som klartekstfiler. Tokenet brukes for plugin-installasjon og sendes aldri til tredjeparter utover Archgate plugins-tjenesten. +Kommandoen `archgate login` gir tokenene dine til den git credential-helperen du har konfigurert, via `git credential approve`. En helper som bruker operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager eller Linux libsecret) holder dem krypterte; gits innebygde `store`-helper skriver dem til `~/.git-credentials` i klartekst, så sjekk din med `git config --global credential.helper`. Tokenet brukes for plugin-installasjon og sendes aldri til tredjeparter utover Archgate plugins-tjenesten. -- Plugin-installasjonskommandoer legger aldri tokenet inn i en URL eller sender det som et kommandolinjeargument: git-baserte installasjoner autentiserer gjennom OS-legitimasjonsbehandleren, og API-nedlastinger sender tokenet i en `Authorization`-header. Ingen av delene er synlige i prosesslister. +- Plugin-installasjonskommandoer legger aldri tokenet inn i en URL eller sender det som et kommandolinjeargument: git-baserte installasjoner autentiserer gjennom git credential-helperen din, og API-nedlastinger sender tokenet i en `Authorization`-header. Ingen av delene er synlige i prosesslister. - For å tilbakekalle tilgang, kjør `archgate login logout`. ### Selvoppdateringsintegritet diff --git a/docs/src/content/docs/nb/reference/cli/credential.mdx b/docs/src/content/docs/nb/reference/cli/credential.mdx index 198f99c9..77b3d862 100644 --- a/docs/src/content/docs/nb/reference/cli/credential.mdx +++ b/docs/src/content/docs/nb/reference/cli/credential.mdx @@ -77,4 +77,4 @@ Utdataene skal vise en tom linje etterfulgt av `!`, den absolutte stien til den ### Helperen er registrert, men legitimasjonen avvises fortsatt -Bekreft at du er logget inn med `archgate login status`. Hvis refresh-tokenet er utløpt eller tilbakekalt, returnerer helperen ingen legitimasjon og melder fra om feilen. Den leser bare tokensettet `archgate login` lagret, så tilbakefallet til eldre tokener som andre kommandoer har, gjelder ikke her. Kjør `archgate login` på nytt. +Bekreft at du er logget inn med `archgate login status`. Hvis refresh-tokenet er utløpt eller tilbakekalt, returnerer helperen ingen legitimasjon og melder fra om feilen. Den leser bare tokensettet `archgate login` lagret; et token utstedt før denne flyten fantes gis aldri til git. Kjør `archgate login` på nytt. diff --git a/docs/src/content/docs/nb/reference/cli/login.mdx b/docs/src/content/docs/nb/reference/cli/login.mdx index 6b3a8289..639eb5e5 100644 --- a/docs/src/content/docs/nb/reference/cli/login.mdx +++ b/docs/src/content/docs/nb/reference/cli/login.mdx @@ -11,9 +11,9 @@ archgate login Starter en OAuth 2.0 Device Authorization Grant (RFC 8628) mot Archgate-plattformen. CLI-en åpner nettleseren på en URL som allerede inneholder engangskoden, og kopierer koden til utklippstavlen i tilfelle siden spør etter den. Både URL-en og koden skrives også ut, så du kan fullføre på en annen maskin eller lime dem inn selv. -Ingen av delene er påkrevd. I CI, over SSH eller på en maskin uten skjermserver hopper CLI-en over begge og skriver dem bare ut. Når autorisasjonen er fullført, gir CLI-en tokenene til git via `git credential approve`, så hvor de havner avhenger av hvilken credential-helper du har konfigurert. En helper som bruker operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager, Linux libsecret) holder dem krypterte; gits innebygde `store`-helper skriver dem til `~/.git-credentials` i klartekst. Sjekk din med `git config --global credential.helper`. +Ingen av delene er påkrevd. I CI, over SSH eller på en ren Linux-maskin uten skjermserver (WSL når Windows-skrivebordet) hopper CLI-en over begge og skriver dem bare ut. Når autorisasjonen er fullført, gir CLI-en tokenene til git via `git credential approve`, så hvor de havner avhenger av hvilken credential-helper du har konfigurert. En helper som bruker operativsystemets legitimasjonsbehandler (macOS Keychain, Windows Credential Manager, Linux libsecret) holder dem krypterte; gits innebygde `store`-helper skriver dem til `~/.git-credentials` i klartekst. Sjekk din med `git config --global credential.helper`. -Tilgangstokener er kortlivede. CLI-en beholder et refresh-token ved siden av dem og fornyer tilgangstokenet automatisk, så det holder normalt å logge inn én gang. Hvis selve refresh-tokenet utløper eller blir tilbakekalt, faller CLI-en tilbake på et token utstedt før denne flyten fantes; finnes ingen, melder den at økten er utløpt, og `archgate login` gjenoppretter den. +Tilgangstokener er kortlivede. CLI-en beholder et refresh-token ved siden av dem og fornyer tilgangstokenet automatisk, så det holder normalt å logge inn én gang. Hvis selve refresh-tokenet utløper eller blir tilbakekalt, melder CLI-en at økten er utløpt, og `archgate login` gjenoppretter den. Et token utstedt før denne flyten fantes virker bare til du logger inn på plattformen; fra da av er det plattformøkten alene som gjelder. Innlogging registrerer også `archgate credential` som en git credential helper for `plugins.archgate.dev`, som er det som lar `git clone` av et plugin-repositorium autentisere uten å spørre. Se [`archgate credential`](/reference/cli/credential/). diff --git a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx index 7cff4484..edf91e64 100644 --- a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx @@ -67,7 +67,7 @@ Se o CLI `claude` estiver no seu PATH, o plugin é instalado automaticamente via 1. `claude plugin marketplace add https://plugins.archgate.dev/archgate.git` (registra o marketplace do Archgate) 2. `claude plugin install archgate@archgate` (instala o plugin) -A URL do marketplace não contém credenciais -- o git se autentica em `plugins.archgate.dev` usando o token que o `archgate login` armazena no gerenciador de credenciais do sistema operacional. +A URL do marketplace não contém credenciais -- o git se autentica em `plugins.archgate.dev` usando o token que o `archgate login` entregou ao seu credential helper do git. Se o CLI `claude` não for encontrado, o comando exibe os comandos manuais para você executar. diff --git a/docs/src/content/docs/pt-br/guides/security.mdx b/docs/src/content/docs/pt-br/guides/security.mdx index bce1a72e..6764b261 100644 --- a/docs/src/content/docs/pt-br/guides/security.mdx +++ b/docs/src/content/docs/pt-br/guides/security.mdx @@ -138,9 +138,9 @@ Ao clonar ou fazer fork de um repositório que usa Archgate, o scanner de segura ### Credenciais -O comando `archgate login` armazena seu token de autenticação no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager ou libsecret no Linux) via `git credential approve`. Nenhuma credencial é gravada em disco como arquivo de texto. O token é usado para instalação de plugins e nunca é enviado a terceiros além do serviço de plugins do Archgate. +O comando `archgate login` entrega seus tokens ao credential helper do git que você configurou, via `git credential approve`. Um helper apoiado no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager ou libsecret no Linux) os mantém criptografados; o helper `store` embutido do git os grava em `~/.git-credentials` em texto puro, então confira o seu com `git config --global credential.helper`. O token é usado para instalação de plugins e nunca é enviado a terceiros além do serviço de plugins do Archgate. -- Comandos de instalação de plugins nunca embutem o token em uma URL nem o passam como argumento de linha de comando: instalações baseadas em git se autenticam através do gerenciador de credenciais do SO, e downloads via API enviam o token em um cabeçalho `Authorization`. Nenhum dos dois fica visível em listagens de processos. +- Comandos de instalação de plugins nunca embutem o token em uma URL nem o passam como argumento de linha de comando: instalações baseadas em git se autenticam através do seu credential helper do git, e downloads via API enviam o token em um cabeçalho `Authorization`. Nenhum dos dois fica visível em listagens de processos. - Para revogar acesso, rode `archgate login logout`. ### Integridade da atualização diff --git a/docs/src/content/docs/pt-br/reference/cli/credential.mdx b/docs/src/content/docs/pt-br/reference/cli/credential.mdx index 67809527..1c63e3af 100644 --- a/docs/src/content/docs/pt-br/reference/cli/credential.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/credential.mdx @@ -77,4 +77,4 @@ A saída deve listar uma linha vazia seguida de `!`, o caminho absoluto do execu ### O helper está registrado, mas as credenciais continuam sendo rejeitadas -Confirme que você está autenticado com `archgate login status`. Se o refresh token expirou ou foi revogado, o helper não retorna credenciais e informa o erro. Ele lê apenas o conjunto de tokens armazenado pelo `archgate login`, então o recurso a tokens antigos que outros comandos têm não se aplica aqui. Execute `archgate login` novamente. +Confirme que você está autenticado com `archgate login status`. Se o refresh token expirou ou foi revogado, o helper não retorna credenciais e informa o erro. Ele lê apenas o conjunto de tokens armazenado pelo `archgate login`; um token emitido antes deste fluxo existir nunca é entregue ao git. Execute `archgate login` novamente. diff --git a/docs/src/content/docs/pt-br/reference/cli/login.mdx b/docs/src/content/docs/pt-br/reference/cli/login.mdx index fc6c337f..07004941 100644 --- a/docs/src/content/docs/pt-br/reference/cli/login.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/login.mdx @@ -11,9 +11,9 @@ archgate login Inicia um OAuth 2.0 Device Authorization Grant (RFC 8628) na plataforma Archgate. A CLI abre seu navegador em uma URL que já contém o código de uso único e copia esse código para a área de transferência, caso a página o solicite. A URL e o código também são exibidos, então você pode concluir em outra máquina ou colá-los manualmente. -Nenhuma das duas etapas é obrigatória. Em CI, via SSH ou em uma máquina sem servidor gráfico, a CLI pula ambas e apenas os exibe. Após a autorização, a CLI entrega os tokens ao git via `git credential approve`, então onde eles ficam depende do credential helper que você tem configurado. Um helper apoiado no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager, libsecret no Linux) os mantém criptografados; o helper `store` embutido do git os grava em `~/.git-credentials` em texto puro. Verifique o seu com `git config --global credential.helper`. +Nenhuma das duas etapas é obrigatória. Em CI, via SSH ou em uma máquina Linux nativa sem servidor gráfico (o WSL alcança a área de trabalho do Windows), a CLI pula ambas e apenas os exibe. Após a autorização, a CLI entrega os tokens ao git via `git credential approve`, então onde eles ficam depende do credential helper que você tem configurado. Um helper apoiado no gerenciador de credenciais do sistema operacional (macOS Keychain, Windows Credential Manager, libsecret no Linux) os mantém criptografados; o helper `store` embutido do git os grava em `~/.git-credentials` em texto puro. Verifique o seu com `git config --global credential.helper`. -Os tokens de acesso têm vida curta. A CLI mantém um refresh token junto deles e renova o token de acesso automaticamente, então normalmente basta entrar uma vez. Se o próprio refresh token expirar ou for revogado, a CLI recorre a um token emitido antes deste fluxo existir; se não houver nenhum, informa que a sessão expirou, e o `archgate login` a restabelece. +Os tokens de acesso têm vida curta. A CLI mantém um refresh token junto deles e renova o token de acesso automaticamente, então normalmente basta entrar uma vez. Se o próprio refresh token expirar ou for revogado, a CLI informa que a sessão expirou, e o `archgate login` a restabelece. Um token emitido antes deste fluxo existir continua funcionando apenas até você entrar na plataforma; a partir daí, somente a sessão da plataforma decide. Entrar também registra `archgate credential` como um credential helper do git para `plugins.archgate.dev`, que é o que permite ao `git clone` de um repositório de plugin autenticar sem solicitar credenciais. Veja [`archgate credential`](/reference/cli/credential/). diff --git a/docs/src/content/docs/reference/cli/credential.mdx b/docs/src/content/docs/reference/cli/credential.mdx index a6ef8dc5..78101458 100644 --- a/docs/src/content/docs/reference/cli/credential.mdx +++ b/docs/src/content/docs/reference/cli/credential.mdx @@ -77,4 +77,4 @@ The output should list an empty line followed by `!`, the absolute path of the a ### The helper is registered but credentials are still rejected -Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, the helper returns no credentials and reports the error rather than staying silent. It reads only the token set stored by `archgate login`, so the legacy-token fallback other commands have does not apply here. Run `archgate login` again. +Confirm you are signed in with `archgate login status`. If the refresh token has expired or been revoked, the helper returns no credentials and reports the error rather than staying silent. It reads only the token set stored by `archgate login`; a token issued before this flow existed is never served to git. Run `archgate login` again. diff --git a/docs/src/content/docs/reference/cli/login.mdx b/docs/src/content/docs/reference/cli/login.mdx index bc31136a..1ea2b62f 100644 --- a/docs/src/content/docs/reference/cli/login.mdx +++ b/docs/src/content/docs/reference/cli/login.mdx @@ -11,9 +11,9 @@ archgate login Starts an OAuth 2.0 Device Authorization Grant (RFC 8628) against the Archgate platform. The CLI opens your browser at a URL that already carries the one-time code, and copies that code to your clipboard in case the page asks for it. Both the URL and the code are printed as well, so you can finish on another machine or paste them yourself. -Neither step is required. In CI, over SSH, or on a host with no display server the CLI skips both and just prints them. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. +Neither step is required. In CI, over SSH, or on a native Linux host with no display server (WSL reaches the Windows desktop) the CLI skips both and just prints them. Once authorized, the CLI hands the resulting tokens to git via `git credential approve`, so where they end up depends on the credential helper you have configured. A helper backed by your OS credential manager (macOS Keychain, Windows Credential Manager, Linux libsecret) keeps them encrypted; git's built-in `store` helper writes them to `~/.git-credentials` in plain text. Check yours with `git config --global credential.helper`. -Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI falls back to any token issued before this flow existed; when none remains it reports that the session has expired, and `archgate login` re-establishes it. +Access tokens are short-lived. The CLI keeps a refresh token alongside them and renews the access token automatically, so signing in once is normally enough. If the refresh token itself expires or is revoked, the CLI reports that the session has expired, and `archgate login` re-establishes it. A token issued before this flow existed keeps working only until you sign in to the platform; from then on the platform session alone decides. Signing in also registers `archgate credential` as a git credential helper for `plugins.archgate.dev`, which is what lets `git clone` of a plugin repository authenticate without prompting. See [`archgate credential`](/reference/cli/credential/). diff --git a/src/commands/login.ts b/src/commands/login.ts index e685f68a..0ecc9d3e 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -14,7 +14,7 @@ import { ensureGitCredentialHelper, unregisterGitCredentialHelper, } from "../helpers/git-credential-config"; -import { logError, logInfo } from "../helpers/log"; +import { logInfo } from "../helpers/log"; import { runLoginFlow } from "../helpers/login-flow"; import { findProjectRoot } from "../helpers/paths"; import { trackLoginResult } from "../helpers/telemetry"; @@ -70,8 +70,17 @@ export function registerLoginCommand(program: Command) { // The helper goes first: while archgate answers for the plugins host, // clearing cannot see a legacy token the OS store holds for it. const unregistered = await unregisterGitCredentialHelper(); - await clearCredentials(); - trackLoginResult({ subcommand: "logout", success: unregistered }); + const cleared = await clearCredentials(); + trackLoginResult({ + subcommand: "logout", + success: unregistered && cleared, + }); + if (!cleared) { + throw new UserError( + "Some credentials could not be removed from your git credential manager.", + "Check `git config --global credential.helper` and run `archgate login logout` again." + ); + } if (!unregistered) { throw new UserError( "Credentials removed, but the git credential helper entry could not be removed. Remove it with `git config --global --unset-all credential.https://plugins.archgate.dev.helper`." @@ -129,12 +138,9 @@ async function handleSignInError( success: false, failure_reason: tls ? "tls" : "other", }); - if (tls) { - logError(tlsHintMessage()); - await exitWith(1); - return; - } - await handleCommandError(err); + // The hint replaces the raw error; as a UserError it keeps exit code 1 and + // the handler's expected-error classification. + await handleCommandError(tls ? new UserError(tlsHintMessage()) : err); } function printNextStep(): void { diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index 5b1e6858..a679737c 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -124,12 +124,22 @@ function noCredentials(timedOut: boolean): FillResult { return { credentials: null, timedOut }; } -async function gitCredentialFill(host: string): Promise { +/** + * Ask git for the record filed under a host. + * + * @param host - Host the record is filed under. + * @param account - When given, selects that account's record rather than + * whichever the helper answers first. + */ +async function gitCredentialFill( + host: string, + account?: string +): Promise { try { // stderr is ignored rather than piped: nothing reads it, and an unread // pipe can fill and block the helper (ARCH-007). const proc = Bun.spawn(["git", "credential", "fill"], { - stdin: new Blob([credentialInput(host)]), + stdin: new Blob([credentialInput(host, account)]), stdout: "pipe", stderr: "ignore", env: gitCredentialEnv(), @@ -217,12 +227,8 @@ export async function saveTokenSet( ): Promise { await cleanupLegacyMetadata(); - const stored = await gitCredential( - "approve", - AUTH_HOST, - user, - JSON.stringify(tokens) - ); + const serialized = JSON.stringify(tokens); + const stored = await gitCredential("approve", AUTH_HOST, user, serialized); if (!stored) { logWarn( "git credential approve failed.", @@ -232,7 +238,10 @@ export async function saveTokenSet( return false; } - if ((await gitCredentialFill(AUTH_HOST)).credentials) { + // The read-back asks for this account and must return this very blob: a + // helper may otherwise answer with an older record or another user's. + const { credentials } = await gitCredentialFill(AUTH_HOST, user); + if (credentials?.password === serialized) { logDebug("Token set verified in git credential manager"); return true; } @@ -340,8 +349,9 @@ async function renew(stale: Session): Promise { /** * Load archgate credentials, renewing a lapsed access token on the way. * - * Tokens issued before platform sign-in are filed under {@link PLUGINS_HOST} - * and are returned unchanged; they keep working until the user signs in again. + * A token issued before platform sign-in is filed under {@link PLUGINS_HOST} + * and is returned unchanged, but only while no platform session is stored: + * once the user has signed in, that session alone decides. * * @returns The stored credentials, or `null` when none are stored. */ @@ -356,10 +366,11 @@ export async function loadCredentials(): Promise { return null; } - // A refused refresh token is an expected signed-out state, not a failure: - // swallowing it here keeps the documented null contract and lets the legacy - // lookup below still answer. Anything else — the service unreachable or - // failing — propagates, so the user is not told to sign in again for it. + // A refused refresh token is an expected signed-out state, not a failure, + // and it ends the lookup: the legacy store is not consulted for a user who + // has signed in to the platform, and while archgate is git's helper for + // the plugins host that lookup would only reach archgate itself. Anything + // else — the service unreachable or failing — propagates. let resolved: StoredCredentials | null = null; let authLookupTimedOut = false; try { @@ -369,6 +380,7 @@ export async function loadCredentials(): Promise { } catch (error) { if (!(error instanceof SessionExpiredError)) throw error; logDebug("stored session is signed out", { reason: error.message }); + return null; } if (resolved) return resolved; @@ -389,23 +401,31 @@ export async function loadCredentials(): Promise { * unregister the git credential helper first: while archgate answers for the * plugins host, the lookup for that host reaches archgate itself rather than * the store holding a legacy token, which would then outlive the logout. + * + * @returns `true` when every record found was removed. */ -export async function clearCredentials(): Promise { +export async function clearCredentials(): Promise { + let cleared = true; /* oxlint-disable no-await-in-loop -- two fixed hosts, cleared in order */ for (const host of [AUTH_HOST, PLUGINS_HOST]) { const { credentials } = await gitCredentialFill(host); - if (credentials) { - await gitCredential( - "reject", - host, - credentials.username, - credentials.password - ); - logDebug("Credentials removed from git credential manager"); + if (!credentials) continue; + const rejected = await gitCredential( + "reject", + host, + credentials.username, + credentials.password + ); + if (rejected) { + logDebug("Credentials removed from git credential manager", { host }); + } else { + logDebug("git credential reject failed", { host }); + cleared = false; } } /* oxlint-enable no-await-in-loop */ await cleanupLegacyMetadata(); + return cleared; } /** diff --git a/src/helpers/desktop.ts b/src/helpers/desktop.ts index a88340a9..6d94710f 100644 --- a/src/helpers/desktop.ts +++ b/src/helpers/desktop.ts @@ -77,20 +77,18 @@ async function run(command: string[], stdin?: string): Promise { /** Browser launchers to try, in order, for the current platform. */ function browserCommands(url: string): string[][] { if (isMacOS()) return [["open", url]]; - // WSL reaches the Windows desktop; wslview ships with wslu, and the two - // PowerShell binaries cover distributions that lack it. + // rundll32 hands the URL to the default browser without a shell in + // between: `cmd /c start` and PowerShell both parse `&` and `?` out of it. + const windowsHandler = ["url.dll,FileProtocolHandler", url]; + // WSL reaches the Windows desktop; wslview ships with wslu, and rundll32 + // covers distributions that lack it. if (isWSL()) { return [ ["wslview", url], - ["powershell.exe", "-NoProfile", "-Command", "Start-Process", url], - ["cmd.exe", "/c", "start", "", url], + ["rundll32.exe", ...windowsHandler], ]; } - if (isWindows()) { - // The empty string is `start`'s title argument; without it a quoted URL - // is taken as the window title and no browser opens. - return [["cmd", "/c", "start", "", url]]; - } + if (isWindows()) return [["rundll32", ...windowsHandler]]; return [ ["xdg-open", url], ["gio", "open", url], diff --git a/tests/commands/credential.test.ts b/tests/commands/credential.test.ts index 91044314..ba0bd5ba 100644 --- a/tests/commands/credential.test.ts +++ b/tests/commands/credential.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, + mock, type Mock, spyOn, test, @@ -17,7 +18,6 @@ import * as credMod from "../../src/helpers/credential-store"; import * as exitModule from "../../src/helpers/exit"; import { rejectionMessage } from "../test-utils"; -let stdinSpy: Mock; let stdoutSpy: Mock; let resolveSpy: Mock; let invalidateSpy: Mock; @@ -29,7 +29,7 @@ function written(): string { /** Run a `credential` subcommand with the given request on stdin. */ async function run(subcommand: string, request: string): Promise { - stdinSpy = spyOn(Bun.stdin, "text").mockResolvedValue(request); + spyOn(Bun.stdin, "text").mockResolvedValue(request); const program = new Command(); program.exitOverride(); registerCredentialCommand(program); @@ -50,10 +50,7 @@ beforeEach(() => { }); afterEach(() => { - stdinSpy.mockRestore(); - stdoutSpy.mockRestore(); - resolveSpy.mockRestore(); - invalidateSpy.mockRestore(); + mock.restore(); }); describe("archgate credential get", () => { @@ -125,9 +122,7 @@ describe("error boundaries", () => { test.each(["get", "store", "erase"])( "%s routes a stdin failure to the error handler", async (subcommand) => { - stdinSpy = spyOn(Bun.stdin, "text").mockRejectedValue( - new Error("stdin closed") - ); + spyOn(Bun.stdin, "text").mockRejectedValue(new Error("stdin closed")); const exitSpy = spyOn(exitModule, "exitWith").mockImplementation(() => { throw new Error("process.exit"); }); diff --git a/tests/commands/login.test.ts b/tests/commands/login.test.ts index 5a06d266..90adbe43 100644 --- a/tests/commands/login.test.ts +++ b/tests/commands/login.test.ts @@ -199,7 +199,7 @@ describe("login action handlers", () => { describe("logout", () => { test("calls clearCredentials and prints success", async () => { - clearCredentialsSpy.mockResolvedValueOnce(); + clearCredentialsSpy.mockResolvedValueOnce(true); const program = makeProgram(); await program.parseAsync(["node", "test", "login", "logout"]); @@ -221,6 +221,7 @@ describe("login action handlers", () => { }); clearCredentialsSpy.mockImplementation(async () => { order.push("clear"); + return true; }); const program = makeProgram(); @@ -229,8 +230,24 @@ describe("login action handlers", () => { expect(order).toEqual(["unregister", "clear"]); }); + test("reports a partial logout when a record cannot be removed", async () => { + clearCredentialsSpy.mockResolvedValueOnce(false); + + const program = makeProgram(); + expect( + await rejectionMessage( + program.parseAsync(["node", "test", "login", "logout"]) + ) + ).toContain("exitWith(1)"); + + const allErrors = errorSpy.mock.calls + .map((c: unknown[]) => c.map(String).join(" ")) + .join("\n"); + expect(allErrors).toContain("could not be removed"); + }); + test("still clears credentials when the helper entry cannot be removed", async () => { - clearCredentialsSpy.mockResolvedValueOnce(); + clearCredentialsSpy.mockResolvedValueOnce(true); unregisterHelperSpy.mockResolvedValue(false); const program = makeProgram(); @@ -244,7 +261,7 @@ describe("login action handlers", () => { }); test("fails when the git credential helper entry cannot be removed", async () => { - clearCredentialsSpy.mockResolvedValueOnce(); + clearCredentialsSpy.mockResolvedValueOnce(true); unregisterHelperSpy.mockResolvedValue(false); const program = makeProgram(); @@ -414,7 +431,7 @@ describe("login action handlers", () => { "exitWith(1)" ); - expect(exitWithSpy).toHaveBeenCalledWith(1); + expect(exitWithSpy.mock.calls.at(-1)?.[0]).toBe(1); const allErrors = errorSpy.mock.calls .map((c: unknown[]) => c.map(String).join(" ")) .join("\n"); @@ -451,6 +468,7 @@ describe("login action handlers", () => { }); clearCredentialsSpy.mockImplementation(async () => { order.push("clear"); + return true; }); runLoginFlowSpy.mockImplementation(async () => { order.push("login"); @@ -464,7 +482,7 @@ describe("login action handlers", () => { }); test("exits with code 1 when refresh login flow fails", async () => { - clearCredentialsSpy.mockResolvedValueOnce(); + clearCredentialsSpy.mockResolvedValueOnce(true); runLoginFlowSpy.mockResolvedValueOnce({ ok: false }); const program = makeProgram(); @@ -477,7 +495,7 @@ describe("login action handlers", () => { }); test("exits with code 1 and prints TLS hint on TLS error during refresh", async () => { - clearCredentialsSpy.mockResolvedValueOnce(); + clearCredentialsSpy.mockResolvedValueOnce(true); runLoginFlowSpy.mockRejectedValueOnce( new Error("unable to verify the first certificate") ); @@ -487,7 +505,7 @@ describe("login action handlers", () => { program.parseAsync(["node", "test", "login", "refresh"]) ).rejects.toThrow("exitWith(1)"); - expect(exitWithSpy).toHaveBeenCalledWith(1); + expect(exitWithSpy.mock.calls.at(-1)?.[0]).toBe(1); const allErrors = errorSpy.mock.calls .map((c: unknown[]) => c.map(String).join(" ")) .join("\n"); @@ -495,7 +513,7 @@ describe("login action handlers", () => { }); test("exits with code 2 on non-TLS unexpected error during refresh", async () => { - clearCredentialsSpy.mockResolvedValueOnce(); + clearCredentialsSpy.mockResolvedValueOnce(true); runLoginFlowSpy.mockRejectedValueOnce(new Error("server unreachable")); const program = makeProgram(); diff --git a/tests/helpers/credential-store-renewal.test.ts b/tests/helpers/credential-store-renewal.test.ts index 9d17934c..c9e2beb3 100644 --- a/tests/helpers/credential-store-renewal.test.ts +++ b/tests/helpers/credential-store-renewal.test.ts @@ -37,9 +37,16 @@ describe("credential renewal", () => { // The refresh token must survive: git erases on any rejection, and losing // it would turn a recoverable 401 into a full device-flow login. test("expires the access token but keeps the refresh token", async () => { - const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => - gitCredentialStub("octocat", JSON.stringify(tokens)) - ); + // Calls run load-fill, approve, verify-fill; the verification must see + // the blob that was just approved. + let call = 0; + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + call += 1; + return gitCredentialStub( + "octocat", + JSON.stringify(call >= 3 ? { ...tokens, expiresAt: 0 } : tokens) + ); + }); try { expect(await invalidateAccessToken()).toBe(true); @@ -131,32 +138,34 @@ describe("credential renewal", () => { }); }); -// A rejected refresh token is a signed-out state, so loadCredentials keeps -// its null contract and still reaches the legacy lookup. +// A refused refresh token is a signed-out state, and it ends the lookup: the +// legacy store is not consulted for a user who has signed in to the platform. describe("loadCredentials with an unrenewable session", () => { - test("falls through to a legacy token", async () => { + test("reports signed out without consulting the legacy store", async () => { const stale = { accessToken: "ey.stale", refreshToken: "refresh-old", expiresAt: Date.now() - 1_000, }; - let call = 0; - const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => { - call += 1; - // First fill answers for AUTH_HOST, the second for PLUGINS_HOST. - return call === 1 - ? gitCredentialStub("octocat", JSON.stringify(stale)) - : gitCredentialStub("octocat", "ag_beta_legacy"); - }); + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("octocat", JSON.stringify(stale)) + ); const refreshSpy = spyOn( platformAuth, "refreshAccessToken" ).mockRejectedValue(new SessionExpiredError()); try { - expect(await loadCredentials()).toEqual({ - token: "ag_beta_legacy", - github_user: "octocat", - }); + expect(await loadCredentials()).toBeNull(); + + const hosts = await Promise.all( + fillSpy.mock.calls + .filter((call) => [...call[0]].includes("fill")) + .map(async (call) => { + const stdin = (call[1] as { stdin?: unknown } | undefined)?.stdin; + return stdin instanceof Blob ? stdin.text() : ""; + }) + ); + expect(hosts.join("")).not.toContain("plugins.archgate.dev"); } finally { refreshSpy.mockRestore(); fillSpy.mockRestore(); diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts index d498f5df..a68843e9 100644 --- a/tests/helpers/credential-store.test.ts +++ b/tests/helpers/credential-store.test.ts @@ -189,8 +189,29 @@ describe("credential-store", () => { }); describe("clearCredentials", () => { + // A record the helper refuses to drop must not let logout report success. + test("reports failure when git cannot reject a record", async () => { + let call = 0; + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + call += 1; + // Odd calls are fills answering a record; even calls are rejects. + const stub = gitCredentialStub("octocat", "ey.access"); + return call % 2 === 0 + ? // oxlint-disable-next-line typescript/no-unsafe-type-assertion + ({ ...stub, exited: Promise.resolve(1) } as unknown as ReturnType< + typeof Bun.spawn + >) + : stub; + }); + try { + expect(await clearCredentials()).toBe(false); + } finally { + spawnSpy.mockRestore(); + } + }); + test("does not throw when no credentials exist", async () => { - expect(clearCredentials()).resolves.toBeUndefined(); + expect(await clearCredentials()).toBe(true); }); test("cleans up legacy metadata file", async () => { @@ -220,6 +241,51 @@ describe("credential-store", () => { }); }); + // `git credential approve` exits 0 even when nothing was stored, so the + // read-back must return this very blob for this account. + describe("saveTokenSet verification", () => { + const tokens = { + accessToken: "ey.access", + refreshToken: "refresh-abc", + expiresAt: 1_800_000_000_000, + }; + + test("asks for the account it wrote and rejects a different blob", async () => { + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("octocat", "stale-blob") + ); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(await saveTokenSet("octocat", tokens)).toBe(false); + + const fill = spawnSpy.mock.calls.find((call) => + [...call[0]].includes("fill") + ); + const stdin = (fill?.[1] as { stdin?: unknown } | undefined)?.stdin; + expect(stdin instanceof Blob ? await stdin.text() : "").toContain( + "username=octocat" + ); + expect(warnSpy.mock.calls.flat().join(" ")).toContain( + "could not be verified" + ); + } finally { + warnSpy.mockRestore(); + spawnSpy.mockRestore(); + } + }); + + test("accepts the read-back when it matches", async () => { + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => + gitCredentialStub("octocat", JSON.stringify(tokens)) + ); + try { + expect(await saveTokenSet("octocat", tokens)).toBe(true); + } finally { + spawnSpy.mockRestore(); + } + }); + }); + describe("credential fill with store helper", () => { test("round-trips credentials through a file-based credential helper", async () => { // A store-based helper persists to a plain file, exercising the diff --git a/tests/helpers/desktop.test.ts b/tests/helpers/desktop.test.ts index acb578f0..12d0b949 100644 --- a/tests/helpers/desktop.test.ts +++ b/tests/helpers/desktop.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, + mock, type Mock, spyOn, test, @@ -85,8 +86,7 @@ beforeEach(() => { }); afterEach(() => { - for (const spy of platformSpies) spy.mockRestore(); - spawnSpy.mockRestore(); + mock.restore(); for (const key of ENV_KEYS) restoreEnv(key, originalEnv[key]); }); @@ -154,24 +154,33 @@ describe("openBrowser", () => { expect(captured()[0]).toEqual([...expected]); }); - // `start`'s first quoted argument is the window title, so it must be empty - // or the URL is consumed as a title and no browser opens. - test("passes an empty title to Windows start", async () => { + // `cmd /c start` and PowerShell parse `?` and `&` out of a URL, so the + // complete verification URL would be truncated before the browser saw it. + test("hands Windows the URL without a shell in between", async () => { for (const spy of platformSpies) spy.mockRestore(); onPlatform("windows"); spawnExits(0); - await openBrowser("https://auth.archgate.dev/device"); + await openBrowser( + "https://auth.archgate.dev/device?user_code=HZML-HXLB&x=1" + ); expect(captured()[0]).toEqual([ - "cmd", - "/c", - "start", - "", - "https://auth.archgate.dev/device", + "rundll32", + "url.dll,FileProtocolHandler", + "https://auth.archgate.dev/device?user_code=HZML-HXLB&x=1", ]); }); + test("falls back to rundll32 on WSL without wslview", async () => { + for (const spy of platformSpies) spy.mockRestore(); + onPlatform("wsl"); + spawnExits(1, 0); + + expect(await openBrowser("https://auth.archgate.dev/device")).toBe(true); + expect(captured()[1]?.[0]).toBe("rundll32.exe"); + }); + test("falls through to the next launcher when the first is missing", async () => { spawnExits(1, 0); diff --git a/tests/helpers/login-flow.test.ts b/tests/helpers/login-flow.test.ts index ec8ea868..e73cb020 100644 --- a/tests/helpers/login-flow.test.ts +++ b/tests/helpers/login-flow.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, + mock, type Mock, spyOn, test, @@ -63,13 +64,7 @@ beforeEach(() => { }); afterEach(() => { - mockRequestDeviceCode.mockRestore(); - mockPollForTokens.mockRestore(); - mockSaveTokenSet.mockRestore(); - mockEnsureHelper.mockRestore(); - mockOpenBrowser.mockRestore(); - mockCopyToClipboard.mockRestore(); - logSpy.mockRestore(); + mock.restore(); }); describe("runLoginFlow", () => { From 38d0f30418baf50a1b89566717c4d9d4053d64e6 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 03:10:24 +0200 Subject: [PATCH 22/27] fix(auth): keep sign-in and logout honest about transport and helper failures - requestDeviceCode reports an unreachable service as a UserError with its cause; pollForTokens retries after a transport failure until the code expires instead of aborting the sign-in. - clearCredentials counts a fill timeout as a host it could not clear, and login refresh stops on a failed clear instead of signing in over it. - The credential helper skips the git install check at startup: git started it, stdout is the protocol channel, and an installer must not run there. Signed-off-by: Rhuan Barreto --- src/cli.ts | 9 ++- src/commands/credential.ts | 13 ++++ src/commands/login.ts | 19 +++-- src/helpers/credential-store.ts | 8 +- src/helpers/oauth-device-flow.ts | 77 +++++++++++++------ tests/commands/credential.test.ts | 18 ++++- tests/commands/login.test.ts | 77 +++++++++---------- .../helpers/credential-store-renewal.test.ts | 24 ++++++ tests/helpers/oauth-device-flow.test.ts | 27 +++++++ 9 files changed, 196 insertions(+), 76 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index e1afbe3c..df4060b5 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,7 +12,10 @@ import packageJson from "../package.json"; import { registerAdrCommand } from "./commands/adr/index"; import { registerCheckCommand } from "./commands/check"; import { registerCleanCommand } from "./commands/clean"; -import { registerCredentialCommand } from "./commands/credential"; +import { + isCredentialHelperInvocation, + registerCredentialCommand, +} from "./commands/credential"; import { registerDoctorCommand } from "./commands/doctor"; import { registerInitCommand } from "./commands/init"; import { registerLoginCommand } from "./commands/login"; @@ -86,7 +89,9 @@ createPathIfNotExists(paths.cacheFolder); void cleanupStaleBinary(); async function main() { - await installGit(); + // The credential helper is started by git itself, with stdout as the + // protocol channel: the install check must not log or spawn there. + if (!isCredentialHelperInvocation(process.argv)) await installGit(); // Start error tracking and telemetry initialization concurrently without // awaiting: the preAction hook awaits this promise right before the first diff --git a/src/commands/credential.ts b/src/commands/credential.ts index 510b1ef2..2cd3754f 100644 --- a/src/commands/credential.ts +++ b/src/commands/credential.ts @@ -35,6 +35,19 @@ function isPluginsRequest(request: CredentialRequest): boolean { return request.protocol === "https" && request.host === PLUGINS_HOST; } +/** + * True when this process was started by git as its credential helper. + * + * Startup must then stay silent and quick: stdout is the protocol channel, + * and git is blocked waiting for it. Any argument list naming `credential` + * qualifies; skipping the git install check for a false positive is harmless. + * + * @param argv - `process.argv` or an equivalent. + */ +export function isCredentialHelperInvocation(argv: readonly string[]): boolean { + return argv.slice(2).includes("credential"); +} + export function registerCredentialCommand(program: Command) { const credential = program .command("credential") diff --git a/src/commands/login.ts b/src/commands/login.ts index 0ecc9d3e..37ed0ece 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -21,6 +21,14 @@ import { trackLoginResult } from "../helpers/telemetry"; import { isTlsError, tlsHintMessage } from "../helpers/tls"; import { UserError } from "../helpers/user-error"; +/** Raised when a stored record survives a clear; logout and refresh share it. */ +function credentialsLeftBehind(): UserError { + return new UserError( + "Some credentials could not be removed from your git credential manager.", + "Check `git config --global credential.helper` and run `archgate login logout` again." + ); +} + export function registerLoginCommand(program: Command) { const login = program .command("login") @@ -75,12 +83,7 @@ export function registerLoginCommand(program: Command) { subcommand: "logout", success: unregistered && cleared, }); - if (!cleared) { - throw new UserError( - "Some credentials could not be removed from your git credential manager.", - "Check `git config --global credential.helper` and run `archgate login logout` again." - ); - } + if (!cleared) throw credentialsLeftBehind(); if (!unregistered) { throw new UserError( "Credentials removed, but the git credential helper entry could not be removed. Remove it with `git config --global --unset-all credential.https://plugins.archgate.dev.helper`." @@ -98,7 +101,9 @@ export function registerLoginCommand(program: Command) { .action(async () => { try { await unregisterGitCredentialHelper(); - await clearCredentials(); + // A leftover record would keep answering for the old account, so a + // failed clear stops the refresh rather than signing in over it. + if (!(await clearCredentials())) throw credentialsLeftBehind(); await signIn("refresh"); } catch (err) { await handleSignInError("refresh", err); diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index a679737c..d3fea34d 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -408,7 +408,13 @@ export async function clearCredentials(): Promise { let cleared = true; /* oxlint-disable no-await-in-loop -- two fixed hosts, cleared in order */ for (const host of [AUTH_HOST, PLUGINS_HOST]) { - const { credentials } = await gitCredentialFill(host); + const { credentials, timedOut } = await gitCredentialFill(host); + if (timedOut) { + // Nothing is known about this host, so nothing can be called removed. + logDebug("git credential fill timed out", { host }); + cleared = false; + continue; + } if (!credentials) continue; const rejected = await gitCredential( "reject", diff --git a/src/helpers/oauth-device-flow.ts b/src/helpers/oauth-device-flow.ts index b832c301..f22178f7 100644 --- a/src/helpers/oauth-device-flow.ts +++ b/src/helpers/oauth-device-flow.ts @@ -10,6 +10,7 @@ import { z } from "zod"; +import { logDebug } from "./log"; import type { DeviceAuthorization, PlatformAuth, @@ -81,11 +82,15 @@ const IdTokenClaimsSchema = z.object({ export function deviceFlowAuth(config: DeviceFlowConfig): PlatformAuth { return { async requestDeviceCode(): Promise { - const response = await postForm(config.deviceAuthorizationEndpoint, { - client_id: config.clientId, - scope: config.scope, - resource: config.resource, - }); + const response = await postFormReachable( + config.deviceAuthorizationEndpoint, + { + client_id: config.clientId, + scope: config.scope, + resource: config.resource, + }, + "to start sign-in" + ); if (!response.ok) { throw new UserError( `Could not start sign-in (HTTP ${response.status}). Please try again.` @@ -114,12 +119,20 @@ export function deviceFlowAuth(config: DeviceFlowConfig): PlatformAuth { // The resource is repeated here (RFC 8707 §2.2) so the first access // token is minted for the plugins audience, not only the refreshed ones. - const response = await postForm(config.tokenEndpoint, { - client_id: config.clientId, - grant_type: DEVICE_GRANT, - device_code: authorization.deviceCode, - resource: config.resource, - }); + let response: Response; + try { + response = await postForm(config.tokenEndpoint, { + client_id: config.clientId, + grant_type: DEVICE_GRANT, + device_code: authorization.deviceCode, + resource: config.resource, + }); + } catch (error) { + // A blip while the user is still at the browser is not a verdict; + // the next poll retries until the code itself expires. + logDebug("poll failed, retrying", { reason: errorMessage(error) }); + continue; + } const body = await jsonBody(response); if (response.ok) { @@ -156,22 +169,16 @@ export function deviceFlowAuth(config: DeviceFlowConfig): PlatformAuth { }, async refreshAccessToken(refreshToken: string): Promise { - let response: Response; - try { - response = await postForm(config.tokenEndpoint, { + const response = await postFormReachable( + config.tokenEndpoint, + { client_id: config.clientId, grant_type: "refresh_token", refresh_token: refreshToken, resource: config.resource, - }); - } catch (error) { - // The cause is kept in the message: TLS interception is detected by - // matching it, and the hint depends on that. - throw new UserError( - "Could not reach the sign-in service to renew your session.", - `Check your connection and try again. (${errorMessage(error)})` - ); - } + }, + "to renew your session" + ); const body = await jsonBody(response); if (response.ok) return toTokenSet(parseTokenResponse(body), refreshToken); @@ -210,6 +217,30 @@ async function postForm( }); } +/** + * {@link postForm}, turning a transport failure into a user-facing error. + * + * The cause is kept in the message: TLS interception is detected by matching + * it, and the hint the user gets depends on that. + * + * @param purpose - Completes "Could not reach the sign-in service …". + * @throws {UserError} When the request could not be sent or answered. + */ +async function postFormReachable( + url: string, + fields: Record, + purpose: string +): Promise { + try { + return await postForm(url, fields); + } catch (error) { + throw new UserError( + `Could not reach the sign-in service ${purpose}.`, + `Check your connection and try again. (${errorMessage(error)})` + ); + } +} + /** * Read a JSON body, yielding `null` for one that is not JSON. * diff --git a/tests/commands/credential.test.ts b/tests/commands/credential.test.ts index ba0bd5ba..b1cca5ea 100644 --- a/tests/commands/credential.test.ts +++ b/tests/commands/credential.test.ts @@ -13,7 +13,10 @@ import { import { Command } from "@commander-js/extra-typings"; -import { registerCredentialCommand } from "../../src/commands/credential"; +import { + isCredentialHelperInvocation, + registerCredentialCommand, +} from "../../src/commands/credential"; import * as credMod from "../../src/helpers/credential-store"; import * as exitModule from "../../src/helpers/exit"; import { rejectionMessage } from "../test-utils"; @@ -53,6 +56,19 @@ afterEach(() => { mock.restore(); }); +// Git starts the helper with stdout as the protocol channel, so startup must +// not run the git install check, which logs and may spawn an installer. +describe("isCredentialHelperInvocation", () => { + test.each([ + [["bun", "archgate", "credential", "get"], true], + [["bun", "archgate", "--log-level", "debug", "credential", "get"], true], + [["bun", "archgate", "login"], false], + [["bun", "archgate"], false], + ])("%p → %p", (argv, expected) => { + expect(isCredentialHelperInvocation(argv)).toBe(expected); + }); +}); + describe("archgate credential get", () => { test("answers with the stored username and access token", async () => { await run("get", PLUGIN_REQUEST); diff --git a/tests/commands/login.test.ts b/tests/commands/login.test.ts index 90adbe43..a89010c4 100644 --- a/tests/commands/login.test.ts +++ b/tests/commands/login.test.ts @@ -32,6 +32,13 @@ import { rejectionMessage } from "../test-utils"; // Tests — Registration // --------------------------------------------------------------------------- +/** Everything a console spy received, one line per call. */ +function printed(spy: Mock<(...args: unknown[]) => void>): string { + return spy.mock.calls + .map((c: unknown[]) => c.map(String).join(" ")) + .join("\n"); +} + describe("registerLoginCommand", () => { test("registers 'login' as a subcommand", () => { const program = new Command(); @@ -156,9 +163,7 @@ describe("login action handlers", () => { const program = makeProgram(); await program.parseAsync(["node", "test", "login", "status"]); - const allOutput = logSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allOutput = printed(logSpy); expect(allOutput).toContain("Logged in as"); expect(allOutput).toContain("octocat"); }); @@ -169,9 +174,7 @@ describe("login action handlers", () => { const program = makeProgram(); await program.parseAsync(["node", "test", "login", "status"]); - const allOutput = logSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allOutput = printed(logSpy); expect(allOutput).toContain("Not logged in"); }); @@ -186,9 +189,7 @@ describe("login action handlers", () => { ).rejects.toThrow("exitWith(2)"); expect(exitWithSpy.mock.calls.at(-1)?.[0]).toBe(2); - const allErrors = errorSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allErrors = printed(errorSpy); expect(allErrors).toContain("credential store unavailable"); }); }); @@ -205,9 +206,7 @@ describe("login action handlers", () => { await program.parseAsync(["node", "test", "login", "logout"]); expect(clearCredentialsSpy).toHaveBeenCalled(); - const allOutput = logSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allOutput = printed(logSpy); expect(allOutput).toContain("Logged out successfully"); }); @@ -240,9 +239,7 @@ describe("login action handlers", () => { ) ).toContain("exitWith(1)"); - const allErrors = errorSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allErrors = printed(errorSpy); expect(allErrors).toContain("could not be removed"); }); @@ -279,9 +276,7 @@ describe("login action handlers", () => { ).rejects.toThrow("exitWith(2)"); expect(exitWithSpy.mock.calls.at(-1)?.[0]).toBe(2); - const allErrors = errorSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allErrors = printed(errorSpy); expect(allErrors).toContain("clear failed"); }); }); @@ -301,9 +296,7 @@ describe("login action handlers", () => { await program.parseAsync(["node", "test", "login"]); // logInfo writes to console.log with "info:" prefix - const allOutput = logSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allOutput = printed(logSpy); expect(allOutput).toContain("Already logged in"); expect(allOutput).toContain("octocat"); expect(runLoginFlowSpy).not.toHaveBeenCalled(); @@ -368,9 +361,7 @@ describe("login action handlers", () => { const program = makeProgram(); await program.parseAsync(["node", "test", "login"]); - const allOutput = logSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allOutput = printed(logSpy); // printNextStep prints either "archgate check" or "archgate init" expect(allOutput).toMatch(/archgate (check|init)/u); }); @@ -389,9 +380,7 @@ describe("login action handlers", () => { const program = makeProgram(); await program.parseAsync(["node", "test", "login"]); - const allOutput = logSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allOutput = printed(logSpy); expect(allOutput).toContain("archgate check"); } finally { rootSpy.mockRestore(); @@ -410,9 +399,7 @@ describe("login action handlers", () => { const program = makeProgram(); await program.parseAsync(["node", "test", "login"]); - const allOutput = logSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allOutput = printed(logSpy); expect(allOutput).toContain("archgate init"); expect(allOutput).not.toContain("archgate check"); } finally { @@ -432,9 +419,7 @@ describe("login action handlers", () => { ); expect(exitWithSpy.mock.calls.at(-1)?.[0]).toBe(1); - const allErrors = errorSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allErrors = printed(errorSpy); expect(allErrors).toContain("TLS certificate verification failed"); }); @@ -448,9 +433,7 @@ describe("login action handlers", () => { ); expect(exitWithSpy.mock.calls.at(-1)?.[0]).toBe(2); - const allErrors = errorSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allErrors = printed(errorSpy); expect(allErrors).toContain("network timeout"); }); }); @@ -481,6 +464,20 @@ describe("login action handlers", () => { expect(order).toEqual(["unregister", "clear", "login"]); }); + // A leftover record would keep answering for the old account. + test("stops when credentials could not be cleared", async () => { + clearCredentialsSpy.mockResolvedValueOnce(false); + + const program = makeProgram(); + expect( + await rejectionMessage( + program.parseAsync(["node", "test", "login", "refresh"]) + ) + ).toContain("exitWith(1)"); + + expect(runLoginFlowSpy).not.toHaveBeenCalled(); + }); + test("exits with code 1 when refresh login flow fails", async () => { clearCredentialsSpy.mockResolvedValueOnce(true); runLoginFlowSpy.mockResolvedValueOnce({ ok: false }); @@ -506,9 +503,7 @@ describe("login action handlers", () => { ).rejects.toThrow("exitWith(1)"); expect(exitWithSpy.mock.calls.at(-1)?.[0]).toBe(1); - const allErrors = errorSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allErrors = printed(errorSpy); expect(allErrors).toContain("TLS certificate verification failed"); }); @@ -522,9 +517,7 @@ describe("login action handlers", () => { ).rejects.toThrow("exitWith(2)"); expect(exitWithSpy.mock.calls.at(-1)?.[0]).toBe(2); - const allErrors = errorSpy.mock.calls - .map((c: unknown[]) => c.map(String).join(" ")) - .join("\n"); + const allErrors = printed(errorSpy); expect(allErrors).toContain("server unreachable"); }); }); diff --git a/tests/helpers/credential-store-renewal.test.ts b/tests/helpers/credential-store-renewal.test.ts index c9e2beb3..421c2be4 100644 --- a/tests/helpers/credential-store-renewal.test.ts +++ b/tests/helpers/credential-store-renewal.test.ts @@ -4,6 +4,7 @@ import { describe, expect, spyOn, test } from "bun:test"; import * as credentialStore from "../../src/helpers/credential-store"; import { + clearCredentials, invalidateAccessToken, loadCredentials, resolveAccessToken, @@ -217,3 +218,26 @@ describe("loadCredentials with an unrenewable session", () => { } }); }); + +// A helper that never answers leaves the host's records unknown, so logout +// must not report them as removed. +describe("clearCredentials with an unresponsive helper", () => { + test("reports failure when the fill times out", async () => { + let call = 0; + const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + call += 1; + if (call > 1) return gitCredentialStub("", ""); + // oxlint-disable-next-line typescript/no-unsafe-type-assertion + return { + stdout: new ReadableStream({ start() {} }), + exited: new Promise(() => {}), + kill: () => {}, + } as unknown as ReturnType; + }); + try { + expect(await clearCredentials()).toBe(false); + } finally { + spawnSpy.mockRestore(); + } + }, 20_000); +}); diff --git a/tests/helpers/oauth-device-flow.test.ts b/tests/helpers/oauth-device-flow.test.ts index 23c3956c..39734970 100644 --- a/tests/helpers/oauth-device-flow.test.ts +++ b/tests/helpers/oauth-device-flow.test.ts @@ -159,6 +159,17 @@ describe("requestDeviceCode", () => { ); }); + test("reports an unreachable service with the underlying cause", async () => { + globalThis.fetch = recordingFetch(requests, () => { + throw new TypeError("getaddrinfo ENOTFOUND"); + }); + + const message = await rejectionMessage(auth.requestDeviceCode()); + + expect(message).toContain("Could not reach the sign-in service"); + expect(message).toContain("ENOTFOUND"); + }); + // A proxy's HTML error page must not escape as a SyntaxError. test("treats a non-JSON body as an unexpected response", async () => { responses.push(new Response("gateway", { status: 200 })); @@ -186,6 +197,22 @@ describe("pollForTokens", () => { }); }); + // A dropped connection while the user is still at the browser must not + // abort the sign-in; the next poll tries again. + test("rides out a transport failure between polls", async () => { + let call = 0; + globalThis.fetch = recordingFetch(requests, () => { + call += 1; + if (call === 1) throw new TypeError("connection reset"); + return grantResponse(); + }); + + expect((await auth.pollForTokens(PENDING)).tokens.accessToken).toBe( + "ey.access" + ); + expect(requests).toHaveLength(2); + }); + test("keeps polling while authorization is pending", async () => { responses.push(errorResponse("authorization_pending"), grantResponse()); From 4da88d72121024834c05998ed92a3c1509bf859d Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 03:28:33 +0200 Subject: [PATCH 23/27] fix(auth): file the platform session under one fixed credential account The record is stored for the account "archgate" with the display name inside the JSON payload, validated by SessionSchema. Signing in as another user replaces the record instead of leaving the old one beside it, every read selects this record rather than whichever the helper answers first, and logout removes the record that is actually in use. With a constant account and a JSON payload nothing can carry a protocol line break, so the explicit guard is gone. Docs use sign-in wording for the beta notes and describe refresh the same way in every locale; suites restore spies with mock.restore(). Signed-off-by: Rhuan Barreto --- docs/public/llms-full.txt | 14 ++-- .../docs/getting-started/quick-start.mdx | 2 +- .../docs/guides/claude-code-plugin.mdx | 2 +- .../docs/guides/copilot-cli-plugin.mdx | 2 +- .../docs/guides/cursor-integration.mdx | 2 +- .../docs/guides/opencode-integration.mdx | 2 +- .../src/content/docs/guides/vscode-plugin.mdx | 2 +- docs/src/content/docs/index.mdx | 2 +- .../docs/nb/getting-started/quick-start.mdx | 2 +- .../docs/nb/guides/claude-code-plugin.mdx | 2 +- .../docs/nb/guides/copilot-cli-plugin.mdx | 2 +- .../docs/nb/guides/cursor-integration.mdx | 2 +- .../docs/nb/guides/opencode-integration.mdx | 2 +- .../content/docs/nb/guides/vscode-plugin.mdx | 2 +- docs/src/content/docs/nb/index.mdx | 2 +- .../content/docs/nb/reference/cli/login.mdx | 4 +- .../pt-br/getting-started/quick-start.mdx | 2 +- .../docs/pt-br/guides/claude-code-plugin.mdx | 2 +- .../docs/pt-br/guides/copilot-cli-plugin.mdx | 2 +- .../docs/pt-br/guides/cursor-integration.mdx | 2 +- .../pt-br/guides/opencode-integration.mdx | 2 +- .../docs/pt-br/guides/vscode-plugin.mdx | 2 +- docs/src/content/docs/pt-br/index.mdx | 2 +- .../docs/pt-br/reference/cli/login.mdx | 12 +-- src/commands/login.ts | 2 +- src/helpers/credential-store.ts | 84 ++++++++++--------- src/helpers/platform-auth.ts | 10 ++- tests/commands/login.test.ts | 19 +---- .../helpers/credential-store-renewal.test.ts | 22 +++-- tests/helpers/credential-store.test.ts | 61 +++++--------- tests/helpers/git-credential-config.test.ts | 57 ++++++------- 31 files changed, 157 insertions(+), 170 deletions(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index fbb54169..01ac0972 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -109,7 +109,7 @@ The Archgate CLI works standalone, but **editor plugins** unlock a full AI guard Cursor's AI agent the same guardrails workflow as Claude Code. -Editor plugins are currently in beta. Run `archgate login` to sign up and authenticate, then `archgate init --install-plugin` to set up the plugin. +Editor plugins are currently in beta. Run `archgate login` to sign in, then `archgate init --install-plugin` to set up the plugin. ## Learn more @@ -489,7 +489,7 @@ Now that you have a working setup, dive deeper: - [Claude Code Plugin](/guides/claude-code-plugin/): Give AI agents architecture-aware guardrails with role-based skills. - [Cursor Integration](/guides/cursor-integration/): Use Archgate with Cursor IDE for AI-assisted development. -Want AI agents that automatically read your ADRs before coding? Run `archgate login` to sign up and authenticate, then run `archgate init --install-plugin` to set up the plugin. +Want AI agents that automatically read your ADRs before coding? Run `archgate login` to sign in, then run `archgate init --install-plugin` to set up the plugin. --- @@ -1328,7 +1328,7 @@ The `archgate:developer` agent is set as the default agent via `.claude/settings ## Installation -The Claude Code plugin is currently in beta. Run `archgate login` to sign up and authenticate. +The Claude Code plugin is currently in beta. Run `archgate login` to sign in. ### 1. Log in to the Archgate platform @@ -1475,7 +1475,7 @@ Installation is declarative: the CLI writes the marketplace and plugin declarati ## Installation -The GitHub Copilot plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. +The GitHub Copilot plugin is currently in beta. Run `archgate login` to sign in before following the steps below. ### 1. Log in to the Archgate platform @@ -1607,7 +1607,7 @@ Run `archgate init` with the `--editor cursor` flag to configure Cursor integrat archgate init --editor cursor ``` -The Cursor plugin is currently in beta. Run `archgate login` to sign up and authenticate. +The Cursor plugin is currently in beta. Run `archgate login` to sign in. If you have logged in via `archgate login`, the init command also installs the Archgate plugin for Cursor. The plugin provides pre-built agents, skills, and hooks that give Cursor's AI agent a full guardrails workflow. @@ -1910,7 +1910,7 @@ Run `archgate init` with the `--editor opencode` flag to configure opencode inte archgate init --editor opencode ``` -The opencode agent bundle is currently in beta. Run `archgate login` to sign up and authenticate. +The opencode agent bundle is currently in beta. Run `archgate login` to sign in. The opencode agents and skills are **not written to your project tree** — unlike Cursor, which also installs its agents and skills at the user scope but still writes its `afterFileEdit` hook into the project's `.cursor/hooks.json`. For opencode nothing editor-specific lands in the repository: everything lives on your machine and is available across every project you open with opencode. @@ -2308,7 +2308,7 @@ The plugin is served in VS Code Copilot's native `.github/plugin/` manifest form Agent plugins require **VS Code 1.110 (February 2026 release) or later**. Earlier versions do not support git-based agent plugin marketplaces. Check your version with `code --version`. -The VS Code plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. +The VS Code plugin is currently in beta. Run `archgate login` to sign in before following the steps below. ### 1. Log in to the Archgate platform diff --git a/docs/src/content/docs/getting-started/quick-start.mdx b/docs/src/content/docs/getting-started/quick-start.mdx index fde73777..634111f9 100644 --- a/docs/src/content/docs/getting-started/quick-start.mdx +++ b/docs/src/content/docs/getting-started/quick-start.mdx @@ -141,5 +141,5 @@ Now that you have a working setup, dive deeper: - [Cursor Integration](/guides/cursor-integration/): Use Archgate with Cursor IDE for AI-assisted development. :::tip[Editor plugins (beta)] -Want AI agents that automatically read your ADRs before coding? Run `archgate login` to sign up and authenticate, then run `archgate init --install-plugin` to set up the plugin. +Want AI agents that automatically read your ADRs before coding? Run `archgate login` to sign in, then run `archgate init --install-plugin` to set up the plugin. ::: diff --git a/docs/src/content/docs/guides/claude-code-plugin.mdx b/docs/src/content/docs/guides/claude-code-plugin.mdx index c9c0aed8..18e9d087 100644 --- a/docs/src/content/docs/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/guides/claude-code-plugin.mdx @@ -31,7 +31,7 @@ The `archgate:developer` agent is set as the default agent via `.claude/settings ## Installation :::note[Beta access required] -The Claude Code plugin is currently in beta. Run `archgate login` to sign up and authenticate. +The Claude Code plugin is currently in beta. Run `archgate login` to sign in. ::: ### 1. Log in to the Archgate platform diff --git a/docs/src/content/docs/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/guides/copilot-cli-plugin.mdx index 20ff2384..d29abc6d 100644 --- a/docs/src/content/docs/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/guides/copilot-cli-plugin.mdx @@ -16,7 +16,7 @@ Installation is declarative: the CLI writes the marketplace and plugin declarati ## Installation :::note[Beta access required] -The GitHub Copilot plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. +The GitHub Copilot plugin is currently in beta. Run `archgate login` to sign in before following the steps below. ::: ### 1. Log in to the Archgate platform diff --git a/docs/src/content/docs/guides/cursor-integration.mdx b/docs/src/content/docs/guides/cursor-integration.mdx index a1dba435..89793b17 100644 --- a/docs/src/content/docs/guides/cursor-integration.mdx +++ b/docs/src/content/docs/guides/cursor-integration.mdx @@ -14,7 +14,7 @@ archgate init --editor cursor ``` :::note[Beta access required] -The Cursor plugin is currently in beta. Run `archgate login` to sign up and authenticate. +The Cursor plugin is currently in beta. Run `archgate login` to sign in. ::: If you have logged in via `archgate login`, the init command also installs the Archgate plugin for Cursor. The plugin provides pre-built agents, skills, and hooks that give Cursor's AI agent a full guardrails workflow. diff --git a/docs/src/content/docs/guides/opencode-integration.mdx b/docs/src/content/docs/guides/opencode-integration.mdx index 38a93cba..5fe2f874 100644 --- a/docs/src/content/docs/guides/opencode-integration.mdx +++ b/docs/src/content/docs/guides/opencode-integration.mdx @@ -14,7 +14,7 @@ archgate init --editor opencode ``` :::note[Beta access required] -The opencode agent bundle is currently in beta. Run `archgate login` to sign up and authenticate. +The opencode agent bundle is currently in beta. Run `archgate login` to sign in. ::: The opencode agents and skills are **not written to your project tree** — unlike Cursor, which also installs its agents and skills at the user scope but still writes its `afterFileEdit` hook into the project's `.cursor/hooks.json`. For opencode nothing editor-specific lands in the repository: everything lives on your machine and is available across every project you open with opencode. diff --git a/docs/src/content/docs/guides/vscode-plugin.mdx b/docs/src/content/docs/guides/vscode-plugin.mdx index b7f28f1e..5bb3a384 100644 --- a/docs/src/content/docs/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/guides/vscode-plugin.mdx @@ -18,7 +18,7 @@ Agent plugins require **VS Code 1.110 (February 2026 release) or later**. Earlie ::: :::note[Beta access required] -The VS Code plugin is currently in beta. Run `archgate login` to sign up and authenticate before following the steps below. +The VS Code plugin is currently in beta. Run `archgate login` to sign in before following the steps below. ::: ### 1. Log in to the Archgate platform diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 925e9cd9..52f68091 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -81,7 +81,7 @@ The Archgate CLI works standalone, but **editor plugins** unlock a full AI guard :::tip[Beta access] -Editor plugins are currently in beta. Run `archgate login` to sign up and authenticate, then `archgate init --install-plugin` to set up the plugin. +Editor plugins are currently in beta. Run `archgate login` to sign in, then `archgate init --install-plugin` to set up the plugin. ::: diff --git a/docs/src/content/docs/nb/getting-started/quick-start.mdx b/docs/src/content/docs/nb/getting-started/quick-start.mdx index 4ed41daa..cf548702 100644 --- a/docs/src/content/docs/nb/getting-started/quick-start.mdx +++ b/docs/src/content/docs/nb/getting-started/quick-start.mdx @@ -141,5 +141,5 @@ Nå som du har et fungerende oppsett, kan du dykke dypere: - [Cursor-integrasjon](/guides/cursor-integration/) -- Bruk Archgate med Cursor IDE for AI-assistert utvikling. :::tip[Editorplugins (beta)] -Vil du ha AI-agenter som automatisk leser ADR-ene dine før de koder? Kjør `archgate login` for å registrere deg og autentisere, deretter kjør `archgate init --install-plugin` for å sette opp pluginen. +Vil du ha AI-agenter som automatisk leser ADR-ene dine før de koder? Kjør `archgate login` for å logge inn, deretter kjør `archgate init --install-plugin` for å sette opp pluginen. ::: diff --git a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx index e41d96e0..fb658dc4 100644 --- a/docs/src/content/docs/nb/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/nb/guides/claude-code-plugin.mdx @@ -31,7 +31,7 @@ Pluginen legger til agenter og rollebaserte ferdigheter i Claude Code. Utviklera ## Installasjon :::note[Beta-tilgang kreves] -Claude Code-pluginen er for tiden i beta. Kjør `archgate login` for å registrere deg og autentisere. +Claude Code-pluginen er for tiden i beta. Kjør `archgate login` for å logge inn. ::: ### 1. Logg inn på Archgate-plattformen diff --git a/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx index be20d577..b1fa0e19 100644 --- a/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/nb/guides/copilot-cli-plugin.mdx @@ -16,7 +16,7 @@ Installasjonen er deklarativ: CLI-en skriver markedsplass- og plugin-deklarasjon ## Installasjon :::note[Betatilgang kreves] -GitHub Copilot-pluginet er for øyeblikket i beta. Kjør `archgate login` for å registrere deg og autentisere før du følger trinnene nedenfor. +GitHub Copilot-pluginet er for øyeblikket i beta. Kjør `archgate login` for å logge inn før du følger trinnene nedenfor. ::: ### 1. Logg inn på Archgate-plattformen diff --git a/docs/src/content/docs/nb/guides/cursor-integration.mdx b/docs/src/content/docs/nb/guides/cursor-integration.mdx index accfe054..caa6c0f0 100644 --- a/docs/src/content/docs/nb/guides/cursor-integration.mdx +++ b/docs/src/content/docs/nb/guides/cursor-integration.mdx @@ -14,7 +14,7 @@ archgate init --editor cursor ``` :::note[Betatilgang kreves] -Cursor-pluginet er for øyeblikket i beta. Kjør `archgate login` for å registrere deg og autentisere. +Cursor-pluginet er for øyeblikket i beta. Kjør `archgate login` for å logge inn. ::: Hvis du har logget inn via `archgate login`, installerer init-kommandoen også Archgate-pluginet for Cursor. Pluginet tilbyr ferdigbygde agenter, skills og hooks som gir Cursors AI-agent en komplett guardrails-arbeidsflyt. diff --git a/docs/src/content/docs/nb/guides/opencode-integration.mdx b/docs/src/content/docs/nb/guides/opencode-integration.mdx index 9a04bbd2..1ea9fc50 100644 --- a/docs/src/content/docs/nb/guides/opencode-integration.mdx +++ b/docs/src/content/docs/nb/guides/opencode-integration.mdx @@ -14,7 +14,7 @@ archgate init --editor opencode ``` :::note[Betatilgang kreves] -opencode-agentpakken er for øyeblikket i beta. Kjør `archgate login` for å registrere deg og autentisere. +opencode-agentpakken er for øyeblikket i beta. Kjør `archgate login` for å logge inn. ::: opencode-agentene og -ferdighetene skrives **ikke til prosjekttreet ditt** -- i motsetning til Cursor, som også installerer sine agenter og ferdigheter på brukernivå, men likevel skriver `afterFileEdit`-hooken sin i prosjektets `.cursor/hooks.json`. For opencode havner ingenting editorspesifikt i repositoriet: alt bor på maskinen din og er tilgjengelig på tvers av alle prosjekter du åpner med opencode. diff --git a/docs/src/content/docs/nb/guides/vscode-plugin.mdx b/docs/src/content/docs/nb/guides/vscode-plugin.mdx index 7220e265..e93d361e 100644 --- a/docs/src/content/docs/nb/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/nb/guides/vscode-plugin.mdx @@ -18,7 +18,7 @@ Agent-plugins krever **VS Code 1.110 (februar 2026-utgivelsen) eller nyere**. Ti ::: :::note[Betatilgang kreves] -VS Code-pluginet er for øyeblikket i beta. Kjør `archgate login` for å registrere deg og autentisere før du følger trinnene nedenfor. +VS Code-pluginet er for øyeblikket i beta. Kjør `archgate login` for å logge inn før du følger trinnene nedenfor. ::: ### 1. Logg inn på Archgate-plattformen diff --git a/docs/src/content/docs/nb/index.mdx b/docs/src/content/docs/nb/index.mdx index e14914d5..c00ad7b8 100644 --- a/docs/src/content/docs/nb/index.mdx +++ b/docs/src/content/docs/nb/index.mdx @@ -82,7 +82,7 @@ Archgate CLI fungerer frittstående, men **editorplugins** låser opp en fullste :::tip[Beta-tilgang] -Editorplugins er for øyeblikket i beta. Kjør `archgate login` for å registrere deg og autentisere, deretter `archgate init --install-plugin` for å sette opp pluginen. +Editorplugins er for øyeblikket i beta. Kjør `archgate login` for å logge inn, deretter `archgate init --install-plugin` for å sette opp pluginen. ::: diff --git a/docs/src/content/docs/nb/reference/cli/login.mdx b/docs/src/content/docs/nb/reference/cli/login.mdx index 639eb5e5..7b96d751 100644 --- a/docs/src/content/docs/nb/reference/cli/login.mdx +++ b/docs/src/content/docs/nb/reference/cli/login.mdx @@ -20,7 +20,7 @@ Innlogging registrerer også `archgate credential` som en git credential helper Legitimasjon kreves for å installere editor-plugins via `archgate init --install-plugin`. Selve CLI-en (check, init osv.) fungerer uten innlogging. :::tip[Plugin-beta] -Editor-plugins er for tiden i beta. Kjør `archgate login` for å registrere deg og autentisere. +Editor-plugins er for tiden i beta. Kjør `archgate login` for å logge inn. ::: ## Underkommandoer @@ -30,7 +30,7 @@ Editor-plugins er for tiden i beta. Kjør `archgate login` for å registrere deg | `archgate login` | Autentiser (hopper over hvis allerede innlogget) | | `archgate login status` | Vis gjeldende autentiseringsstatus | | `archgate login logout` | Fjern lagret legitimasjon | -| `archgate login refresh` | Autentiser på nytt og hent et nytt token | +| `archgate login refresh` | Logg inn på nytt og erstatt lagrede token | ## Eksempler diff --git a/docs/src/content/docs/pt-br/getting-started/quick-start.mdx b/docs/src/content/docs/pt-br/getting-started/quick-start.mdx index 6ab05422..72ec3cd3 100644 --- a/docs/src/content/docs/pt-br/getting-started/quick-start.mdx +++ b/docs/src/content/docs/pt-br/getting-started/quick-start.mdx @@ -141,5 +141,5 @@ Agora que você tem uma configuração funcionando, aprofunde-se: - [Integração com Cursor](/guides/cursor-integration/): use o Archgate com o Cursor IDE para desenvolvimento assistido por IA. :::tip[Plugins para editores (beta)] -Quer agentes de IA que leiam automaticamente seus ADRs antes de programar? Execute `archgate login` para se cadastrar e autenticar, depois execute `archgate init --install-plugin` para configurar o plugin. +Quer agentes de IA que leiam automaticamente seus ADRs antes de programar? Execute `archgate login` para entrar, depois execute `archgate init --install-plugin` para configurar o plugin. ::: diff --git a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx index edf91e64..b8a9a95f 100644 --- a/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/claude-code-plugin.mdx @@ -31,7 +31,7 @@ O agente `archgate:developer` é definido como agente padrão via `.claude/setti ## Instalação :::note[Acesso beta necessário] -O plugin para Claude Code está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar. +O plugin para Claude Code está atualmente em beta. Execute `archgate login` para entrar. ::: ### 1. Entre na plataforma Archgate diff --git a/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx b/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx index 4b5b8bcb..08c5849a 100644 --- a/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/copilot-cli-plugin.mdx @@ -16,7 +16,7 @@ A instalação é declarativa: o CLI escreve a declaração do marketplace e do ## Instalação :::note[Acesso beta necessário] -O plugin para GitHub Copilot está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar antes de seguir os passos abaixo. +O plugin para GitHub Copilot está atualmente em beta. Execute `archgate login` para entrar antes de seguir os passos abaixo. ::: ### 1. Entre na plataforma Archgate diff --git a/docs/src/content/docs/pt-br/guides/cursor-integration.mdx b/docs/src/content/docs/pt-br/guides/cursor-integration.mdx index 4b31bd85..57d607d9 100644 --- a/docs/src/content/docs/pt-br/guides/cursor-integration.mdx +++ b/docs/src/content/docs/pt-br/guides/cursor-integration.mdx @@ -14,7 +14,7 @@ archgate init --editor cursor ``` :::note[Acesso beta necessário] -O plugin para Cursor está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar. +O plugin para Cursor está atualmente em beta. Execute `archgate login` para entrar. ::: Se você fez login via `archgate login`, o comando init também instala o plugin Archgate para o Cursor. O plugin fornece agentes, skills e hooks pré-configurados que dão ao agente de IA do Cursor um fluxo completo de guardrails. diff --git a/docs/src/content/docs/pt-br/guides/opencode-integration.mdx b/docs/src/content/docs/pt-br/guides/opencode-integration.mdx index 6b26f79e..09d84666 100644 --- a/docs/src/content/docs/pt-br/guides/opencode-integration.mdx +++ b/docs/src/content/docs/pt-br/guides/opencode-integration.mdx @@ -14,7 +14,7 @@ archgate init --editor opencode ``` :::note[Acesso beta necessário] -O bundle de agentes do opencode está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar. +O bundle de agentes do opencode está atualmente em beta. Execute `archgate login` para entrar. ::: Os agentes e skills do opencode **não são gravados na árvore do seu projeto** -- diferente do Cursor, que também instala seus agentes e skills no escopo do usuário, mas ainda assim grava seu hook `afterFileEdit` no `.cursor/hooks.json` do projeto. No opencode nada específico do editor chega ao repositório: tudo vive na sua máquina e fica disponível em todos os projetos que você abrir com o opencode. diff --git a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx index f01bffef..bc479896 100644 --- a/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx +++ b/docs/src/content/docs/pt-br/guides/vscode-plugin.mdx @@ -18,7 +18,7 @@ Plugins de agente requerem **VS Code 1.110 (lançamento de fevereiro de 2026) ou ::: :::note[Acesso beta necessário] -O plugin para VS Code está atualmente em beta. Execute `archgate login` para se cadastrar e autenticar antes de seguir os passos abaixo. +O plugin para VS Code está atualmente em beta. Execute `archgate login` para entrar antes de seguir os passos abaixo. ::: ### 1. Entre na plataforma Archgate diff --git a/docs/src/content/docs/pt-br/index.mdx b/docs/src/content/docs/pt-br/index.mdx index c943b688..8a812bd3 100644 --- a/docs/src/content/docs/pt-br/index.mdx +++ b/docs/src/content/docs/pt-br/index.mdx @@ -83,7 +83,7 @@ A CLI do Archgate funciona de forma independente, mas os **plugins para editores :::tip[Acesso beta] -Os plugins para editores estão atualmente em beta. Execute `archgate login` para se cadastrar e autenticar, depois `archgate init --install-plugin` para configurar o plugin. +Os plugins para editores estão atualmente em beta. Execute `archgate login` para entrar, depois `archgate init --install-plugin` para configurar o plugin. ::: diff --git a/docs/src/content/docs/pt-br/reference/cli/login.mdx b/docs/src/content/docs/pt-br/reference/cli/login.mdx index 07004941..1a76dd8d 100644 --- a/docs/src/content/docs/pt-br/reference/cli/login.mdx +++ b/docs/src/content/docs/pt-br/reference/cli/login.mdx @@ -25,12 +25,12 @@ Os plugins de editor estão atualmente em beta. Execute `archgate login` para en ## Subcomandos -| Subcomando | Descrição | -| ------------------------ | -------------------------------------- | -| `archgate login` | Autenticar (pula se já estiver logado) | -| `archgate login status` | Mostrar o status atual de autenticação | -| `archgate login logout` | Remover credenciais armazenadas | -| `archgate login refresh` | Reautenticar e solicitar um novo token | +| Subcomando | Descrição | +| ------------------------ | ---------------------------------------- | +| `archgate login` | Autenticar (pula se já estiver logado) | +| `archgate login status` | Mostrar o status atual de autenticação | +| `archgate login logout` | Remover credenciais armazenadas | +| `archgate login refresh` | Entrar novamente, substituindo os tokens | ## Exemplos diff --git a/src/commands/login.ts b/src/commands/login.ts index 37ed0ece..29e79d8f 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -97,7 +97,7 @@ export function registerLoginCommand(program: Command) { login .command("refresh") - .description("Re-authenticate and claim a new token") + .description("Sign in again, replacing stored tokens") .action(async () => { try { await unregisterGitCredentialHelper(); diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index d3fea34d..10b24855 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -1,11 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate /** - * Secure credential storage in the OS credential manager (macOS Keychain, - * Windows Credential Manager, libsecret) via `git credential - * approve/fill/reject` — nothing is written to disk. The platform token set - * is filed under {@link AUTH_HOST} as a JSON `password`; a token issued - * before platform sign-in sits under {@link PLUGINS_HOST} as-is. + * Credential storage through `git credential approve/fill/reject`, so the + * configured helper decides where records live. The platform session is + * filed under {@link AUTH_HOST} for {@link SESSION_ACCOUNT} as a JSON + * password; a pre-platform token sits under {@link PLUGINS_HOST} as-is. * * @see https://git-scm.com/docs/git-credential */ @@ -21,15 +20,24 @@ import { isExpired, platformAuth, type Session, + SessionSchema, type TokenSet, - TokenSetSchema, } from "./platform-auth"; import { PLUGINS_HOST } from "./plugin-install"; import { SessionExpiredError } from "./session-expired-error"; -import { UserError } from "./user-error"; const CREDENTIAL_TIMEOUT_MS = 3_000; +/** + * Account the platform session is filed under. + * + * One fixed name, so signing in as another user replaces the record instead + * of leaving the old one beside it, and every read selects this record rather + * than whichever the helper answers first. The display name lives inside the + * JSON payload. + */ +const SESSION_ACCOUNT = "archgate"; + /** * Build env for git credential commands at call time (not import time), * suppressing every interactive prompt across platforms and Git Credential @@ -57,32 +65,22 @@ export interface StoredCredentials { // --------------------------------------------------------------------------- /** - * Refuse a value that could forge protocol lines or truncate the record. + * One record in git's credential protocol. * - * A newline inside an account name would add a second `host=` line, and git - * takes the later one, so the token set would be filed under that host. - * - * @throws {UserError} When the value holds a line break or NUL. + * No value here can forge a protocol line: hosts and the session account are + * constants, and the session payload is JSON, which escapes line breaks and + * NUL. Legacy records only ever carry values git itself produced. */ -function protocolValue(field: string, value: string): string { - if (value.includes("\n") || value.includes("\r") || value.includes("\0")) { - throw new UserError( - `The credential ${field} contains a line break and cannot be stored.` - ); - } - return value; -} - function credentialInput( host: string, username?: string, password?: string ): string { - const lines = ["protocol=https", `host=${protocolValue("host", host)}`]; + const lines = ["protocol=https", `host=${host}`]; if (username !== undefined && username !== "") - lines.push(`username=${protocolValue("username", username)}`); + lines.push(`username=${username}`); if (password !== undefined && password !== "") - lines.push(`password=${protocolValue("password", password)}`); + lines.push(`password=${password}`); lines.push("", ""); return lines.join("\n"); } @@ -211,7 +209,7 @@ const CREDENTIAL_HELPER_HINT = "Run `git config --global credential.helper` to check your configuration."; /** - * Persist a platform token set in the OS credential manager. + * Persist a platform session in the OS credential manager. * * A verification round-trip (`git credential fill`) confirms the blob was * actually persisted — `git credential approve` exits 0 even without a @@ -219,7 +217,7 @@ const CREDENTIAL_HELPER_HINT = * * @param user - Display name shown by `archgate login status`. * @param tokens - Access token, refresh token and absolute expiry. - * @returns `true` when the token set is retrievable afterwards. + * @returns `true` when the session is retrievable afterwards. */ export async function saveTokenSet( user: string, @@ -227,8 +225,13 @@ export async function saveTokenSet( ): Promise { await cleanupLegacyMetadata(); - const serialized = JSON.stringify(tokens); - const stored = await gitCredential("approve", AUTH_HOST, user, serialized); + const serialized = JSON.stringify({ user, tokens } satisfies Session); + const stored = await gitCredential( + "approve", + AUTH_HOST, + SESSION_ACCOUNT, + serialized + ); if (!stored) { logWarn( "git credential approve failed.", @@ -238,9 +241,9 @@ export async function saveTokenSet( return false; } - // The read-back asks for this account and must return this very blob: a - // helper may otherwise answer with an older record or another user's. - const { credentials } = await gitCredentialFill(AUTH_HOST, user); + // The read-back must return this very blob, not whatever else the helper + // may hold for the host. + const { credentials } = await gitCredentialFill(AUTH_HOST, SESSION_ACCOUNT); if (credentials?.password === serialized) { logDebug("Token set verified in git credential manager"); return true; @@ -269,18 +272,18 @@ async function readSession(): Promise<{ session: Session | null; timedOut: boolean; }> { - const { credentials, timedOut } = await gitCredentialFill(AUTH_HOST); + const { credentials, timedOut } = await gitCredentialFill( + AUTH_HOST, + SESSION_ACCOUNT + ); if (!credentials) return { session: null, timedOut }; - const parsed = TokenSetSchema.safeParse(safeJsonParse(credentials.password)); + const parsed = SessionSchema.safeParse(safeJsonParse(credentials.password)); if (!parsed.success) { - logDebug("Stored token set is unreadable"); + logDebug("Stored session is unreadable"); return { session: null, timedOut: false }; } - return { - session: { user: credentials.username, tokens: parsed.data }, - timedOut: false, - }; + return { session: parsed.data, timedOut: false }; } /** @@ -408,7 +411,10 @@ export async function clearCredentials(): Promise { let cleared = true; /* oxlint-disable no-await-in-loop -- two fixed hosts, cleared in order */ for (const host of [AUTH_HOST, PLUGINS_HOST]) { - const { credentials, timedOut } = await gitCredentialFill(host); + const { credentials, timedOut } = await gitCredentialFill( + host, + host === AUTH_HOST ? SESSION_ACCOUNT : undefined + ); if (timedOut) { // Nothing is known about this host, so nothing can be called removed. logDebug("git credential fill timed out", { host }); diff --git a/src/helpers/platform-auth.ts b/src/helpers/platform-auth.ts index 54ff5334..512d27e9 100644 --- a/src/helpers/platform-auth.ts +++ b/src/helpers/platform-auth.ts @@ -30,11 +30,13 @@ export const TokenSetSchema = z.object({ export type TokenSet = z.infer; /** A signed-in account and the tokens that prove it. */ -export interface Session { +export const SessionSchema = z.object({ /** Display name of the account, shown by `archgate login status`. */ - user: string; - tokens: TokenSet; -} + user: z.string().min(1), + tokens: TokenSetSchema, +}); + +export type Session = z.infer; /** What the user must do to approve a sign-in started on this device. */ export interface DeviceAuthorization { diff --git a/tests/commands/login.test.ts b/tests/commands/login.test.ts index a89010c4..79802627 100644 --- a/tests/commands/login.test.ts +++ b/tests/commands/login.test.ts @@ -12,9 +12,10 @@ import { beforeEach, describe, expect, + mock, + type Mock, spyOn, test, - type Mock, } from "bun:test"; import { Command } from "@commander-js/extra-typings"; @@ -99,7 +100,6 @@ describe("login action handlers", () => { >; let runLoginFlowSpy: Mock; let exitWithSpy: Mock; - let trackLoginSpy: Mock; beforeEach(() => { logSpy = spyOn(console, "log").mockImplementation(() => {}); @@ -118,9 +118,7 @@ describe("login action handlers", () => { "unregisterGitCredentialHelper" ).mockResolvedValue(true); runLoginFlowSpy = spyOn(loginFlow, "runLoginFlow"); - trackLoginSpy = spyOn(telemetry, "trackLoginResult").mockImplementation( - () => {} - ); + spyOn(telemetry, "trackLoginResult").mockImplementation(() => {}); // Stub exitWith to throw instead of calling process.exit — avoids // needing to mock telemetry flush / sentry flush internals. exitWithSpy = spyOn(exitMod, "exitWith").mockImplementation( @@ -131,16 +129,7 @@ describe("login action handlers", () => { }); afterEach(() => { - logSpy.mockRestore(); - errorSpy.mockRestore(); - loadCredentialsSpy.mockRestore(); - loadTokenSetSpy.mockRestore(); - clearCredentialsSpy.mockRestore(); - ensureHelperSpy.mockRestore(); - unregisterHelperSpy.mockRestore(); - runLoginFlowSpy.mockRestore(); - exitWithSpy.mockRestore(); - trackLoginSpy.mockRestore(); + mock.restore(); }); function makeProgram(): Command { diff --git a/tests/helpers/credential-store-renewal.test.ts b/tests/helpers/credential-store-renewal.test.ts index 421c2be4..8b023771 100644 --- a/tests/helpers/credential-store-renewal.test.ts +++ b/tests/helpers/credential-store-renewal.test.ts @@ -27,6 +27,14 @@ function gitCredentialStub(username: string, password: string) { } as unknown as ReturnType; } +/** The record `saveTokenSet` files: one fixed account, the session as JSON. */ +function sessionRecord( + user: string, + tokens: { accessToken: string; refreshToken: string; expiresAt: number } +): ReturnType { + return gitCredentialStub("archgate", JSON.stringify({ user, tokens })); +} + describe("credential renewal", () => { describe("invalidateAccessToken", () => { const tokens = { @@ -43,9 +51,9 @@ describe("credential renewal", () => { let call = 0; const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { call += 1; - return gitCredentialStub( + return sessionRecord( "octocat", - JSON.stringify(call >= 3 ? { ...tokens, expiresAt: 0 } : tokens) + call >= 3 ? { ...tokens, expiresAt: 0 } : tokens ); }); try { @@ -85,7 +93,7 @@ describe("credential renewal", () => { // rejected access token on the next lookup, so the failure is reported. test("reports when the lapsed token set cannot be stored", async () => { const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => - gitCredentialStub("octocat", JSON.stringify(tokens)) + sessionRecord("octocat", tokens) ); const saveSpy = spyOn(credentialStore, "saveTokenSet").mockResolvedValue( false @@ -108,7 +116,7 @@ describe("credential renewal", () => { expiresAt: Date.now() - 1_000, }; const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => - gitCredentialStub("octocat", JSON.stringify(stale)) + sessionRecord("octocat", stale) ); const saveSpy = spyOn(credentialStore, "saveTokenSet").mockResolvedValue( false @@ -149,7 +157,7 @@ describe("loadCredentials with an unrenewable session", () => { expiresAt: Date.now() - 1_000, }; const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => - gitCredentialStub("octocat", JSON.stringify(stale)) + sessionRecord("octocat", stale) ); const refreshSpy = spyOn( platformAuth, @@ -181,7 +189,7 @@ describe("loadCredentials with an unrenewable session", () => { expiresAt: Date.now() - 1_000, }; const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => - gitCredentialStub("octocat", JSON.stringify(stale)) + sessionRecord("octocat", stale) ); const refreshSpy = spyOn( platformAuth, @@ -204,7 +212,7 @@ describe("loadCredentials with an unrenewable session", () => { expiresAt: Date.now() - 1_000, }; const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => - gitCredentialStub("octocat", JSON.stringify(stale)) + sessionRecord("octocat", stale) ); const refreshSpy = spyOn( platformAuth, diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts index a68843e9..90635cc5 100644 --- a/tests/helpers/credential-store.test.ts +++ b/tests/helpers/credential-store.test.ts @@ -36,6 +36,14 @@ function gitCredentialStub( } as unknown as ReturnType; } +/** The record `saveTokenSet` files: one fixed account, the session as JSON. */ +function sessionRecord( + user: string, + tokens: { accessToken: string; refreshToken: string; expiresAt: number } +): ReturnType { + return gitCredentialStub("archgate", JSON.stringify({ user, tokens })); +} + describe("credential-store", () => { let tempDir: string; let originalHome: string | undefined; @@ -129,34 +137,6 @@ describe("credential-store", () => { ); }); - // A newline in an account name would add a second `host=` line, and git - // takes the later one, so the token set would be filed under that host. - describe("credential protocol values", () => { - test.each([ - ["a line feed", "octo\nhost=evil.example.com"], - ["a carriage return", "octo\rhost=evil.example.com"], - ["a NUL", "octo\0cat"], - ])("refuses an account name holding %s", async (_label, user) => { - const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { - throw new Error("git must not be spawned"); - }); - try { - expect( - await rejectionMessage( - saveTokenSet(user, { - accessToken: "ey.access", - refreshToken: "refresh-abc", - expiresAt: Date.now() + 3_600_000, - }) - ) - ).toContain("line break"); - expect(spawnSpy).not.toHaveBeenCalled(); - } finally { - spawnSpy.mockRestore(); - } - }); - }); - describe("loadCredentials", () => { test("returns null when no credentials exist anywhere", async () => { const result = await loadCredentials(); @@ -263,7 +243,7 @@ describe("credential-store", () => { ); const stdin = (fill?.[1] as { stdin?: unknown } | undefined)?.stdin; expect(stdin instanceof Blob ? await stdin.text() : "").toContain( - "username=octocat" + "username=archgate" ); expect(warnSpy.mock.calls.flat().join(" ")).toContain( "could not be verified" @@ -276,10 +256,18 @@ describe("credential-store", () => { test("accepts the read-back when it matches", async () => { const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => - gitCredentialStub("octocat", JSON.stringify(tokens)) + sessionRecord("octocat", tokens) ); try { expect(await saveTokenSet("octocat", tokens)).toBe(true); + const approve = spawnSpy.mock.calls.find((call) => + [...call[0]].includes("approve") + ); + const stdin = (approve?.[1] as { stdin?: unknown } | undefined)?.stdin; + // One fixed account; the display name travels inside the payload. + expect(stdin instanceof Blob ? await stdin.text() : "").toContain( + "username=archgate" + ); } finally { spawnSpy.mockRestore(); } @@ -428,7 +416,7 @@ describe("credential-store", () => { expiresAt: Date.now() + 3_600_000, }; const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => - gitCredentialStub("octocat", JSON.stringify(tokens)) + sessionRecord("octocat", tokens) ); try { expect(await loadTokenSet()).toEqual({ user: "octocat", tokens }); @@ -446,7 +434,7 @@ describe("credential-store", () => { expiresAt: Date.now() + 3_600_000, }; const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => - gitCredentialStub("octocat", JSON.stringify(tokens)) + sessionRecord("octocat", tokens) ); const refreshSpy = spyOn(platformAuth, "refreshAccessToken"); try { @@ -468,7 +456,7 @@ describe("credential-store", () => { expiresAt: Date.now() - 1_000, }; const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => - gitCredentialStub("octocat", JSON.stringify(stale)) + sessionRecord("octocat", stale) ); const refreshSpy = spyOn( platformAuth, @@ -510,10 +498,7 @@ describe("credential-store", () => { let read = 0; const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => { read += 1; - return gitCredentialStub( - "octocat", - JSON.stringify(read === 1 ? expired : rotated) - ); + return sessionRecord("octocat", read === 1 ? expired : rotated); }); const refreshSpy = spyOn( platformAuth, @@ -532,7 +517,7 @@ describe("credential-store", () => { test("rethrows when the stored token set did not change", async () => { const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => - gitCredentialStub("octocat", JSON.stringify(expired)) + sessionRecord("octocat", expired) ); const refreshSpy = spyOn( platformAuth, diff --git a/tests/helpers/git-credential-config.test.ts b/tests/helpers/git-credential-config.test.ts index dc5ed47b..41563a12 100644 --- a/tests/helpers/git-credential-config.test.ts +++ b/tests/helpers/git-credential-config.test.ts @@ -1,6 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Archgate -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { + afterEach, + beforeEach, + describe, + expect, + mock, + spyOn, + test, +} from "bun:test"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -72,6 +80,7 @@ beforeEach(() => { }); afterEach(() => { + mock.restore(); restoreEnv("GIT_CONFIG_NOSYSTEM", originalNoSystem); restoreEnv("GIT_CONFIG_SYSTEM", originalSystem); restoreEnv("GIT_CONFIG_GLOBAL", originalGlobal); @@ -173,14 +182,11 @@ describe("registerGitCredentialHelper", () => { // Login must degrade to a warning, not an internal fault, without git. test("reports failure when git cannot be started", async () => { - const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + spyOn(Bun, "spawn").mockImplementation(() => { throw new Error("spawn git ENOENT"); }); - try { - expect(await registerGitCredentialHelper()).toBe(false); - } finally { - spawnSpy.mockRestore(); - } + + expect(await registerGitCredentialHelper()).toBe(false); }); }); @@ -189,28 +195,22 @@ describe("registerGitCredentialHelper", () => { describe("ensureGitCredentialHelper", () => { test("stays quiet when the helper is registered", async () => { const warnSpy = spyOn(logMod, "logWarn").mockImplementation(() => {}); - try { - await ensureGitCredentialHelper(); - - expect(warnSpy).not.toHaveBeenCalled(); - expect(await configuredHelpers()).toEqual(["", helperCommand()]); - } finally { - warnSpy.mockRestore(); - } + + await ensureGitCredentialHelper(); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(await configuredHelpers()).toEqual(["", helperCommand()]); }); test("warns instead of failing when git cannot write the config", async () => { Bun.env.GIT_CONFIG_GLOBAL = tempDir; const warnSpy = spyOn(logMod, "logWarn").mockImplementation(() => {}); - try { - await ensureGitCredentialHelper(); - - expect(warnSpy.mock.calls.flat().join(" ")).toContain( - "Could not register archgate" - ); - } finally { - warnSpy.mockRestore(); - } + + await ensureGitCredentialHelper(); + + expect(warnSpy.mock.calls.flat().join(" ")).toContain( + "Could not register archgate" + ); }); }); @@ -228,13 +228,10 @@ describe("unregisterGitCredentialHelper", () => { }); test("reports failure when git cannot be started", async () => { - const spawnSpy = spyOn(Bun, "spawn").mockImplementation(() => { + spyOn(Bun, "spawn").mockImplementation(() => { throw new Error("spawn git ENOENT"); }); - try { - expect(await unregisterGitCredentialHelper()).toBe(false); - } finally { - spawnSpy.mockRestore(); - } + + expect(await unregisterGitCredentialHelper()).toBe(false); }); }); From afd96640f4bd39d3f79180949a4381e5bd4fc664 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 03:37:59 +0200 Subject: [PATCH 24/27] fix(auth): skip empty ID-token claims when naming the session An empty username, name or email claim was accepted as the account name; the stored session requires a non-empty one, so the record was written and then dropped on the next read. Empty claims are now skipped like absent ones. Signed-off-by: Rhuan Barreto --- src/helpers/oauth-device-flow.ts | 11 ++++++----- tests/helpers/oauth-device-flow.test.ts | 5 +++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/helpers/oauth-device-flow.ts b/src/helpers/oauth-device-flow.ts index f22178f7..226f4ade 100644 --- a/src/helpers/oauth-device-flow.ts +++ b/src/helpers/oauth-device-flow.ts @@ -316,12 +316,13 @@ function userFromIdToken(idToken: string | undefined): string { JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) ); if (!claims.success) return FALLBACK_USER; - return ( - claims.data.username ?? - claims.data.name ?? - claims.data.email ?? - claims.data.sub + // An empty claim is skipped like an absent one: the stored session + // requires a non-empty name, so an empty one would be dropped on read. + const { username, name, email, sub } = claims.data; + const first = [username, name, email, sub].find( + (claim): claim is string => typeof claim === "string" && claim !== "" ); + return first ?? FALLBACK_USER; } catch { return FALLBACK_USER; } diff --git a/tests/helpers/oauth-device-flow.test.ts b/tests/helpers/oauth-device-flow.test.ts index 39734970..de1f7c99 100644 --- a/tests/helpers/oauth-device-flow.test.ts +++ b/tests/helpers/oauth-device-flow.test.ts @@ -307,6 +307,10 @@ describe("pollForTokens", () => { [{ sub: "usr_1", name: "Octo Cat" }, "Octo Cat"], [{ sub: "usr_1", email: "octo@example.com" }, "octo@example.com"], [{ sub: "usr_1" }, "usr_1"], + // An empty claim must not win over a filled one: the stored session + // requires a non-empty name and would be dropped on the next read. + [{ sub: "usr_1", username: "", name: "Octo Cat" }, "Octo Cat"], + [{ sub: "usr_1", username: "", name: "", email: "" }, "usr_1"], ])("from the most specific claim in %o", async (claims, expected) => { responses.push(grantResponse({ id_token: idTokenFor(claims) })); @@ -320,6 +324,7 @@ describe("pollForTokens", () => { ["a token with an empty payload", "header..sig"], ["a payload that is not base64", "header.!!!notbase64!!!.sig"], ["claims without a subject", idTokenFor({ name: "nobody" })], + ["claims that are all empty", idTokenFor({ sub: "", username: "" })], ])("generically given %s", async (_label, idToken) => { responses.push(grantResponse({ id_token: idToken })); From d8e8096f6e2e9f1f75ce0d3ae963ae080b363011 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 03:48:34 +0200 Subject: [PATCH 25/27] fix(auth): keep the credential helper off the network and tolerate a late-landing winner - As git's credential helper the CLI skips telemetry and Sentry startup, the update notice and the exit flushes, so git is not held on the network; the answer ends with the protocol's blank-line terminator. - A renewal refused because a sibling process rotated the token re-reads the store a few times before giving up, since the winner writes its result only after its own exchange. Signed-off-by: Rhuan Barreto --- src/cli.ts | 19 +++-- src/helpers/credential-store.ts | 27 +++++-- src/helpers/git-credential-protocol.ts | 6 +- tests/commands/credential.test.ts | 2 +- .../helpers/credential-store-renewal.test.ts | 78 +++++++++++++++++++ tests/helpers/credential-store.test.ts | 58 +------------- tests/helpers/git-credential-protocol.test.ts | 5 +- 7 files changed, 120 insertions(+), 75 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index df4060b5..78fb90a4 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -89,16 +89,21 @@ createPathIfNotExists(paths.cacheFolder); void cleanupStaleBinary(); async function main() { - // The credential helper is started by git itself, with stdout as the - // protocol channel: the install check must not log or spawn there. - if (!isCredentialHelperInvocation(process.argv)) await installGit(); + // Started by git as its credential helper, stdout is the protocol channel + // and git waits for this process to exit: startup skips everything that + // could write there or wait on the network — the git install check, + // telemetry, and the update notice. + const helper = isCredentialHelperInvocation(process.argv); + if (!helper) await installGit(); // Start error tracking and telemetry initialization concurrently without // awaiting: the preAction hook awaits this promise right before the first // telemetry event fires, so `repo_id` is always present on `command_executed` // events, while paths that never reach preAction (--help, --version) leave // the ~150ms of SDK parse and repo_id resolution off the critical path. - const telemetryReady = Promise.all([initSentry(), initTelemetry()]); + const telemetryReady: Promise = helper + ? Promise.resolve() + : Promise.all([initSentry(), initTelemetry()]); const logLevelOption = new Option("--log-level ", "Set log verbosity") .choices(["error", "warn", "info", "debug"] as const) @@ -164,12 +169,14 @@ async function main() { registerTelemetryCommand(program); registerCredentialCommand(program); - const updateCheckPromise = maybeCheckForUpdates(packageJson.version); + const updateCheckPromise = helper + ? Promise.resolve(null) + : maybeCheckForUpdates(packageJson.version); await program.parseAsync(process.argv); const notice = await updateCheckPromise; if (notice !== null && notice !== "") console.log(notice); - await Promise.all([flushTelemetry(), flushSentry()]); + if (!helper) await Promise.all([flushTelemetry(), flushSentry()]); // Belt-and-braces: force exit so any stray handle left by a third-party // SDK (posthog-node, @sentry/node-core, etc.) can't linger and make the diff --git a/src/helpers/credential-store.ts b/src/helpers/credential-store.ts index 10b24855..3333e58c 100644 --- a/src/helpers/credential-store.ts +++ b/src/helpers/credential-store.ts @@ -28,6 +28,10 @@ import { SessionExpiredError } from "./session-expired-error"; const CREDENTIAL_TIMEOUT_MS = 3_000; +/** How often, and how far apart, a losing renewal re-reads the store. */ +const RENEWAL_REREADS = 4; +const RENEWAL_REREAD_DELAY_MS = 250; + /** * Account the platform session is filed under. * @@ -336,15 +340,22 @@ async function renew(stale: Session): Promise { } return { token: renewed.accessToken, github_user: stale.user }; } catch (error) { - const { session } = await readSession(); - if ( - session && - session.tokens.refreshToken !== stale.tokens.refreshToken && - !isExpired(session.tokens.expiresAt) - ) { - logDebug("token set was renewed by another process"); - return { token: session.tokens.accessToken, github_user: session.user }; + // The winner stores its result only after its own exchange, so a loser + // can be refused before that write lands: re-read a few times first. + /* oxlint-disable no-await-in-loop -- bounded re-reads, in order */ + for (let attempt = 0; attempt < RENEWAL_REREADS; attempt += 1) { + if (attempt > 0) await Bun.sleep(RENEWAL_REREAD_DELAY_MS); + const { session } = await readSession(); + if ( + session && + session.tokens.refreshToken !== stale.tokens.refreshToken && + !isExpired(session.tokens.expiresAt) + ) { + logDebug("token set was renewed by another process", { attempt }); + return { token: session.tokens.accessToken, github_user: session.user }; + } } + /* oxlint-enable no-await-in-loop */ throw error; } } diff --git a/src/helpers/git-credential-protocol.ts b/src/helpers/git-credential-protocol.ts index b0b63547..a5a9b177 100644 --- a/src/helpers/git-credential-protocol.ts +++ b/src/helpers/git-credential-protocol.ts @@ -29,12 +29,16 @@ export function parseCredentialRequest(input: string): CredentialRequest { /** * Render a credential answer in the format git expects on stdout. * + * The answer ends with a blank line, the protocol's terminator, so git can + * stop reading without waiting for end-of-file. + * * @param fields - Pairs to emit; values containing a newline are dropped, * since a newline would let a value forge additional protocol lines. */ export function formatCredentialResponse(fields: CredentialRequest): string { - return Object.entries(fields) + const lines = Object.entries(fields) .filter(([, value]) => !value.includes("\n") && !value.includes("\0")) .map(([key, value]) => `${key}=${value}\n`) .join(""); + return `${lines}\n`; } diff --git a/tests/commands/credential.test.ts b/tests/commands/credential.test.ts index b1cca5ea..d1e6844c 100644 --- a/tests/commands/credential.test.ts +++ b/tests/commands/credential.test.ts @@ -74,7 +74,7 @@ describe("archgate credential get", () => { await run("get", PLUGIN_REQUEST); expect(written()).toBe( - "protocol=https\nhost=plugins.archgate.dev\nusername=octocat\npassword=ey.access\n" + "protocol=https\nhost=plugins.archgate.dev\nusername=octocat\npassword=ey.access\n\n" ); }); diff --git a/tests/helpers/credential-store-renewal.test.ts b/tests/helpers/credential-store-renewal.test.ts index 8b023771..547e6379 100644 --- a/tests/helpers/credential-store-renewal.test.ts +++ b/tests/helpers/credential-store-renewal.test.ts @@ -249,3 +249,81 @@ describe("clearCredentials with an unresponsive helper", () => { } }, 20_000); }); + +// Git runs the helper many times per operation, so processes can race to +// refresh the same expired token set. The platform rotates the refresh +// token, so every exchange after the first fails. +describe("concurrent renewal", () => { + const expired = { + accessToken: "ey.stale", + refreshToken: "refresh-old", + expiresAt: Date.now() - 1_000, + }; + const rotated = { + accessToken: "ey.fromWinner", + refreshToken: "refresh-rotated", + expiresAt: Date.now() + 3_600_000, + }; + + test("uses the token another process already saved", async () => { + let read = 0; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => { + read += 1; + return sessionRecord("octocat", read === 1 ? expired : rotated); + }); + const refreshSpy = spyOn( + platformAuth, + "refreshAccessToken" + ).mockRejectedValue(new SessionExpiredError()); + try { + expect(await resolveAccessToken()).toEqual({ + token: "ey.fromWinner", + github_user: "octocat", + }); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); + + // The winner's write lands after its own exchange, so the loser may have + // to look more than once before the rotated record is there. + test("waits for a winner whose write lands late", async () => { + let read = 0; + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => { + read += 1; + return sessionRecord("octocat", read >= 3 ? rotated : expired); + }); + const refreshSpy = spyOn( + platformAuth, + "refreshAccessToken" + ).mockRejectedValue(new SessionExpiredError()); + try { + expect(await resolveAccessToken()).toEqual({ + token: "ey.fromWinner", + github_user: "octocat", + }); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); + + test("rethrows when the stored token set did not change", async () => { + const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => + sessionRecord("octocat", expired) + ); + const refreshSpy = spyOn( + platformAuth, + "refreshAccessToken" + ).mockRejectedValue(new SessionExpiredError()); + try { + expect(await rejectionMessage(resolveAccessToken())).toContain( + "session has expired" + ); + } finally { + refreshSpy.mockRestore(); + fillSpy.mockRestore(); + } + }); +}); diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts index 90635cc5..fa8cb0de 100644 --- a/tests/helpers/credential-store.test.ts +++ b/tests/helpers/credential-store.test.ts @@ -13,8 +13,7 @@ import { clearCredentials, } from "../../src/helpers/credential-store"; import { platformAuth } from "../../src/helpers/platform-auth"; -import { SessionExpiredError } from "../../src/helpers/session-expired-error"; -import { rejectionMessage, restoreEnv, safeRmSync } from "../test-utils"; +import { restoreEnv, safeRmSync } from "../test-utils"; /** * A `Bun.spawn` stand-in that answers `git credential fill` with one record. @@ -478,59 +477,4 @@ describe("credential-store", () => { } }); }); - - // Git runs the helper many times per operation, so processes can race to - // refresh the same expired token set. The platform rotates the refresh - // token, so every exchange after the first fails. - describe("concurrent renewal", () => { - const expired = { - accessToken: "ey.stale", - refreshToken: "refresh-old", - expiresAt: Date.now() - 1_000, - }; - const rotated = { - accessToken: "ey.fromWinner", - refreshToken: "refresh-rotated", - expiresAt: Date.now() + 3_600_000, - }; - - test("uses the token another process already saved", async () => { - let read = 0; - const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => { - read += 1; - return sessionRecord("octocat", read === 1 ? expired : rotated); - }); - const refreshSpy = spyOn( - platformAuth, - "refreshAccessToken" - ).mockRejectedValue(new SessionExpiredError()); - try { - expect(await resolveAccessToken()).toEqual({ - token: "ey.fromWinner", - github_user: "octocat", - }); - } finally { - refreshSpy.mockRestore(); - fillSpy.mockRestore(); - } - }); - - test("rethrows when the stored token set did not change", async () => { - const fillSpy = spyOn(Bun, "spawn").mockImplementation(() => - sessionRecord("octocat", expired) - ); - const refreshSpy = spyOn( - platformAuth, - "refreshAccessToken" - ).mockRejectedValue(new SessionExpiredError()); - try { - expect(await rejectionMessage(resolveAccessToken())).toContain( - "session has expired" - ); - } finally { - refreshSpy.mockRestore(); - fillSpy.mockRestore(); - } - }); - }); }); diff --git a/tests/helpers/git-credential-protocol.test.ts b/tests/helpers/git-credential-protocol.test.ts index 38dfacc4..b475fed5 100644 --- a/tests/helpers/git-credential-protocol.test.ts +++ b/tests/helpers/git-credential-protocol.test.ts @@ -51,8 +51,9 @@ describe("formatCredentialResponse", () => { password: "ey.token", }); + // The trailing blank line is the protocol's terminator. expect(output).toBe( - "protocol=https\nhost=plugins.archgate.dev\nusername=octocat\npassword=ey.token\n" + "protocol=https\nhost=plugins.archgate.dev\nusername=octocat\npassword=ey.token\n\n" ); }); @@ -64,6 +65,6 @@ describe("formatCredentialResponse", () => { ])("drops %s when the value can forge protocol lines", (key, value) => { const output = formatCredentialResponse({ host: "h", [key]: value }); - expect(output).toBe("host=h\n"); + expect(output).toBe("host=h\n\n"); }); }); From 1ee97f7fbaede0e5332703dadf0817e3f0c5d954 Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Mon, 7 Sep 2026 09:24:17 +0200 Subject: [PATCH 26/27] fix(auth): stop refresh when the credential helper cannot be unregistered While the helper still answers for the plugins host, clearing cannot see a legacy token behind it, so a failed unregister now halts refresh before anything is touched. Logout and refresh share the recovery hint. Also type the session-record test helpers with the schema-derived TokenSet, and say sign in rather than sign up on the installation page. Signed-off-by: Rhuan Barreto --- .../docs/getting-started/installation.mdx | 2 +- .../docs/nb/getting-started/installation.mdx | 2 +- .../pt-br/getting-started/installation.mdx | 2 +- src/commands/login.ts | 25 ++++++++++++++----- tests/commands/login.test.ts | 19 ++++++++++++++ .../helpers/credential-store-renewal.test.ts | 3 ++- tests/helpers/credential-store.test.ts | 3 ++- 7 files changed, 45 insertions(+), 11 deletions(-) diff --git a/docs/src/content/docs/getting-started/installation.mdx b/docs/src/content/docs/getting-started/installation.mdx index 76fca77c..c9302608 100644 --- a/docs/src/content/docs/getting-started/installation.mdx +++ b/docs/src/content/docs/getting-started/installation.mdx @@ -233,5 +233,5 @@ If you prefer `npm install -g archgate` instead of the proto plugin, you need to Once installed, run `archgate init` in your project to set up linting and guardrails. See the [Quick Start](/getting-started/quick-start/) guide for a walkthrough. :::tip[Editor plugins (beta)] -Want your AI agent to read ADRs before coding and validate after? The editor plugins for [Claude Code](/guides/claude-code-plugin/) and [Cursor](/guides/cursor-integration/) add a full guardrails workflow on top of the CLI. Run `archgate login` to sign up and get started. +Want your AI agent to read ADRs before coding and validate after? The editor plugins for [Claude Code](/guides/claude-code-plugin/) and [Cursor](/guides/cursor-integration/) add a full guardrails workflow on top of the CLI. Run `archgate login` to sign in and get started. ::: diff --git a/docs/src/content/docs/nb/getting-started/installation.mdx b/docs/src/content/docs/nb/getting-started/installation.mdx index 0674cf14..769d4a32 100644 --- a/docs/src/content/docs/nb/getting-started/installation.mdx +++ b/docs/src/content/docs/nb/getting-started/installation.mdx @@ -233,5 +233,5 @@ Hvis du foretrekker `npm install -g archgate` i stedet for proto-pluginen, må d Etter installasjonen, kjør `archgate init` i prosjektet ditt for å sette opp linting og guardrails. Se guiden [Hurtigstart](/getting-started/quick-start/) for en gjennomgang. :::tip[Editorplugins (beta)] -Vil du at AI-agenten din skal lese ADR-er før den koder og validere etterpå? Editorpluginene for [Claude Code](/guides/claude-code-plugin/) og [Cursor](/guides/cursor-integration/) legger til en komplett guardrails-arbeidsflyt på toppen av CLI-en. Kjør `archgate login` for å registrere deg og komme i gang. +Vil du at AI-agenten din skal lese ADR-er før den koder og validere etterpå? Editorpluginene for [Claude Code](/guides/claude-code-plugin/) og [Cursor](/guides/cursor-integration/) legger til en komplett guardrails-arbeidsflyt på toppen av CLI-en. Kjør `archgate login` for å logge inn og komme i gang. ::: diff --git a/docs/src/content/docs/pt-br/getting-started/installation.mdx b/docs/src/content/docs/pt-br/getting-started/installation.mdx index 6ab5b237..7f888add 100644 --- a/docs/src/content/docs/pt-br/getting-started/installation.mdx +++ b/docs/src/content/docs/pt-br/getting-started/installation.mdx @@ -233,5 +233,5 @@ Se você preferir `npm install -g archgate` ao invés do plugin proto, precisa c Após a instalação, execute `archgate init` no seu projeto para configurar linting e guardrails. Veja o guia de [Início Rápido](/getting-started/quick-start/) para um passo a passo. :::tip[Plugins para editores (beta)] -Quer que seu agente de IA leia ADRs antes de programar e valide depois? Os plugins para editores do [Claude Code](/guides/claude-code-plugin/) e [Cursor](/guides/cursor-integration/) adicionam um fluxo completo de guardrails sobre a CLI. Execute `archgate login` para se cadastrar e começar. +Quer que seu agente de IA leia ADRs antes de programar e valide depois? Os plugins para editores do [Claude Code](/guides/claude-code-plugin/) e [Cursor](/guides/cursor-integration/) adicionam um fluxo completo de guardrails sobre a CLI. Execute `archgate login` para entrar e começar. ::: diff --git a/src/commands/login.ts b/src/commands/login.ts index 29e79d8f..a21f8fdb 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -29,6 +29,19 @@ function credentialsLeftBehind(): UserError { ); } +/** + * Raised when the helper entry survives an unregister; logout and refresh + * share it. + * + * @param prefix - What already happened before the helper removal failed. + */ +function helperLeftBehind(prefix: string): UserError { + return new UserError( + `${prefix}, but the git credential helper entry could not be removed.`, + "Remove it with `git config --global --unset-all credential.https://plugins.archgate.dev.helper`." + ); +} + export function registerLoginCommand(program: Command) { const login = program .command("login") @@ -84,11 +97,7 @@ export function registerLoginCommand(program: Command) { success: unregistered && cleared, }); if (!cleared) throw credentialsLeftBehind(); - if (!unregistered) { - throw new UserError( - "Credentials removed, but the git credential helper entry could not be removed. Remove it with `git config --global --unset-all credential.https://plugins.archgate.dev.helper`." - ); - } + if (!unregistered) throw helperLeftBehind("Credentials removed"); console.log("Logged out successfully."); } catch (err) { await handleCommandError(err); @@ -100,7 +109,11 @@ export function registerLoginCommand(program: Command) { .description("Sign in again, replacing stored tokens") .action(async () => { try { - await unregisterGitCredentialHelper(); + // While the helper still answers for the plugins host, clearing cannot + // see a legacy token behind it, so a failed unregister stops here. + if (!(await unregisterGitCredentialHelper())) { + throw helperLeftBehind("Nothing changed"); + } // A leftover record would keep answering for the old account, so a // failed clear stops the refresh rather than signing in over it. if (!(await clearCredentials())) throw credentialsLeftBehind(); diff --git a/tests/commands/login.test.ts b/tests/commands/login.test.ts index 79802627..a65f8a9f 100644 --- a/tests/commands/login.test.ts +++ b/tests/commands/login.test.ts @@ -453,6 +453,25 @@ describe("login action handlers", () => { expect(order).toEqual(["unregister", "clear", "login"]); }); + // While the helper still answers for the plugins host, clearing cannot + // see a legacy token behind it, so nothing may be touched. + test("stops before clearing when the helper entry cannot be removed", async () => { + unregisterHelperSpy.mockResolvedValue(false); + + const program = makeProgram(); + expect( + await rejectionMessage( + program.parseAsync(["node", "test", "login", "refresh"]) + ) + ).toContain("exitWith(1)"); + + expect(clearCredentialsSpy).not.toHaveBeenCalled(); + expect(runLoginFlowSpy).not.toHaveBeenCalled(); + expect(printed(errorSpy)).toContain( + "git config --global --unset-all credential.https://plugins.archgate.dev.helper" + ); + }); + // A leftover record would keep answering for the old account. test("stops when credentials could not be cleared", async () => { clearCredentialsSpy.mockResolvedValueOnce(false); diff --git a/tests/helpers/credential-store-renewal.test.ts b/tests/helpers/credential-store-renewal.test.ts index 547e6379..f2362e6b 100644 --- a/tests/helpers/credential-store-renewal.test.ts +++ b/tests/helpers/credential-store-renewal.test.ts @@ -11,6 +11,7 @@ import { } from "../../src/helpers/credential-store"; import * as logMod from "../../src/helpers/log"; import { platformAuth } from "../../src/helpers/platform-auth"; +import type { TokenSet } from "../../src/helpers/platform-auth"; import { SessionExpiredError } from "../../src/helpers/session-expired-error"; import { UserError } from "../../src/helpers/user-error"; import { rejectionMessage } from "../test-utils"; @@ -30,7 +31,7 @@ function gitCredentialStub(username: string, password: string) { /** The record `saveTokenSet` files: one fixed account, the session as JSON. */ function sessionRecord( user: string, - tokens: { accessToken: string; refreshToken: string; expiresAt: number } + tokens: TokenSet ): ReturnType { return gitCredentialStub("archgate", JSON.stringify({ user, tokens })); } diff --git a/tests/helpers/credential-store.test.ts b/tests/helpers/credential-store.test.ts index fa8cb0de..1c025d6d 100644 --- a/tests/helpers/credential-store.test.ts +++ b/tests/helpers/credential-store.test.ts @@ -13,6 +13,7 @@ import { clearCredentials, } from "../../src/helpers/credential-store"; import { platformAuth } from "../../src/helpers/platform-auth"; +import type { TokenSet } from "../../src/helpers/platform-auth"; import { restoreEnv, safeRmSync } from "../test-utils"; /** @@ -38,7 +39,7 @@ function gitCredentialStub( /** The record `saveTokenSet` files: one fixed account, the session as JSON. */ function sessionRecord( user: string, - tokens: { accessToken: string; refreshToken: string; expiresAt: number } + tokens: TokenSet ): ReturnType { return gitCredentialStub("archgate", JSON.stringify({ user, tokens })); } From 39b00e4c91ddc8ede0ba5553a59feef84f74b4f9 Mon Sep 17 00:00:00 2001 From: rhuanbarreto <283004+rhuanbarreto@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:24:50 +0000 Subject: [PATCH 27/27] docs: regenerate llms-full.txt Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- docs/public/llms-full.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 01ac0972..2be2c100 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -344,7 +344,7 @@ If you prefer `npm install -g archgate` instead of the proto plugin, you need to Once installed, run `archgate init` in your project to set up linting and guardrails. See the [Quick Start](/getting-started/quick-start/) guide for a walkthrough. -Want your AI agent to read ADRs before coding and validate after? The editor plugins for [Claude Code](/guides/claude-code-plugin/) and [Cursor](/guides/cursor-integration/) add a full guardrails workflow on top of the CLI. Run `archgate login` to sign up and get started. +Want your AI agent to read ADRs before coding and validate after? The editor plugins for [Claude Code](/guides/claude-code-plugin/) and [Cursor](/guides/cursor-integration/) add a full guardrails workflow on top of the CLI. Run `archgate login` to sign in and get started. ---