diff --git a/docs/guide/ai.md b/docs/guide/ai.md index 0617b4940..279f77d0e 100644 --- a/docs/guide/ai.md +++ b/docs/guide/ai.md @@ -17,8 +17,14 @@ npm run dev When the app imports `void/ai`, Void enables a remote Workers AI binding during development and preview. Requests use your Cloudflare account and its allowance; -Workers AI has no local simulator. Use your Cloudflare login or -`CLOUDFLARE_API_TOKEN` in an automated environment. +Workers AI has no local simulator. Development and preview use the same +Cloudflare login as `void connect`; sign in with `void cloudflare login`. A +`CLOUDFLARE_API_TOKEN` takes precedence; use one in an automated environment. + +If development stops with a Cloudflare authentication error, run +`void cloudflare login` in a terminal as a user who can access the deployment +account shown by `void cloudflare status`, or set `CLOUDFLARE_API_TOKEN`. +`wrangler login` does not affect development. For a team platform, use `void connect ` and link your project. Development requests use that platform's account and usage limits. diff --git a/docs/guide/platform/installation/credentials.md b/docs/guide/platform/installation/credentials.md index 55ac729c9..d56108789 100644 --- a/docs/guide/platform/installation/credentials.md +++ b/docs/guide/platform/installation/credentials.md @@ -6,6 +6,18 @@ outline: deep Keep one password-manager entry for this platform. Paste the saved values into the installer when asked; most do not need shell environment variables. +| Credential | Created by | Why the platform needs it | +| -------------------------------------- | -------------------------------- | ---------------------------------------------------------------------------------------- | +| Management API token | You, in Cloudflare | Lets the CLI change DNS and Access for a domain installation or Access setup. | +| Runtime API token | You, in Cloudflare | Stored as a Worker secret so the platform can deploy apps after you close your terminal. | +| PostgreSQL URL | You, with your database provider | Stores platform users, projects, and deployments. | +| Login provider client ID and secret | You, or Void for Access login | Signs people in to the platform. | +| R2 Access Key ID and Secret Access Key | You, in Cloudflare | Lets the platform sign upload links so `void deploy` sends static files straight to R2. | +| Access setup and app-access tokens | You, in Cloudflare | Only with Cloudflare Access: create and manage Access applications. | +| JWT signing key | Void, on interactive installs | Signs platform login tokens. | +| Project encryption key | Void, on interactive installs | Encrypts app secrets stored by your platform. | +| Email signing key | Void, when email is enabled | Signs confirmation links and email requests. | + ## Cloudflare API Tokens {#runtime-token-permissions} For a domain installation or Cloudflare Access setup, the installer guides you through creating separate management and runtime tokens using Cloudflare's [API token setup](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/). Its links preselect the permissions and suggest names based on your platform's display name, but Cloudflare's form cannot preselect a zone: for a domain installation, change the zone policy from **All Domains** to **Specified Domains** and select your zone. If Void will create the zone, keep **All Domains** on both tokens: the zone does not exist yet, and the runtime token needs it to purge cache. Save the tokens in your password manager and paste them into the masked CLI prompts. @@ -74,17 +86,21 @@ The installer deploys the email gateway, prepares the shared mail route, and ver ## R2 Upload Credentials +`void deploy --platform void` uploads your app's static files straight to R2 instead of through the platform API. The API signs short-lived upload links for them with an R2 key pair. + The installer opens the **R2 token creation** form with a token name based on your platform's display name, **Object Read & Write**, and **Apply to all buckets in this account (including newly created buckets)** preselected. Review the settings and create an account token, or a user token if your role requires it. Save its **Access Key ID** and **Secret Access Key** in your password manager, then paste both into the installer. These differ from the management and runtime API tokens. See [R2’s token instructions](https://developers.cloudflare.com/r2/api/tokens/). ## Signing and Encryption Keys {#signing-and-encryption-keys} -The **JWT signing key** signs platform login tokens. The **Project encryption key** encrypts app secrets stored by your platform. Generate a separate key for each by running this command twice: +The **JWT signing key** signs platform login tokens. The **Project encryption key** encrypts app secrets stored by your platform. An interactive install generates both and keeps them in this machine's encrypted recovery data. Lifecycle commands on this machine read them from there. + +To keep your own copy, for example to repair the platform from CI or another machine, create both keys before installing and set `VOID_PLATFORM_JWT_SECRET` and `VOID_PLATFORM_PROJECT_SECRET_KEY`. Non-interactive installs must set both. Generate a separate key for each by running this command twice: ```sh openssl rand -base64 32 ``` -Save each result in your password manager, then paste it into the corresponding installer prompt. The command generates 32 random bytes encoded as base64, suitable for either field. Keep the two original keys for recovery; do not regenerate them when resuming or upgrading. +Save each result in your password manager. The command generates 32 random bytes encoded as base64, suitable for either field. Keep the two original keys for recovery; do not regenerate them when resuming or upgrading. ::: details Generate the keys without printing them to your terminal diff --git a/docs/guide/platform/installation/domains.md b/docs/guide/platform/installation/domains.md index 8aed5719c..6954951c9 100644 --- a/docs/guide/platform/installation/domains.md +++ b/docs/guide/platform/installation/domains.md @@ -100,4 +100,4 @@ New platform resources use deterministic `void--` names | Proxied wildcard DNS records | 0 or 1 | Created only when an application domain is configured | | Zones | 0 or 1 | Created only when the requested application zone is absent | -The API Worker uses four Durable Object classes for usage, cron scheduling, error monitoring, and concurrency. Worker bindings create the request and log datasets in Analytics Engine. The core installation doesn't create Container applications, a GitHub App, a dashboard Worker, or build Workers. +The API Worker uses four Durable Object classes for usage, cron scheduling, error monitoring, and concurrency. Worker bindings create the request and log datasets in Analytics Engine, which must be [enabled for the account](/guide/platform/installation/prerequisites#if-cloudflare-rejects-analytics-engine). The core installation doesn't create Container applications, a GitHub App, a dashboard Worker, or build Workers. diff --git a/docs/guide/platform/installation/maintenance.md b/docs/guide/platform/installation/maintenance.md index 7a3d19d5c..99e733aba 100644 --- a/docs/guide/platform/installation/maintenance.md +++ b/docs/guide/platform/installation/maintenance.md @@ -50,6 +50,43 @@ For completed provisioning whose administrator setup is still pending, run expired. Enter it at `/setup`, sign in, and confirm the displayed identity. An installation that already has an administrator does not reopen setup. +### First administrator sign-in fails + +If sign-in fails after you enter the setup code, the page shows the reason and an +error code. When a provider setting may be the cause, it also shows the callback +URL the provider must have. + +| Error code | What to check | +| --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `github_incorrect_client_credentials`, `github_failed`, `oidc_token_failed` | The client ID and client secret, and that the provider has the callback URL | +| `github_redirect_uri_mismatch` | The callback URL registered in the GitHub OAuth app | +| `oidc_discovery_failed` | The issuer URL, exactly, including any trailing slash | +| `github_unverified_user_email` | Verify the primary email of the GitHub account | +| `google_domain_not_allowed`, `oidc_group_not_allowed` | Sign in with an allowed account, or change the allowed domains or groups | +| `login_denied`, `github_bad_verification_code` | Start a new sign-in | + +If the provider shows its own error page instead, register exactly the callback +URL printed with your setup code. + +To change the login settings, run repair on the computer that installed the +platform: + +```sh +void platform repair +void platform repair --auth-config +``` + +Interactive repair asks whether to change the login settings, with the saved +values filled in; leave the client secret blank to keep it. `--auth-config` +takes a file in the [install format](/guide/platform/installation/setup#finish-login-configuration) +and is the way to change OIDC allowed groups. Login methods and Access protection +stay as installed. Repair prints a new setup code and the callback URL. + +A GitHub-only installation signs in with the GitHub OAuth App from its +installation settings. To replace its client ID and secret, set +`VOID_PLATFORM_GITHUB_CLIENT_ID` and `VOID_PLATFORM_GITHUB_CLIENT_SECRET` when +you run `void platform repair `. + ### Repair missing resources Preview what needs repair, then apply it: @@ -164,7 +201,7 @@ During a restore, keep platform and application traffic disabled before changing ### Where local state lives -Installation records live in `~/.void/platforms/`. Credentials are stored separately in encrypted recovery files, with the encryption key in your operating system's keychain. Keep your original secrets in a password manager for recovery on another machine. +Installation records live in `~/.void/platforms/`. Credentials are stored separately in encrypted recovery files, with the encryption key in your operating system's keychain. Keep your original secrets in a password manager for recovery on another machine. Signing and encryption keys that Void generated exist only in these files; to keep your own copy, [supply them when installing](/guide/platform/installation/credentials#signing-and-encryption-keys). If the keychain isn't available, Void stops before writing secrets. Headless environments can supply `VOID_PLATFORM_RECOVERY_KEY`: a canonical base64-encoded 32-byte key from a secret manager. A temporary CI runner can use a new recovery-encryption key for each run only when every original credential remains available in protected CI secrets, including `VOID_PLATFORM_JWT_SECRET`, `VOID_PLATFORM_EMAIL_SIGNING_SECRET` for an email-enabled installation, and either the original `VOID_PLATFORM_PROJECT_SECRET_KEY` or the complete rotated keyring and active-version pair. diff --git a/docs/guide/platform/installation/prerequisites.md b/docs/guide/platform/installation/prerequisites.md index fd2485ec4..06d59f2f7 100644 --- a/docs/guide/platform/installation/prerequisites.md +++ b/docs/guide/platform/installation/prerequisites.md @@ -6,7 +6,7 @@ outline: deep To run a Void platform, you need: -- A Cloudflare account with Workers for Platforms and R2 enabled. +- A Cloudflare account with Workers for Platforms, R2, and Analytics Engine enabled. - An empty hosted PostgreSQL database. - An account with your chosen login provider: GitHub, Google, OIDC, or Cloudflare Access. - A domain for your apps, or `workers.dev` for testing. You can [add a domain later](/guide/platform/installation/domains#adding-a-domain). @@ -28,6 +28,10 @@ If that domain is already in this Cloudflare account, use its existing zone. Oth You can also let Void create the zone during installation. Setting it up now lets you scope the API tokens to that zone and finish installation without a DNS pause. The platform API uses `workers.dev` by default, so it needs no additional domain. +### If Cloudflare rejects Analytics Engine + +The platform stores request metrics and logs in Analytics Engine. If installation stops with "You need to enable Analytics Engine" (code 10089), add an Analytics Engine binding to a Worker that is not part of the platform under **Settings** > **Bindings**, and deploy it once from the dashboard. A new Hello World Worker works. Do not edit the platform's own Workers. Then run the printed `void platform install --resume` command. + ## Install the CLI and Preview With Node.js 24.21.0 or later, install the CLI: @@ -78,7 +82,7 @@ Use any PostgreSQL provider that accepts connections from your computer and Clou | [PlanetScale Postgres](https://planetscale.com/docs/postgres/connecting) | Choose **Postgres**, not Vitess/MySQL. Open **Connect**, create role credentials, and use the direct primary connection on port `5432`, not PgBouncer on `6432`. | | [Neon](https://neon.com/docs/get-started-with-neon/connect-neon) | Open **Connect**, select the database and owner role, and turn **connection pooling off**. | -Once installation claims the database, continue using that same database for resume and maintenance commands. Uninstall never deletes external PostgreSQL. +Once installation claims the database, continue using that same database for resume and maintenance commands. If installation stops because it cannot use the database, for example because the database is not empty, set `VOID_PLATFORM_DATABASE_URL` to another database's URL before you resume. Uninstall never deletes external PostgreSQL. ### Use a provider CLI diff --git a/docs/guide/platform/installation/setup.md b/docs/guide/platform/installation/setup.md index 719f279b3..326fd43bf 100644 --- a/docs/guide/platform/installation/setup.md +++ b/docs/guide/platform/installation/setup.md @@ -153,6 +153,28 @@ providers, and existing company policies. Void creates dedicated applications and a scoped service token for protected installation checks. It preserves your company policies, including device and MFA requirements. +If no policy fits, choose **Allow my company’s email domain** in the policy list +and enter your domains. Void creates that Allow policy in your account and keeps +it there for reuse; you can add MFA or device rules to it in Cloudflare later. +For identity-provider groups or other rules, choose **Create in Cloudflare** and +follow the printed steps. + +An Allow policy must name who may sign in: emails, email domains or lists, +identity-provider groups (Okta, Microsoft Entra ID, Google Workspace, GitHub +organizations, or SAML), authentication contexts, members of a Cloudflare +account, or Access groups built from them. Microsoft Entra ID groups and +authentication contexts count only when they come from an identity provider you +select. A login method also counts when it is one of the identity providers you select and uses +Cloudflare's connector for a company directory: Microsoft Entra ID, Okta, +OneLogin, PingOne, Centrify, Google Workspace, or the Cloudflare login with +**Restrict to account members** turned on. Policies that allow everyone, any +service token, or bypass Access are rejected, and so are policies that rely +only on One-time PIN, a public login such as GitHub, or a login added with the +generic **OpenID Connect** or **SAML** option, even one that fronts your +company directory. Void cannot tell who can sign in through a generic login; +add a Require rule such as **Emails ending in** your company domain to that +policy. + The setup credential needs **Access: Apps and Policies Write** and **Access: Organizations, Identity Providers, and Groups Read** in the identity account. Creating protection also needs **Access: Service Tokens Write**. @@ -212,7 +234,7 @@ This OAuth App handles sign-in. ## Finish installation -The installer collects your runtime token, PostgreSQL URL, login-provider credentials, R2 credentials, and signing and encryption keys. It masks secret values and saves setup progress encrypted through your system keychain. Keep a password-manager copy for recovery on another machine. +The installer collects your runtime token, PostgreSQL URL, login-provider credentials, and R2 credentials, and generates the [signing and encryption keys](/guide/platform/installation/credentials#signing-and-encryption-keys). It masks secret values and saves setup progress encrypted through your system keychain. Keep a password-manager copy for recovery on another machine. Once installation finishes, open the printed admin dashboard link. For GitHub-only setup, sign in as the administrator selected during installation. For configurable login, use the one-time `/setup` code to confirm the administrator’s identity. Use the printed API URL to [connect and deploy an app](/guide/platform/installation/first-deployment). diff --git a/docs/guide/platform/installation/uninstall.md b/docs/guide/platform/installation/uninstall.md index 4439cead5..76f6f19f4 100644 --- a/docs/guide/platform/installation/uninstall.md +++ b/docs/guide/platform/installation/uninstall.md @@ -33,7 +33,7 @@ void platform uninstall [id] --plan void platform uninstall [id] ``` -Uninstall blocks platform traffic, removes Queues that no remaining platform Worker uses, and lists the resources left for you to review. Use `--plan` to review deletions and retained resources before making changes. By default, Workers, the Queues they use, KV, R2, Hyperdrive, the dispatch namespace, and AI Gateway remain in your account. Adopted resources, external PostgreSQL, and zones are always retained. Email Routing settings are retained; `--purge-resources` removes only the rules that send mail to the platform. DNS records, routes, custom domains, and Cloudflare Access resources are retained unless you use `--purge-resources`. +Uninstall blocks platform traffic, removes Queues that no remaining platform Worker uses, and lists the resources left for you to review. Use `--plan` to review deletions and retained resources before making changes. By default, Workers, the Queues they use, KV, R2, Hyperdrive, the dispatch namespace, and AI Gateway remain in your account. Adopted resources, external PostgreSQL, and zones are always retained. To install a platform again, use a new empty PostgreSQL database. Email Routing settings are retained; `--purge-resources` removes only the rules that send mail to the platform. DNS records, routes, custom domains, and Cloudflare Access resources are retained unless you use `--purge-resources`. When uninstall finishes, the record of what was kept stays at `~/.void/platforms//.json`. Without `--purge-data`, the encrypted credentials file `.recovery.enc` next to it is kept too. Delete both once you have finished the manual cleanup below. @@ -91,6 +91,11 @@ If installation configured Cloudflare Access, the uninstall plan lists its recor Uninstall does not remove the applications created for [project Zero Trust](/guide/platform/administration/zero-trust). While Zero Trust is enabled or still has Access applications, uninstall and `--plan` stop before any change and list those applications. Run `void platform zero-trust disable` first. Uninstall reads this from the installation database, so it also stops when the database cannot be reached. +If no platform user exists yet, for example because first administrator setup did +not finish, nobody can run `zero-trust disable`. Uninstall then turns off project +Zero Trust and deletes its applications itself. Run it on the computer that installed +the platform. If the uninstall asks you to, update an older platform runtime first. + ::: details Why some resources require manual cleanup Resources that may have been shared or repurposed require manual review before deletion. Void verifies ownership, keeps those resources in place, and blocks platform traffic. diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index a71f5592a..5b77b7dc8 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -210,7 +210,7 @@ Custom domain commands update routes immediately. If a first deployment times ou Local D1, KV, and R2 bindings use local data. Your production resource IDs are used during deployment. -Workers AI runs remotely after you connect to Cloudflare, so development calls consume your account's allowance. +Workers AI runs remotely after you connect to Cloudflare, so development calls consume your account's allowance. Remote bindings in development and preview use your `void cloudflare login` session, or `CLOUDFLARE_API_TOKEN` when it is set. ### AI on your Cloudflare account {#ai-self-host} diff --git a/docs/reference/cli/platform-installation.md b/docs/reference/cli/platform-installation.md index e9c8aa514..b3b76d5d5 100644 --- a/docs/reference/cli/platform-installation.md +++ b/docs/reference/cli/platform-installation.md @@ -58,11 +58,15 @@ authentication file. Upgrades preserve administrator settings. New resources use `void--` names; choose an unused installation name. The installer opens setup pages for missing credentials and reuses values already supplied. -During Access setup, choose existing company policies or **Create new** for manual -setup instructions and an account dashboard link. After you save the policy and -confirm completion, Void reloads the list so you can select it and continue in the -same run. **Refresh list** reloads policies created in another window. Your -identity-provider choice and still-available policy selections are retained. +During Access setup, choose existing company policies or **Create in Cloudflare** +for manual setup instructions and an account dashboard link. After you save the +policy and confirm completion, Void reloads the list so you can select it and +continue in the same run. When Void creates the Access applications, **Allow my +company’s email domain** creates an Allow policy for the domains you enter and +selects it. **Refresh list** reloads policies created in another window. Your +identity-provider choice and still-available policy selections are retained. If a +selected policy does not restrict sign-in to company identities, Void explains why +and asks again. Installation progress and partial credentials are saved encrypted locally. Rerun the installer to continue unfinished setup, or pass `--resume --name `. Use lifecycle commands for completed installations. @@ -98,7 +102,7 @@ Use these commands to recover, update, pause, or remove an installation: void platform discover [--account ] [--installation ] void platform upgrade [id] [--runtime ] [--dashboard-url ] [--plan] [--yes] void platform rollback [id] --runtime [--from-runtime ] [--plan] [--yes] -void platform repair [id] [--runtime ] [--dashboard-url ] [--plan] [--yes] +void platform repair [id] [--runtime ] [--dashboard-url ] [--auth-config ] [--plan] [--yes] void platform disable [id] [--plan] [--yes] void platform enable [id] [--runtime ] [--plan] [--yes] void platform uninstall [id] [--plan] [--purge-data | --purge-resources] [--keep-zone] [--yes] @@ -113,6 +117,12 @@ use `repair --dashboard-url ` or `upgrade --dashboard-url `. Preview with `--plan` first. Existing Access protection must cover the configured dashboard origin before maintenance can proceed. See [Optional Dashboard](../../guide/platform/installation/domains.md#optional-dashboard). +While first administrator setup is pending, `repair --auth-config ` replaces +the login settings from a file in the install format and prints a new setup code. +Interactive repair offers the same change with the saved values filled in. Login +methods and Access protection stay as installed. Run it on the computer that +installed the platform. See [First administrator sign-in fails](../../guide/platform/installation/maintenance.md#first-administrator-sign-in-fails). + After discovery on another machine, set `VOID_PLATFORM_DATABASE_URL`. See [Manage an installation from another machine](../../guide/platform/installation/maintenance.md#manage-an-installation-from-another-machine) for the secrets needed to recreate a missing Worker. | Command | Behavior | @@ -137,6 +147,8 @@ An upgrade completes after the new Workers pass health checks. If rollout fails, `platform rollback` restores a compatible earlier runtime without reversing database migrations. Pass its files with `--runtime`; if the installed version is custom, also supply that version with `--from-runtime`. Void refuses incompatible targets. +If project Zero Trust is on and no platform user exists yet, uninstall turns it off and deletes its Access applications first. Run it on the computer that installed the platform. + Uninstall verifies remote ownership before removing anything. Data resources are retained unless you pass `--purge-data`. Workers, the Queues they use, R2, AI Gateway, DNS records, routes, custom domains, adopted resources, external PostgreSQL, and zones are retained for manual review. `--purge-resources` includes `--purge-data` and also deletes every other resource the installation created, including Workers, Queues, R2, AI Gateway, the platform DNS record, routes, custom domains, and installer-created Access resources. It removes only the Email Routing rules that send mail to the platform email gateway, and keeps zones, adopted resources, external PostgreSQL, other Email Routing settings, and API tokens. A route, custom domain, DNS record, or Access resource that no longer matches the installation's record is kept and listed. It refuses before any change while projects remain or resource reads are denied. Use the Edit permissions listed by `--plan`; read checks cannot verify them, so a denied deletion can stop uninstall after changes have begun. Rerun the same command to continue after an interruption, or after a plain or `--purge-data` uninstall stopped. diff --git a/packages/platform/src/access-policy.ts b/packages/platform/src/access-policy.ts new file mode 100644 index 000000000..50d63a9f9 --- /dev/null +++ b/packages/platform/src/access-policy.ts @@ -0,0 +1,93 @@ +// Rule kinds whose non-empty value names a company identity source or an +// Access group built from such rules. +const COMPANY_IDENTITY_RULES = [ + 'email_list', + 'gsuite', + 'github-organization', + 'saml', + 'okta', + 'azureAD', + 'auth_context', + 'group', +] as const; + +// Identity provider types whose required settings name one company directory: +// an Entra ID tenant, a Centrify, Okta or OneLogin account, a Google Workspace +// domain or a PingOne environment. One-time PIN, passkeys and public logins +// such as GitHub, Google (consumer accounts), Facebook, LinkedIn and Yandex +// admit anyone with an email address or an account there. Generic OIDC and +// SAML connect any provider, including ones with public sign-up, such as +// consumer Google in Cloudflare's own OIDC example, so their type proves +// nothing about who can sign in. +const COMPANY_IDENTITY_PROVIDER_TYPES: ReadonlySet = new Set([ + 'azureAD', + 'centrify', + 'google-apps', + 'okta', + 'onelogin', + 'pingone', +]); + +function plainObject(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +/** An exact email domain, such as `example.com`, without wildcards or an `@`. */ +export function isPlatformAccessEmailDomain(value: unknown): value is string { + return typeof value === 'string' && /^(?:[a-z0-9-]+\.)+[a-z]{2,63}$/i.test(value); +} + +/** Whether signing in with this Access identity provider proves a company identity. */ +export function isPlatformAccessCompanyIdentityProvider( + provider: Readonly>, +): boolean { + if (provider.type === 'cloudflare') { + // Without this setting, any Cloudflare user can sign in. + return plainObject(provider.config) && provider.config.restrict_to_account_members === true; + } + return COMPANY_IDENTITY_PROVIDER_TYPES.has(provider.type); +} + +/** + * Whether one Access include or require rule restricts people to company + * identities. The installer checks the policies that protect the platform and + * the API checks project Zero Trust policies with this same rule list. + * + * A `login_method` rule admits everyone who can use the identity provider it + * names, so it counts only when `companyLoginMethod` accepts that provider ID. + */ +export function isPlatformAccessIdentityRule( + rule: Readonly>, + companyLoginMethod: (identityProviderId: string) => boolean, +): boolean { + if (rule.email_domain) { + return plainObject(rule.email_domain) && isPlatformAccessEmailDomain(rule.email_domain.domain); + } + if (rule.email) { + return ( + plainObject(rule.email) && + typeof rule.email.email === 'string' && + /^[^\s*@]+@[^\s*@]+\.[^\s*@]+$/.test(rule.email.email) + ); + } + if (rule.login_method) { + return ( + plainObject(rule.login_method) && + typeof rule.login_method.id === 'string' && + companyLoginMethod(rule.login_method.id) + ); + } + if (rule.cloudflare_account_member) { + // Members of one Cloudflare account; without an ID, the account that owns the policy. + const member = rule.cloudflare_account_member; + return ( + plainObject(member) && + (member.account_id === undefined || + (typeof member.account_id === 'string' && /^[0-9a-f]{32}$/i.test(member.account_id))) + ); + } + return COMPANY_IDENTITY_RULES.some((key) => { + const value = Object.hasOwn(rule, key) ? rule[key] : undefined; + return plainObject(value) && Object.keys(value).length > 0; + }); +} diff --git a/packages/platform/src/app-access.ts b/packages/platform/src/app-access.ts index de68ce0d7..f5c8d1354 100644 --- a/packages/platform/src/app-access.ts +++ b/packages/platform/src/app-access.ts @@ -1,4 +1,6 @@ export const PLATFORM_APP_ACCESS_SETUP_PROOF_PREFIX = 'void-platform-app-access-v1'; +/** Uninstall handoff that turns off installer-managed project Zero Trust before any user exists. */ +export const PLATFORM_APP_ACCESS_TURN_OFF_PROOF_PREFIX = 'void-platform-app-access-turn-off-v1'; /** Installer handoff. Token values belong only in encrypted recovery and request bodies. */ export type PlatformAppAccessSetup = Readonly<{ diff --git a/packages/platform/src/index.ts b/packages/platform/src/index.ts index c71a5fddf..8d342d410 100644 --- a/packages/platform/src/index.ts +++ b/packages/platform/src/index.ts @@ -1,5 +1,11 @@ +export { + isPlatformAccessCompanyIdentityProvider, + isPlatformAccessEmailDomain, + isPlatformAccessIdentityRule, +} from './access-policy.ts'; export { PLATFORM_APP_ACCESS_SETUP_PROOF_PREFIX, + PLATFORM_APP_ACCESS_TURN_OFF_PROOF_PREFIX, parsePlatformAppAccessSetup, type PlatformAppAccessSetup, } from './app-access.ts'; diff --git a/packages/platform/test/unit/access-policy.test.ts b/packages/platform/test/unit/access-policy.test.ts new file mode 100644 index 000000000..49067ded2 --- /dev/null +++ b/packages/platform/test/unit/access-policy.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { isPlatformAccessIdentityRule } from '../../src/access-policy.ts'; + +const anyLoginMethod = () => true; + +describe('isPlatformAccessIdentityRule', () => { + it.each([ + { cloudflare_account_member: {} }, + { cloudflare_account_member: { account_id: '023e105f4ecef8ad9ca31a8372d0c353' } }, + ])('counts Cloudflare account members as company identities (%j)', (rule) => { + expect(isPlatformAccessIdentityRule(rule, anyLoginMethod)).toBe(true); + }); + + it.each([ + { cloudflare_account_member: true }, + { cloudflare_account_member: [] }, + { cloudflare_account_member: { account_id: '' } }, + { cloudflare_account_member: { account_id: '*' } }, + { cloudflare_account_member: { account_id: 123 } }, + ])('rejects a malformed Cloudflare account member rule (%j)', (rule) => { + expect(isPlatformAccessIdentityRule(rule, anyLoginMethod)).toBe(false); + }); +}); diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index ac73f0c56..79f59c81b 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -80,6 +80,7 @@ generated entries must preserve the module-boundary rules below. - **Installation app access**: New installs record `--app-access public|platform|custom` in deployment schema 4. Reuse company selections only within their account; require explicit consent before retaining a setup token for ongoing app management. Keep the setup payload in encrypted recovery, never Worker secrets. Complete the authenticated, resumable Zero Trust handoff before opening traffic, and preserve completed/admin settings during upgrades. Older manifests remain unchanged. - **Cloudflare session switching**: Explicit `void cloudflare login` forces a fresh browser sign-in, while automatic authentication reuses valid sessions. Adopted OAuth sessions persist the keyring preference so later compatible CLI logins update the same encrypted store. Environment API credentials block explicit browser login rather than masking it. Status reports identity/auth method and the pinned account separately; login never rewrites the project target. Keep credentials out of all diagnostics. +- **Direct Cloudflare remote bindings**: The Cloudflare Vite plugin and Wrangler's platform proxy authenticate remote bindings inside the Vite process, and read Void's cf login only when `CLOUDFLARE_CF_AUTH` is present (with `CLOUDFLARE_AUTH_USE_KEYRING=true` for its encrypted store), so `selectVoidCloudflareLogin()` sets both at plugin construction for direct Cloudflare apps. Re-check that flag name when updating `@cloudflare/vite-plugin` or `wrangler`. - **Minimum platform version**: `MIN_PLATFORM_VERSION` in `src/cli/client.ts` is the oldest Void platform release the CLI supports. `PlatformClient` checks public discovery once per process and base URL and keeps every outcome. A release version below the minimum, or a 404 from discovery (a platform that predates it), stops the command. `"hosted"` (a hosted deploy without `PLATFORM_VERSION`) and other non-release strings (custom or development builds) pass, as do unusable answers (network errors, a disabled platform, Access login pages, invalid documents), which leave the real request to report its own error. Hosted deploys set `PLATFORM_VERSION` from `packages/platform/package.json` (`platform/scripts/platform-version.ts`). Only login opts out. Operator, installer, and management commands do not use `PlatformClient`, so they keep working on older runtimes. Do not add CLI fallbacks for platforms older than the minimum. When a server change ships in a release, raise the minimum to that release and delete the fallbacks marked for it in the same change. - **Authentication commands**: `void auth login|status|logout` follows the saved `.void/project.json` deployment platform, or prompts when unset; headless calls need `--platform` or an explicit command. `void account` always targets a Void platform account, and `void cloudflare` always targets Cloudflare. Use `void account whoami|link|token` for Void account operations. Auth selection never changes the deployment preference. diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index de839a470..65873fbc6 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -287,7 +287,10 @@ const HELP_TREE: HelpTree = { command: 'rollback [id]', description: 'Restore a compatible earlier platform runtime', }, - { command: 'repair [id]', description: 'Reconcile an installed Void platform' }, + { + command: 'repair [id]', + description: 'Reconcile an installed Void platform or its unfinished login setup', + }, { command: 'disable [id]', description: 'Disable traffic without deleting data' }, { command: 'enable [id]', description: 'Restore traffic for a disabled platform' }, { command: 'uninstall [id]', description: 'Safely remove an installed Void platform' }, @@ -556,7 +559,10 @@ const HELP_TREE: HelpTree = { { command: '--yes', description: 'Acknowledge non-interactive Cloudflare changes' }, ], }, - repair: lifecycleHelp('repair', 'Reconcile drift using the installation ownership manifest.'), + repair: lifecycleHelp( + 'repair', + 'Reconcile drift using the installation ownership manifest, or change the login settings while first administrator setup is pending.', + ), disable: lifecycleHelp('disable', 'Disable platform traffic without deleting data.'), enable: lifecycleHelp('enable', 'Restore configured traffic for a disabled platform.'), uninstall: { @@ -1389,7 +1395,7 @@ function lifecycleHelp( return { summary, usage: [ - `void platform ${command} [id] [--plan]${command === 'disable' ? '' : ' [--runtime ]'}${command === 'upgrade' || command === 'repair' ? ' [--dashboard-url ]' : ''} [--yes]`, + `void platform ${command} [id] [--plan]${command === 'disable' ? '' : ' [--runtime ]'}${command === 'upgrade' || command === 'repair' ? ' [--dashboard-url ]' : ''}${command === 'repair' ? ' [--auth-config ]' : ''} [--yes]`, ], options: [ { command: '--plan', description: 'Print the exact plan without making changes' }, @@ -1410,6 +1416,15 @@ function lifecycleHelp( }, ] : []), + ...(command === 'repair' + ? [ + { + command: '--auth-config ', + description: + 'While first administrator setup is pending, replace its login settings (install file format)', + }, + ] + : []), { command: '--yes', description: 'Acknowledge non-interactive Cloudflare changes' }, ], }; diff --git a/packages/void/src/cli/platform-access-provision.ts b/packages/void/src/cli/platform-access-provision.ts index bfdea53b7..a8e5478dd 100644 --- a/packages/void/src/cli/platform-access-provision.ts +++ b/packages/void/src/cli/platform-access-provision.ts @@ -1,5 +1,7 @@ import { createHash } from 'node:crypto'; import { + isPlatformAccessCompanyIdentityProvider, + isPlatformAccessIdentityRule, parsePlatformAccessProtection, parsePlatformLoginConfiguration, type PlatformAccessProtection, @@ -131,44 +133,46 @@ export class PlatformAccessClient { } } -/** - * Project Zero Trust keeps its own, broader copy of this list in - * platform/packages/api/src/zero-trust/cloudflare.ts; review both when adding - * a rule type. - */ -function identityRule(rule: ObjectValue): boolean { - if (rule.email_domain) { - const domain = object(rule.email_domain, 'email domain').domain; - return typeof domain === 'string' && /^(?:[a-z0-9-]+\.)+[a-z]{2,63}$/i.test(domain); - } - if (rule.email) { - const email = object(rule.email, 'email rule').email; - return typeof email === 'string' && /^[^\s*@]+@[^\s*@]+\.[^\s*@]+$/.test(email); - } - return [ - 'email', - 'email_domain', - 'email_list', - 'gsuite', - 'github-organization', - 'saml', - 'okta', - 'group', - ].some( - (key) => - Object.hasOwn(rule, key) && - !!rule[key] && - typeof rule[key] === 'object' && - !Array.isArray(rule[key]) && - Object.keys(rule[key] as object).length > 0, - ); -} +/** The selected providers or policies cannot protect Void; other errors are not about the selection. */ +export class PlatformAccessPolicyRejectedError extends Error {} /** Preserve corporate MFA/device requirements; reject broad policies rather than rewriting them. */ export async function verifyCompanyAccessPolicies( client: PlatformAccessClient, policies: ReadonlyArray, + identityProviderIds: ReadonlyArray, + identityProviders?: ReadonlyArray, ): Promise { + const companyProviders = new Set( + (identityProviders ?? (await client.list('identity_providers'))) + .filter(isPlatformAccessCompanyIdentityProvider) + .map((provider) => provider.id), + ); + // The applications offer only the selected providers at sign-in, so a login + // method, Entra ID group or authentication context rule for any other + // provider matches nobody. + const selectedCompanyProviders = new Set( + identityProviderIds.filter((id) => companyProviders.has(id)), + ); + const identityRule = ( + entry: unknown, + name: string, + providers: ReadonlySet = selectedCompanyProviders, + ) => { + const rule = object(entry, name); + const entra = rule.azureAD || rule.auth_context; + if (entra) { + const id = typeof entra === 'object' ? (entra as ObjectValue).identity_provider_id : null; + return typeof id === 'string' && providers.has(id); + } + return isPlatformAccessIdentityRule(rule, (id) => providers.has(id)); + }; + const restricted = (target: ObjectValue, providers?: ReadonlySet) => + Array.isArray(target.include) && + target.include.length > 0 && + ((Array.isArray(target.require) && + target.require.some((entry) => identityRule(entry, 'policy rule', providers))) || + target.include.every((entry) => identityRule(entry, 'policy rule', providers))); const groups = new Set(); const checkedGroups = new Set(); const checkRules = async (value: unknown, depth = 0): Promise => { @@ -176,41 +180,51 @@ export async function verifyCompanyAccessPolicies( return; } if (!Array.isArray(value) || value.length > 100 || depth > 8) { - throw new Error('platform: Unsupported Access policy rule structure.'); + throw new PlatformAccessPolicyRejectedError( + 'platform: Unsupported Access policy rule structure.', + ); } for (const entry of value) { const rule = object(entry, 'policy rule'); if (Object.hasOwn(rule, 'everyone') || Object.hasOwn(rule, 'any_valid_service_token')) { - throw new Error( + throw new PlatformAccessPolicyRejectedError( 'platform: Company Access policies must not allow Everyone or any valid service token.', ); } if (rule.group) { const id = text(object(rule.group, 'Access group').id, 'Access group ID'); if (groups.has(id)) { - throw new Error('platform: Access groups contain a recursive membership rule.'); + throw new PlatformAccessPolicyRejectedError( + 'platform: Access groups contain a recursive membership rule.', + ); } if (checkedGroups.has(id)) { continue; } if (checkedGroups.size >= 100) { - throw new Error('platform: Access group policy exceeds the inspection limit.'); + throw new PlatformAccessPolicyRejectedError( + 'platform: Access group policy exceeds the inspection limit.', + ); } groups.add(id); const group = object(await client.get(`groups/${encodeURIComponent(id)}`), 'Access group'); await checkRules(group.include, depth + 1); await checkRules(group.require, depth + 1); if (!Array.isArray(group.include) || !group.include.length) { - throw new Error('platform: Access groups must contain explicit identity rules.'); + throw new PlatformAccessPolicyRejectedError( + 'platform: Access groups must contain explicit identity rules.', + ); } if ( !( Array.isArray(group.require) && - group.require.some((entry) => identityRule(object(entry, 'group rule'))) + group.require.some((entry) => identityRule(entry, 'group rule')) ) && - !group.include.every((entry) => identityRule(object(entry, 'group rule'))) + !group.include.every((entry) => identityRule(entry, 'group rule')) ) { - throw new Error('platform: Access groups must restrict company identities.'); + throw new PlatformAccessPolicyRejectedError( + 'platform: Access groups must restrict company identities.', + ); } groups.delete(id); checkedGroups.add(id); @@ -220,10 +234,12 @@ export async function verifyCompanyAccessPolicies( let allows = 0; for (const policy of policies) { if (!['allow', 'deny', 'non_identity', 'bypass'].includes(String(policy.decision))) { - throw new Error('platform: Unsupported Access policy decision.'); + throw new PlatformAccessPolicyRejectedError('platform: Unsupported Access policy decision.'); } if (policy.decision === 'bypass') { - throw new Error('platform: A bypass policy cannot protect a Void platform.'); + throw new PlatformAccessPolicyRejectedError( + 'platform: A bypass policy cannot protect a Void platform.', + ); } if (policy.decision !== 'allow') { continue; @@ -231,20 +247,18 @@ export async function verifyCompanyAccessPolicies( allows++; await checkRules(policy.include); await checkRules(policy.require); - if ( - !Array.isArray(policy.include) || - !policy.include.length || - (!( - Array.isArray(policy.require) && - policy.require.some((entry) => identityRule(object(entry, 'policy rule'))) - ) && - !policy.include.every((entry) => identityRule(object(entry, 'policy rule')))) - ) { - throw new Error('platform: Select Access allow policies restricted to company identities.'); + if (!restricted(policy)) { + throw new PlatformAccessPolicyRejectedError( + restricted(policy, companyProviders) + ? 'platform: An Access allow policy uses an identity provider that is not one of the selected identity providers.' + : 'platform: Select Access allow policies restricted to company identities.', + ); } } if (!allows) { - throw new Error('platform: Select at least one company Access allow policy.'); + throw new PlatformAccessPolicyRejectedError( + 'platform: Select at least one company Access allow policy.', + ); } } @@ -291,6 +305,32 @@ export function platformAccessCreateRejected(error: unknown): boolean { ); } +/** Read reusable policies by ID and check that together they admit only company identities. */ +export async function verifySelectedAccessPolicies( + client: PlatformAccessClient, + policyIds: ReadonlyArray, + identityProviderIds: ReadonlyArray, + identityProviders?: ReadonlyArray, +): Promise> { + const policies = await Promise.all( + policyIds.map(async (id) => { + try { + return object(await client.get(`policies/${encodeURIComponent(id)}`), 'Access policy'); + } catch (error) { + if (error instanceof PlatformCloudflareApiError && error.status === 404) { + throw new PlatformAccessPolicyRejectedError( + `platform: The selected Access policy ${encodeURIComponent(id)} is unavailable in this account.`, + { cause: error }, + ); + } + throw error; + } + }), + ); + await verifyCompanyAccessPolicies(client, policies, identityProviderIds, identityProviders); + return policies; +} + export async function inspectPlatformAccess( client: PlatformAccessClient, input: Pick, @@ -317,7 +357,7 @@ export async function inspectPlatformAccess( new Set(input.identityProviderIds).size !== input.identityProviderIds.length || new Set(input.policyIds).size !== input.policyIds.length ) { - throw new Error( + throw new PlatformAccessPolicyRejectedError( 'platform: Select explicit identity providers and company policies for Access.', ); } @@ -326,16 +366,16 @@ export async function inspectPlatformAccess( (id) => !identityProviders.some((provider) => provider.id === id), ) ) { - throw new Error( + throw new PlatformAccessPolicyRejectedError( 'platform: A selected Access identity provider is unavailable in this account.', ); } - const policies = await Promise.all( - input.policyIds.map(async (id) => - object(await client.get(`policies/${encodeURIComponent(id)}`), 'Access policy'), - ), + const policies = await verifySelectedAccessPolicies( + client, + input.policyIds, + input.identityProviderIds, + providers, ); - await verifyCompanyAccessPolicies(client, policies); return { teamDomain, identityProviders, policies }; } @@ -676,7 +716,7 @@ export async function verifyPlatformAccessApplication( const currentPolicies = await client.list( `apps/${encodeURIComponent(text(app.id, 'application ID'))}/policies`, ); - await verifyCompanyAccessPolicies(client, currentPolicies); + await verifyCompanyAccessPolicies(client, currentPolicies, input.identityProviderIds); const allowIds = currentPolicies .filter((policy) => policy.decision === 'allow' || policy.decision === 'deny') .map((policy) => text(policy.id, 'policy ID')); diff --git a/packages/void/src/cli/platform-access-setup.ts b/packages/void/src/cli/platform-access-setup.ts index 9b06dba0e..76a597667 100644 --- a/packages/void/src/cli/platform-access-setup.ts +++ b/packages/void/src/cli/platform-access-setup.ts @@ -1,13 +1,15 @@ import { stripVTControlCharacters } from 'node:util'; -import type { PlatformInstallationManifest } from '@void/platform'; +import { isPlatformAccessEmailDomain, type PlatformInstallationManifest } from '@void/platform'; import { confirm, log, multiselect, password, select, text } from '@voidzero-dev/vite-plus-prompts'; import { cfAccessHeaders } from '../shared/cf-access.ts'; import { note, pc } from './output.ts'; import { PlatformAccessClient, + PlatformAccessPolicyRejectedError, platformAccessIdentityProvider, platformAccessNonce, provisionPlatformAccess, + verifySelectedAccessPolicies, type PlatformAccessCheckpoint, type PlatformAccessProvisionResult, } from './platform-access-provision.ts'; @@ -22,12 +24,29 @@ function accessSetupGuide(): string { }); } +/** Parse up to ten exact company email domains, or return undefined when any entry is invalid. */ +function companyEmailDomains(value: string): Array | undefined { + const domains = [ + ...new Set( + value + .split(/[\s,]+/) + .map((entry) => entry.trim().replace(/^@/, '').toLowerCase()) + .filter(Boolean), + ), + ]; + return domains.length && domains.length <= 10 && domains.every(isPlatformAccessEmailDomain) + ? domains + : undefined; +} + export async function selectCompanyAccessPolicies( client: PlatformAccessClient, initialPolicies: Array>, accountId: string, mode: PlatformAccessSetupSpec['mode'], + identityProviderIds: ReadonlyArray, ): Promise> { + const emailDomain = '__email_domain__'; const create = '__create__'; const refresh = '__refresh__'; let policies = initialPolicies; @@ -45,9 +64,19 @@ export async function selectCompanyAccessPolicies( const available = new Set(options.map((option) => option.value)); selectedIds = selectedIds.filter((id) => available.has(id)); const actions = [ + // Existing applications are managed elsewhere and need policies attached there. + ...(mode === 'create' + ? [ + { + value: emailDomain, + label: 'Allow my company’s email domain', + hint: 'Void creates this Allow policy for you', + }, + ] + : []), { value: create, - label: 'Create new', + label: 'Create in Cloudflare', hint: 'Set up a policy in Cloudflare, then return here', }, { value: refresh, label: 'Refresh list' }, @@ -73,7 +102,41 @@ export async function selectCompanyAccessPolicies( ); } selectedIds = choices.filter((id) => available.has(id)); - if (choices.includes(create)) { + if (choices.includes(emailDomain)) { + const domains = companyEmailDomains( + platformPromptAnswer( + await text({ + message: 'Company email domains (comma-separated):', + placeholder: 'example.com', + validate: (value) => + companyEmailDomains(value ?? '') + ? undefined + : 'Enter up to 10 email domains, such as example.com.', + }), + ), + )!; + const name = `Emails ending in @${domains[0]}${domains.length > 1 ? ` and ${domains.length - 1} more` : ''}`; + try { + // A lost response can lead to a second policy with the same name on retry. It + // admits the same people, so the installer does not track or remove it. + const created = (await client.create('policies', { + name, + decision: 'allow', + include: domains.map((domain) => ({ email_domain: { domain } })), + })) as { id?: unknown } | null; + if (typeof created?.id === 'string' && created.id) { + selectedIds.push(created.id); + } + log.info( + `Created the Access policy “${name}” in Cloudflare. It stays there for reuse; you can add MFA or device rules to it later.`, + ); + } catch (error) { + if (!(error instanceof PlatformCloudflareApiError)) { + throw error; + } + log.warn(`${error.message} Choose “Create in Cloudflare” instead.`); + } + } else if (choices.includes(create)) { note( [ '1. Open Zero Trust → Access controls → Policies → Add a policy in this account.', @@ -104,7 +167,20 @@ export async function selectCompanyAccessPolicies( continue; } } else if (!choices.includes(refresh)) { - return selectedIds; + try { + await verifySelectedAccessPolicies(client, selectedIds, identityProviderIds); + return selectedIds; + } catch (error) { + if ( + !(error instanceof PlatformAccessPolicyRejectedError) && + !(error instanceof PlatformCloudflareApiError && error.status === 404) + ) { + throw error; + } + log.warn( + `${error.message.replace(/^platform: /, '')} Choose other policies, or fix the policy in Cloudflare and refresh the list.`, + ); + } } policies = await client.list('policies'); } @@ -493,7 +569,13 @@ export async function preparePlatformAccessSetup(input: { const policyIds = spec.policyIds ?? (process.stdin.isTTY - ? await selectCompanyAccessPolicies(client, policies, accountId, spec.mode) + ? await selectCompanyAccessPolicies( + client, + policies, + accountId, + spec.mode, + identityProviderIds, + ) : []); if (!identityProviderIds.length || !policyIds.length) { throw new Error('platform: Access setup requires explicit identityProviderIds and policyIds.'); @@ -583,6 +665,7 @@ export async function preparePlatformAccessSetup(input: { ); input.save({ accessExistingLoginSecret: loginClientSecret }); } + let provisioning = previous; const result = await provisionPlatformAccess( client, { @@ -606,8 +689,30 @@ export async function preparePlatformAccessSetup(input: { serviceToken, }, previous, - (state) => input.save({ accessProvisioningJson: JSON.stringify(state) }), - ); + (state) => { + provisioning = state; + input.save({ accessProvisioningJson: JSON.stringify(state) }); + }, + ).catch((error: unknown): never => { + if (!(error instanceof PlatformAccessPolicyRejectedError)) { + throw error; + } + // A resume reuses saved picks, so a rejected pick would fail the same way every + // time. Forget it so the next interactive run asks again, unless a configuration + // file owns the pick or Access resources already depend on it. + if (!input.spec && process.stdin.isTTY && !Object.keys(provisioning.resources).length) { + const { identityProviderIds: _providers, policyIds: _policies, ...unpicked } = spec; + input.save({ accessSetupSpecJson: JSON.stringify(unpicked) }); + throw new PlatformAccessPolicyRejectedError( + `${error.message} Choose other policies when you run the command again, or fix the policy in Cloudflare.`, + { cause: error }, + ); + } + throw new PlatformAccessPolicyRejectedError( + `${error.message} Fix the selected policy in Cloudflare, then resume.`, + { cause: error }, + ); + }); if (result.credentials) { const accessCredentialsJson = JSON.stringify(result.credentials); input.save({ accessCredentialsJson }); diff --git a/packages/void/src/cli/platform-app-access.ts b/packages/void/src/cli/platform-app-access.ts index fc8ce378f..ec0e6ba15 100644 --- a/packages/void/src/cli/platform-app-access.ts +++ b/packages/void/src/cli/platform-app-access.ts @@ -3,6 +3,7 @@ import { stripVTControlCharacters } from 'node:util'; import { parsePlatformAppAccessSetup, PLATFORM_APP_ACCESS_SETUP_PROOF_PREFIX, + PLATFORM_APP_ACCESS_TURN_OFF_PROOF_PREFIX, type PlatformAppAccess, type PlatformAppAccessSetup, type PlatformDeploymentManifest, @@ -317,6 +318,7 @@ export async function prepareInstallationAppAccess(input: { await access.list('policies'), input.plan.account.id, 'create', + identityProviderIds, ); input.save({ appAccessPolicyIds: JSON.stringify(policyIds) }); } @@ -425,3 +427,84 @@ export async function initializeInstalledAppAccess( 'platform: App access setup is safely checkpointed. Resume installation to finish before app traffic opens.', ); } + +/** + * Turn off the project Zero Trust the installer enabled, for an uninstall + * before any platform user exists. Signed with the app access setup authority + * that turned it on. + */ +export async function turnOffInstalledProjectZeroTrust( + input: { + apiUrl: string; + installationId: string; + probeToken: string; + setupToken: string; + /** Deploys a runtime that has the turn-off route. */ + updateCommand: string; + }, + fetcher: typeof fetch = fetch, +): Promise { + const body = JSON.stringify({ version: 1 }); + const signature = createHmac('sha256', input.setupToken) + .update(`${PLATFORM_APP_ACCESS_TURN_OFF_PROOF_PREFIX}\n${input.installationId}\n${body}`) + .digest('base64url'); + const response = await fetcher(new URL('/internal/platform/app-access/turn-off', input.apiUrl), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Void-Platform-Probe': input.probeToken, + 'X-Void-App-Access-Setup': signature, + ...cfAccessHeaders(process.env, input.apiUrl), + }, + body, + redirect: 'manual', + signal: AbortSignal.timeout(60_000), + }); + let result: { + turnedOff?: boolean; + usersExist?: boolean; + installationId?: string; + error?: string; + code?: string; + } = {}; + try { + result = (await response.json()) as typeof result; + } catch { + // Older runtimes answer an unknown route without a JSON body. + } + // An older runtime does not know the route: it answers 404, its traffic gate + // answers 503 while the platform is disabled, and its Access check refuses + // the installer's service token on routes it does not list. + if ( + response.status === 404 || + (response.status === 503 && result.error === 'Platform disabled') || + (response.status === 403 && result.error === 'access_human_identity_required') + ) { + throw new Error( + `platform: This platform runtime cannot turn off project Zero Trust before an administrator exists. Run \`${input.updateCommand}\`, then retry the uninstall.`, + ); + } + if (result.code === 'users_exist') { + throw new Error( + 'platform: A platform user exists, so an administrator must turn off project Zero Trust. Run `void platform zero-trust disable`, then retry the uninstall.', + ); + } + if (result.code === 'app_access_credentials') { + throw new Error( + 'platform: Cloudflare rejected the saved app access token, so Void cannot turn off project Zero Trust.', + ); + } + if (!response.ok || result.installationId !== input.installationId) { + throw new Error( + `platform: ${result.error || `Turning off project Zero Trust failed (HTTP ${response.status}).`}`, + ); + } + if (!result.turnedOff) { + throw new Error('platform: Project Zero Trust is still turning off. Rerun the uninstall.'); + } + if (result.usersExist) { + throw new Error( + 'platform: A platform user signed in while project Zero Trust was turning off, so the uninstall stopped. Project Zero Trust is now off and the platform still runs. To keep the platform, an administrator can turn project Zero Trust back on in Admin → Zero Trust or with `void platform zero-trust configure`. To remove it, run the uninstall again.', + ); + } +} diff --git a/packages/void/src/cli/platform-args.ts b/packages/void/src/cli/platform-args.ts index fa940131f..cbf6c029d 100644 --- a/packages/void/src/cli/platform-args.ts +++ b/packages/void/src/cli/platform-args.ts @@ -46,7 +46,7 @@ export const platformSubcommands: Array = [ { name: 'repair', arg: '[id]', - description: 'Reconcile a platform or configure its dashboard origin', + description: 'Reconcile a platform, its dashboard origin, or unfinished login setup', }, { name: 'disable', arg: '[id]', description: 'Disable traffic without deleting data' }, { name: 'enable', arg: '[id]', description: 'Restore traffic for a disabled platform' }, @@ -96,6 +96,8 @@ export type PlatformArgs = } | { subcommand: 'upgrade' | 'repair' | 'disable' | 'enable'; + /** repair only: replaces the login settings while first administrator setup is pending. */ + authConfig?: string; dashboardUrl?: string; selector: string; plan: boolean; @@ -165,11 +167,13 @@ function parseLifecycleSelector( runtime: string; fromRuntime: string; yes: boolean; + authConfig?: string; dashboardUrl?: string; } { const positional: Array = []; let runtime = ''; let fromRuntime = ''; + let authConfig: string | undefined; let dashboardUrl: string | undefined; for (let index = 0; index < rest.length; index++) { const arg = rest[index]!; @@ -184,6 +188,10 @@ function parseLifecycleSelector( const [value, next] = readValue(rest, index, arg); dashboardUrl = normalizePlatformDashboardUrl(value); index = next; + } else if (arg === '--auth-config' && subcommand === 'repair') { + const [value, next] = readValue(rest, index, arg); + authConfig = value; + index = next; } else if (arg === '--from-runtime' && subcommand === 'rollback') { const [value, next] = readValue(rest, index, arg); fromRuntime = value; @@ -200,6 +208,7 @@ function parseLifecycleSelector( ...parsed, runtime, fromRuntime, + ...(authConfig !== undefined ? { authConfig } : {}), ...(dashboardUrl !== undefined ? { dashboardUrl } : {}), }; } @@ -311,10 +320,8 @@ export function parsePlatformArgs(args: Array): PlatformArgs { subcommand === 'disable' || subcommand === 'enable' ) { - const { selector, plan, runtime, fromRuntime, yes, dashboardUrl } = parseLifecycleSelector( - rest, - subcommand, - ); + const { selector, plan, runtime, fromRuntime, yes, authConfig, dashboardUrl } = + parseLifecycleSelector(rest, subcommand); return subcommand === 'rollback' ? { subcommand, selector, plan, runtime, fromRuntime, yes } : { @@ -323,6 +330,7 @@ export function parsePlatformArgs(args: Array): PlatformArgs { plan, runtime, yes, + ...(authConfig !== undefined ? { authConfig } : {}), ...(dashboardUrl !== undefined ? { dashboardUrl } : {}), }; } diff --git a/packages/void/src/cli/platform-install-auth.ts b/packages/void/src/cli/platform-install-auth.ts index d3df44db7..bc3658a38 100644 --- a/packages/void/src/cli/platform-install-auth.ts +++ b/packages/void/src/cli/platform-install-auth.ts @@ -8,6 +8,7 @@ import { parsePlatformLoginConfiguration, platformDashboardUrl, type PlatformAppAccess, + type PlatformAuthenticationSetup, type PlatformDeploymentManifest, type PlatformLoginKind, } from '@void/platform'; @@ -44,6 +45,28 @@ function defaultConnectionId(kind: PlatformLoginKind): string { return kind === 'oidc' ? 'company' : kind === 'cloudflare-access' ? 'access' : kind; } +async function prompt(message: string, initialValue = '', masked = false): Promise { + if (!process.stdin.isTTY) { + throw new Error( + `platform: ${message} Supply it through --auth-config and environment references.`, + ); + } + return masked + ? platformPromptAnswer(await password({ message })) + : platformPromptAnswer(await text({ message, initialValue })); +} + +function clientSecretFromEnvironment(name: string): string { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + throw new Error('platform: Invalid clientSecretEnv reference.'); + } + const secret = process.env[name]; + if (!secret) { + throw new Error(`platform: ${name} must contain the login client secret.`); + } + return secret; +} + export type PlatformAuthSelection = { authentication: PlatformDeploymentManifest['authentication']; file?: AuthFile; @@ -200,16 +223,6 @@ export async function collectInstallationAuthentication( authBootstrapCode: checkpoint.authBootstrapCode ?? '', }; } - const prompt = async (message: string, initialValue = '', masked = false): Promise => { - if (!process.stdin.isTTY) { - throw new Error( - `platform: ${message} Supply it through --auth-config and environment references.`, - ); - } - return masked - ? platformPromptAnswer(await password({ message })) - : platformPromptAnswer(await text({ message, initialValue })); - }; const entries = selection.file?.connections ?? selection.authentication.providers.map((kind) => ({ @@ -301,13 +314,7 @@ export async function collectInstallationAuthentication( const parsed = parsePlatformLoginConfiguration(config); let secret: string | undefined; if (entry.clientSecretEnv) { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(entry.clientSecretEnv)) { - throw new Error('platform: Invalid clientSecretEnv reference.'); - } - secret = process.env[entry.clientSecretEnv]; - if (!secret) { - throw new Error(`platform: ${entry.clientSecretEnv} must contain the login client secret.`); - } + secret = clientSecretFromEnvironment(entry.clientSecretEnv); } else if ( stored?.clientSecret && stored.configuration?.clientId === parsed.clientId && @@ -427,3 +434,147 @@ export async function collectInstallationAuthentication( save(result); return result; } + +/** The hash the platform records for accepted login settings; a reset request does not change it. */ +export function authenticationSetupRequestHash(setupJson: string): string { + const { resetPending: _, ...configuration } = parsePlatformAuthenticationSetup( + JSON.parse(setupJson), + ); + return createHash('sha256').update(JSON.stringify(configuration)).digest('hex'); +} + +/** + * Read a repair login file. It may change login settings, not methods or Access + * protection: it lists the same connection IDs and kinds as the saved setup. + */ +export async function readLoginReplacementFile( + file: string, + authentication: PlatformDeploymentManifest['authentication'], + setupJson: string, +): Promise { + const selection = await selectInstallationAuthentication(file); + const installed = parsePlatformAuthenticationSetup(JSON.parse(setupJson)).connections; + const connections = ( + entries: ReadonlyArray<{ configuration: { id?: unknown; kind?: unknown } }>, + ) => + entries + .map(({ configuration: { id, kind } }) => + JSON.stringify([id || defaultConnectionId(kind as PlatformLoginKind), kind]), + ) + .sort() + .join('\n'); + if ( + connections(selection.file!.connections) !== connections(installed) || + !!selection.authentication.accessProtection !== !!authentication.accessProtection + ) { + throw new Error( + `platform: Repair changes login settings, not which login methods or Access protection the platform uses. List each installed connection with its ID: ${installed.map(({ configuration }) => configuration.id).join(', ')}.`, + ); + } + return selection.file!; +} + +/** + * New login settings for a first administrator setup that is still pending, + * from a file or from prompts prefilled with the saved settings. A Cloudflare + * Access login and Access protection stay as installed. The result resets + * pending setup with a new setup code; the setup authority does not change. + */ +export async function replacePendingInstallationAuthentication(input: { + file?: string; + authentication: PlatformDeploymentManifest['authentication']; + setupJson: string; + apiUrl: string; +}): Promise<{ authenticationSetupJson: string; authBootstrapCode: string }> { + const previous = parsePlatformAuthenticationSetup(JSON.parse(input.setupJson)); + const file = input.file + ? await readLoginReplacementFile(input.file, input.authentication, input.setupJson) + : undefined; + console.log(`Login callback URL: ${formatUrl(`${input.apiUrl}/auth/callback`)}`); + const savedConnection = (id: unknown) => + previous.connections.find((connection) => connection.configuration.id === id); + const connections: Array = []; + for (const entry of file?.connections ?? []) { + const config = { ...entry.configuration }; + const kind = config.kind as PlatformLoginKind; + if (!config.id) { + config.id = defaultConnectionId(kind); + } + if (kind === 'cloudflare-access') { + const saved = savedConnection(config.id); + if (saved?.configuration.kind !== 'cloudflare-access') { + throw new Error( + 'platform: Repair keeps the installed Cloudflare Access login. Use its installed connection ID.', + ); + } + connections.push(saved); + continue; + } + if (!config.label) { + config.label = kind; + } + if (kind === 'oidc' && !config.issuer) { + config.issuer = await prompt(`${config.label}: issuer URL:`); + } + if (!config.clientId) { + config.clientId = await prompt(`${config.label}: client ID:`); + } + const configuration = parsePlatformLoginConfiguration(config); + connections.push({ + configuration, + clientSecret: entry.clientSecretEnv + ? clientSecretFromEnvironment(entry.clientSecretEnv) + : await prompt(`${configuration.label}: client secret:`, '', true), + }); + } + for (const saved of file ? [] : previous.connections) { + const current = saved.configuration; + if (current.kind === 'cloudflare-access') { + connections.push(saved); + continue; + } + const issuer = + current.kind === 'oidc' + ? await prompt(`${current.label}: issuer URL:`, current.issuer ?? '') + : current.issuer; + const clientId = await prompt(`${current.label}: client ID:`, current.clientId); + const allowedDomains = + current.kind === 'google' + ? ( + await prompt( + 'Allowed Google Workspace domains (comma-separated, or blank):', + current.allowedDomains.join(', '), + ) + ) + .split(',') + .map((value) => value.trim()) + .filter(Boolean) + : current.allowedDomains; + connections.push({ + configuration: parsePlatformLoginConfiguration({ + ...current, + ...(issuer ? { issuer } : {}), + clientId, + allowedDomains, + }), + clientSecret: + (await prompt(`${current.label}: client secret (leave blank to keep it):`, '', true)) || + saved.clientSecret, + }); + } + const ids = connections.map((connection) => connection.configuration.id); + const authBootstrapCode = randomBytes(32).toString('hex'); + const setup = parsePlatformAuthenticationSetup({ + connections, + administratorConnectionId: + file?.administratorConnectionId ?? + (ids.includes(previous.administratorConnectionId) + ? previous.administratorConnectionId + : ids[0]), + admission: file?.admission ?? previous.admission, + protection: previous.protection, + resetPending: true, + bootstrapCredentialHash: createHash('sha256').update(authBootstrapCode).digest('hex'), + }); + return { authenticationSetupJson: JSON.stringify(setup), authBootstrapCode }; +} diff --git a/packages/void/src/cli/platform-installer-database.ts b/packages/void/src/cli/platform-installer-database.ts index a1a6393c4..6add1ac53 100644 --- a/packages/void/src/cli/platform-installer-database.ts +++ b/packages/void/src/cli/platform-installer-database.ts @@ -16,6 +16,7 @@ import { join } from 'pathslash'; import type { Pool } from 'pg'; import { verifyPlatformDatabaseArtifacts } from './platform-installer-runtime.ts'; import { + type PlatformAdministratorSetupState, type PlatformInstallerDependencies, type PlatformDatabaseLifecycleLock, type InstallationCheckpointWriter, @@ -301,6 +302,36 @@ export function readPlatformProjectSlugs( ); } +/** Read the first administrator setup row and whether any user exists. */ +export async function readPlatformAdministratorSetupConnection( + client: QueryConnection, +): Promise { + const tables = await client.query<{ setup: boolean; users: boolean }>( + `SELECT to_regclass('public.platform_auth_bootstrap') IS NOT NULL AS "setup", + to_regclass('public.users') IS NOT NULL AS "users"`, + ); + const present = tables.rows[0]; + const setup = present?.setup + ? ( + await client.query<{ request_hash: string; consumed_at: unknown }>( + 'SELECT "request_hash", "consumed_at" FROM "platform_auth_bootstrap" WHERE "singleton" = true', + ) + ).rows[0] + : undefined; + const usersExist = present?.users + ? ( + await client.query<{ present: boolean }>( + 'SELECT EXISTS(SELECT 1 FROM "users") AS "present"', + ) + ).rows[0]?.present === true + : false; + return { + pending: !!setup && setup.consumed_at === null, + usersExist, + requestHash: setup?.request_hash ?? null, + }; +} + /** Slugs of every project row, including projects whose deletion stopped partway. */ export async function readPlatformProjectSlugsConnection( client: QueryConnection, @@ -593,6 +624,7 @@ export async function acquirePlatformDatabaseLifecycleLockConnection( } : null; }; + release.readAdministratorSetup = () => readPlatformAdministratorSetupConnection(client); release.readProjectZeroTrust = () => readPlatformProjectZeroTrustStateConnection(client); release.readProjects = () => readPlatformProjectSlugsConnection(client); release.lockProjectZeroTrust = () => lockPlatformProjectZeroTrustConnection(client); @@ -965,7 +997,7 @@ async function claimPlatformDatabaseConnection( claim.ownership_nonce !== ownershipNonce ) { throw new Error( - `platform: This PostgreSQL database is already claimed by Void platform installation ${claim.installation_id}. Use a dedicated empty database for each installation.`, + `platform: This PostgreSQL database is already claimed by Void platform installation ${claim.installation_id}. Uninstall keeps the database and its data, so use a dedicated empty database for each installation.`, ); } await client.query('COMMIT'); diff --git a/packages/void/src/cli/platform-installer-errors.ts b/packages/void/src/cli/platform-installer-errors.ts index 8b8a52e57..c0d7e67f0 100644 --- a/packages/void/src/cli/platform-installer-errors.ts +++ b/packages/void/src/cli/platform-installer-errors.ts @@ -4,6 +4,12 @@ import { type CloudflareCommandResult, } from '@void/deploy-cloudflare'; +// Cloudflare refuses a Worker with an Analytics Engine binding (code 10089) +// until the account has used Analytics Engine once. No API turns it on, and +// the dashboard page may offer only "Create dataset"; deploying a binding from +// the dashboard once enables it (cloudflare/workers-sdk#9312). +const ANALYTICS_ENGINE_NOT_ENABLED = 10089; + export function assertCleanPlatformWrangler( result: CloudflareCommandResult, operation: string, @@ -13,8 +19,15 @@ export function assertCleanPlatformWrangler( ...result, stderr: result.stderr.trim() || result.errors?.map((error) => error.message).join('\n') || '', }); + const analyticsEngineDisabled = + result.errorCode === ANALYTICS_ENGINE_NOT_ENABLED || + result.errors?.some((error) => error.code === ANALYTICS_ENGINE_NOT_ENABLED) || + /\b10089\b/.test(diagnostic ?? ''); throw new Error( - `platform: Wrangler failed to ${operation} (${diagnostic || `exit ${result.status}`}).`, + `platform: Wrangler failed to ${operation} (${diagnostic || `exit ${result.status}`}).` + + (analyticsEngineDisabled + ? ' If the Analytics Engine page offers no way to enable it, follow https://void.cloud/guide/platform/installation/prerequisites#if-cloudflare-rejects-analytics-engine, then retry.' + : ''), ); } } diff --git a/packages/void/src/cli/platform-installer-secrets.ts b/packages/void/src/cli/platform-installer-secrets.ts index 2635944ce..3aa118067 100644 --- a/packages/void/src/cli/platform-installer-secrets.ts +++ b/packages/void/src/cli/platform-installer-secrets.ts @@ -32,6 +32,8 @@ export function createPlatformInstallationSecrets(input: { githubClientId: string; githubClientSecret: string; existingInstallation?: boolean; + /** Create missing identity keys for a fresh interactive install that keeps them in recovery data. */ + generateIdentityKeys?: boolean; /** The caller supplies current explicit rotation inputs, not resumed inputs. */ updateExistingWorkers?: boolean; jwtSecret?: string; @@ -54,6 +56,7 @@ export function createPlatformInstallationSecrets(input: { // explicit complete-keyring pair is the only supported way to supersede a // stale encryption checkpoint, and existing Workers still inherit live keys. const existingInstallation = input.existingInstallation === true; + const generateIdentityKeys = input.generateIdentityKeys === true && !existingInstallation; const restoredIdentity = (explicit: string, name: string): string => existingInstallation ? checkpoint[name] || explicit || '' : explicit || checkpoint[name] || ''; const configured = (explicit: string, name: string): string => explicit || checkpoint[name] || ''; @@ -85,7 +88,9 @@ export function createPlatformInstallationSecrets(input: { ); } } - const jwtSecret = restoredIdentity(input.jwtSecret || '', 'jwtSecret'); + const jwtSecret = + restoredIdentity(input.jwtSecret || '', 'jwtSecret') || + (generateIdentityKeys ? randomBytes(32).toString('base64') : ''); const resolvedJwtSecret = input.existingInstallation ? jwtSecret : required(jwtSecret, 'VOID_PLATFORM_JWT_SECRET'); @@ -125,6 +130,7 @@ export function createPlatformInstallationSecrets(input: { const projectSecretKeyring = resolveProjectSecretKeyring({ checkpoint, existingInstallation, + generateIdentityKeys, projectSecretActiveKeyVersion: input.projectSecretActiveKeyVersion || '', projectSecretKey: input.projectSecretKey || '', projectSecretKeysJson: input.projectSecretKeysJson || '', @@ -228,6 +234,7 @@ export function createPlatformInstallationSecrets(input: { function resolveProjectSecretKeyring(input: { checkpoint: Record; existingInstallation: boolean; + generateIdentityKeys: boolean; projectSecretActiveKeyVersion: string; projectSecretKey: string; projectSecretKeysJson: string; @@ -281,7 +288,9 @@ function resolveProjectSecretKeyring(input: { const v1Key = input.existingInstallation ? input.checkpoint.projectSecretKey || input.projectSecretKey || '' : input.required( - input.projectSecretKey || input.checkpoint.projectSecretKey || '', + input.projectSecretKey || + input.checkpoint.projectSecretKey || + (input.generateIdentityKeys ? randomBytes(32).toString('base64') : ''), 'VOID_PLATFORM_PROJECT_SECRET_KEY', ); if (!v1Key) { diff --git a/packages/void/src/cli/platform-installer-types.ts b/packages/void/src/cli/platform-installer-types.ts index 6335121dc..ad3fdcfdd 100644 --- a/packages/void/src/cli/platform-installer-types.ts +++ b/packages/void/src/cli/platform-installer-types.ts @@ -95,6 +95,14 @@ export type PlatformWorkerSecretUpdateIntent = Readonly<{ updates: Readonly; }>; +export type PlatformAdministratorSetupState = Readonly<{ + /** A setup row exists that no administrator has used yet. */ + pending: boolean; + usersExist: boolean; + /** Hash of the login settings the platform last accepted, or null before setup started. */ + requestHash: string | null; +}>; + export type PlatformDatabaseLifecycleLock = (() => Promise) & { readAuthenticationState?: () => Promise<{ enabledProviders: ReadonlyArray; @@ -113,6 +121,8 @@ export type PlatformDatabaseLifecycleLock = (() => Promise) & { prepareSandboxDrain?: ( requiredAdmissionProtocol?: 0 | 2 | 3, ) => Promise; + /** First administrator setup state; repair replaces its login settings only while pending. */ + readAdministratorSetup?: () => Promise; /** Project Zero Trust state that decides whether uninstall may start. */ readProjectZeroTrust?: () => Promise; /** Project slugs, which --purge-resources requires to be empty. */ diff --git a/packages/void/src/cli/platform-installer.ts b/packages/void/src/cli/platform-installer.ts index 0df7e582b..315b464bb 100644 --- a/packages/void/src/cli/platform-installer.ts +++ b/packages/void/src/cli/platform-installer.ts @@ -829,7 +829,9 @@ export async function installPlatform( ); if (authentication.setupPending) { setupPending = true; - dependencies.onProgress?.(`Administrator setup pending: ${state.plan.apiUrl}/setup`); + dependencies.onProgress?.( + 'Administrator setup prepared; its link and code are shown when this finishes', + ); } state.manifest = recordInstallationStep( state.manifest, @@ -1037,6 +1039,7 @@ export { recordResource } from './platform-installer-resources.ts'; export { platformDatabaseClientUrl, acquirePlatformDatabaseLifecycleLock, + claimPlatformDatabase, acquirePlatformDatabaseLifecycleLockConnection, readPlatformDatabaseLifecycleManifest, readPlatformProjectSlugs, diff --git a/packages/void/src/cli/platform-lifecycle.ts b/packages/void/src/cli/platform-lifecycle.ts index 4d506a20a..67f0c09d5 100644 --- a/packages/void/src/cli/platform-lifecycle.ts +++ b/packages/void/src/cli/platform-lifecycle.ts @@ -42,6 +42,7 @@ import { } from './platform-install-auth.ts'; import { acquirePlatformDatabaseLifecycleLock, + claimPlatformDatabase, createPlatformInstallationSecrets, installPlatform, loadPlatformRuntime, @@ -84,15 +85,14 @@ import { platformInstallCommandArguments, platformSetupPages, platformSetupFieldHelp, + platformSetupLink, } from './platform-setup.ts'; -export function resolvePlatformInstallDatabaseUrl( +function isPlatformInstallDatabaseBound( checkpoint: Record | null, - manifest: PlatformInstallationManifest | null = null, -): string { - const explicit = process.env.VOID_PLATFORM_DATABASE_URL || process.env.DATABASE_URL || ''; - const recorded = checkpoint?.databaseUrl || ''; - const databaseBound = + manifest: PlatformInstallationManifest | null, +): boolean { + return Boolean( checkpoint?.databaseClaimState === 'claimed' || manifest?.resources.some( (resource) => @@ -102,8 +102,17 @@ export function resolvePlatformInstallDatabaseUrl( manifest?.steps.some( (step) => step.key.startsWith('database-schema:') || step.key.startsWith('database-migrations:'), - ); - if (databaseBound) { + ), + ); +} + +export function resolvePlatformInstallDatabaseUrl( + checkpoint: Record | null, + manifest: PlatformInstallationManifest | null = null, +): string { + const explicit = process.env.VOID_PLATFORM_DATABASE_URL || process.env.DATABASE_URL || ''; + const recorded = checkpoint?.databaseUrl || ''; + if (isPlatformInstallDatabaseBound(checkpoint, manifest)) { if (!recorded) { if (explicit) { // Discovery intentionally recovers no credentials. The database's @@ -456,6 +465,8 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr active.start(message); return active; }; + // Set while a step uses an unclaimed database URL, which a resume may still replace. + let usingUnboundDatabase = false; try { if (!existing && !args.plan) { assertPlatformInstallationSlugAvailable(installationSlug); @@ -482,11 +493,13 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr ); } progress = startProgress('Reading the saved installation'); + usingUnboundDatabase = !isPlatformInstallDatabaseBound(checkpoint, existing); const authoritative = await readPlatformDatabaseLifecycleManifest( databaseUrl, existing.id, existing.ownershipNonce, ); + usingUnboundDatabase = false; progress?.stop('Read the saved installation.'); progress = undefined; if (authoritative) { @@ -1101,6 +1114,19 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr process.env.VOID_PLATFORM_PROJECT_SECRET_KEYS_JSON || process.env.VOID_PLATFORM_PROJECT_SECRET_ACTIVE_KEY_VERSION, ); + // Interactive fresh installs keep the identity keys in this machine's encrypted + // recovery data, like the email signing key. Headless runners may lose that data, + // so they still supply both keys. + const generateIdentityKeys = credentialSetup && !!process.stdin.isTTY; + const generatedKeys = generateIdentityKeys + ? [ + configured('jwtSecret', 'VOID_PLATFORM_JWT_SECRET') ? '' : 'JWT signing key', + configured('projectSecretKey', 'VOID_PLATFORM_PROJECT_SECRET_KEY') || + hasFullProjectSecretKeyring + ? '' + : 'project encryption key', + ].filter(Boolean) + : []; const secrets = createPlatformInstallationSecrets({ configurableAuthentication: !!authenticationSelection.authentication.configurable, ...authenticationSecrets, @@ -1117,22 +1143,25 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr r2AccessKeyId, r2SecretAccessKey, existingInstallation: !credentialSetup, - jwtSecret: await collect( - 'jwtSecret', - 'VOID_PLATFORM_JWT_SECRET', - 'JWT signing key:', - true, - true, - credentialSetup - ? (value) => - Buffer.byteLength(value, 'utf8') >= 32 - ? undefined - : 'The JWT signing secret must contain at least 32 random bytes.' - : undefined, - ), + generateIdentityKeys, + jwtSecret: generateIdentityKeys + ? configured('jwtSecret', 'VOID_PLATFORM_JWT_SECRET') + : await collect( + 'jwtSecret', + 'VOID_PLATFORM_JWT_SECRET', + 'JWT signing key:', + true, + true, + credentialSetup + ? (value) => + Buffer.byteLength(value, 'utf8') >= 32 + ? undefined + : 'The JWT signing secret must contain at least 32 random bytes.' + : undefined, + ), projectSecretKey: configured('projectSecretKey', 'VOID_PLATFORM_PROJECT_SECRET_KEY') || - (hasFullProjectSecretKeyring + (hasFullProjectSecretKeyring || generateIdentityKeys ? '' : await collect( 'projectSecretKey', @@ -1153,17 +1182,32 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr projectSecretActiveKeyVersion: process.env.VOID_PLATFORM_PROJECT_SECRET_ACTIVE_KEY_VERSION || '', }); + if (generatedKeys.length) { + log.info( + `Void generated the ${generatedKeys.join(' and ')} and keeps ${generatedKeys.length > 1 ? 'them' : 'it'} in this machine’s encrypted recovery data. ` + + platformSetupLink({ + title: 'About these keys', + url: 'https://void.cloud/guide/platform/installation/credentials#signing-and-encryption-keys', + }), + ); + } progress = startProgress('Installing the platform'); if (credentialSetup) { // Stop treating this as an untouched local draft before any database claim is attempted. saveValues({ + jwtSecret: secrets.jwtSecret, projectSecretKey: secrets.projectSecretKey, ...(secrets.emailEnabled ? { emailSigningSecret: secrets.emailSigningSecret } : {}), }); existing = completePlatformSetupDraft(existing); } + usingUnboundDatabase = !isPlatformInstallDatabaseBound(checkpoint, existing); const result = await installPlatform(plan, secrets, existing, { acquireDatabaseLifecycleLock: acquirePlatformDatabaseLifecycleLock, + claimDatabase: async (...claim) => { + await claimPlatformDatabase(...claim); + usingUnboundDatabase = false; + }, client, operatorApiToken: managementApiToken || operatorCredential.token, runtime, @@ -1271,9 +1315,13 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr if (result.setupPending && completedAuthCode) { log.info(`First administrator setup: ${formatUrl(`${plan.apiUrl}/setup`)}`); console.log(`One-time administrator setup code: ${completedAuthCode}`); + log.info(`Login callback URL: ${formatUrl(`${plan.apiUrl}/auth/callback`)}`); log.info( 'Confirm your identity in the browser, then test and enable any additional login methods in Settings.', ); + log.info( + `If sign-in fails, run \`void platform repair ${result.manifest.id}\` to change the login settings.`, + ); } if (!plan.deployment.application.zoneName) { log.info( @@ -1291,6 +1339,11 @@ export async function runPlatformLifecycleCommand(commandArgs: PlatformArgs): Pr log.info( `Continue with: ${cliCommandName()} platform install --resume --name ${existing.id}`, ); + if (usingUnboundDatabase) { + log.info( + 'To use another PostgreSQL database, set VOID_PLATFORM_DATABASE_URL to its URL first.', + ); + } } throw error; } finally { diff --git a/packages/void/src/cli/platform-management.ts b/packages/void/src/cli/platform-management.ts index 03d3c8804..b98479fed 100644 --- a/packages/void/src/cli/platform-management.ts +++ b/packages/void/src/cli/platform-management.ts @@ -13,7 +13,10 @@ import { type PlatformInstallationManifest, } from '@void/platform'; import { confirm, isCancel, log, outro, select } from '@voidzero-dev/vite-plus-prompts'; +import { ensurePlatformAccessToken } from './cf-access.ts'; import { printSuccess, createSpinner, formatUrl, note, pc } from './output.ts'; +import { activatePlatformAccessCredentials } from './platform-access-setup.ts'; +import { turnOffInstalledProjectZeroTrust } from './platform-app-access.ts'; import type { PlatformArgs } from './platform-args.ts'; import { platformDatabaseNameFromOwnerUrl } from './platform-auth-database.ts'; import { @@ -28,8 +31,14 @@ import { PlatformCloudflareClient, verifyPlatformRuntimeTokenPermissions, } from './platform-cloudflare.ts'; +import { + authenticationSetupRequestHash, + readLoginReplacementFile, + replacePendingInstallationAuthentication, +} from './platform-install-auth.ts'; import { acquirePlatformDatabaseLifecycleLock, + createPlatformHealthProbeToken, createPlatformInstallationSecrets, installPlatform, loadPlatformRuntime, @@ -59,10 +68,12 @@ import { acquirePlatformLifecycleLock, listInstallationManifests, readInstallationRecoverySecrets, + readPendingInstallationSecrets, removeInstallationRecoverySecrets, removePlatformConnectionForInstallation, resolveInstallationManifest, writeInstallationManifest, + writePendingInstallationSecrets, } from './platform-registry.ts'; import { assertNoPlatformProjects, @@ -84,6 +95,7 @@ import { } from './platform-teardown.ts'; import { assertProjectZeroTrustAllowsUninstall, + formatZeroTrustApplications, type PlatformProjectZeroTrustState, } from './platform-zero-trust-uninstall.ts'; @@ -182,6 +194,11 @@ type PlatformManagementDependencies = Readonly<{ fenceProjectZeroTrust?: () => Promise; /** Removes that fence when uninstall stops before it disables traffic. */ liftProjectZeroTrustFence?: () => Promise; + /** + * Turns off project Zero Trust through the platform API while no platform + * user exists. Present only with the installer's saved app access authority. + */ + turnOffProjectZeroTrust?: () => Promise; /** Project slugs; --purge-resources refuses to start while any remain or when this cannot read them. */ readProjects?: () => Promise>; /** Where the operator credential came from; --purge-resources checks it before any change. */ @@ -307,6 +324,8 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise readPlatformProjectSlugs(databaseUrl, manifest.id, manifest.ownershipNonce), credential: { source: operatorCredential.source, aiGatewayToken: !!gatewayToken }, + ...(apiUrl && appAccessSetupToken + ? { + turnOffProjectZeroTrust: async () => { + const saved: unknown = recovery?.accessCredentialsJson + ? JSON.parse(recovery.accessCredentialsJson) + : {}; + if (saved && typeof saved === 'object' && apiUrl in saved) { + activatePlatformAccessCredentials( + JSON.stringify({ [apiUrl]: (saved as Record)[apiUrl] }), + [apiUrl], + ); + } + if (manifest.deployment.authentication.accessProtection) { + ensurePlatformAccessToken(apiUrl, false, true); + } + await turnOffInstalledProjectZeroTrust({ + apiUrl, + installationId: manifest.id, + probeToken: createPlatformHealthProbeToken(manifest.ownershipNonce), + setupToken: appAccessSetupToken, + updateCommand: readPendingInstallationSecrets(manifest) + ? `void platform install --resume --name ${commandArgument(manifest.id)}` + : `void platform upgrade ${commandArgument(manifest.id)}`, + }); + }, + } + : {}), }); return; } @@ -334,6 +380,31 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise => { + const after = replacedLogin + ? await databaseLock?.readAdministratorSetup?.().catch(() => undefined) + : undefined; + if ( + !after || + !recovery?.authenticationSetupJson || + after.requestHash !== authenticationSetupRequestHash(recovery.authenticationSetupJson) || + (!after.usersExist && after.pending) + ) { + return error; + } + const pending = readPendingInstallationSecrets(installation); + if (pending) { + writePendingInstallationSecrets(installation, { + ...pending, + authenticationSetupJson: recovery.authenticationSetupJson, + ...(recovery.authBootstrapCode ? { authBootstrapCode: recovery.authBootstrapCode } : {}), + }); + } + return new Error( + 'platform: The first administrator signed in before the new login settings were applied. Nothing changed. Change login methods with `void platform config auth`.', + { cause: error }, + ); + }; const result = await installPlatform(plan, secrets, installation, { // runInstalledPlatformCommand holds the database session lock across the // entire replan and mutation sequence. @@ -527,6 +676,8 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise { + throw await keepAcceptedLogin(error); }); // Keep progress visible while the outer database session releases its lock. progress.message('Finalizing platform changes'); @@ -574,6 +725,10 @@ export async function runInstalledPlatformCommand(args: PlatformArgs): Promise; /** Projects whose deletion stopped before it removed their applications. */ unfinishedDeletions: ReadonlyArray; + /** Whether any platform user exists; read only while Zero Trust is active. */ + usersExist?: boolean; }>; type ProjectApplicationRow = Readonly<{ @@ -171,9 +173,10 @@ export function projectZeroTrustUninstallState( export async function readPlatformProjectZeroTrustStateConnection( client: QueryConnection, ): Promise { - const tables = await client.query<{ settings: boolean; projects: boolean }>( + const tables = await client.query<{ settings: boolean; projects: boolean; users?: boolean }>( `SELECT to_regclass('public.platform_settings') IS NOT NULL AS "settings", - to_regclass('public.project_zero_trust') IS NOT NULL AS "projects"`, + to_regclass('public.project_zero_trust') IS NOT NULL AS "projects", + to_regclass('public.users') IS NOT NULL AS "users"`, ); const present = tables.rows[0]; if (!present) { @@ -203,7 +206,18 @@ export async function readPlatformProjectZeroTrustStateConnection( ) ).rows : []; - return projectZeroTrustUninstallState(setting, projectRows); + const state = projectZeroTrustUninstallState(setting, projectRows); + if (!state.active) { + return state; + } + const usersExist = present.users + ? ( + await client.query<{ present: boolean }>( + 'SELECT EXISTS(SELECT 1 FROM "users") AS "present"', + ) + ).rows[0]?.present === true + : false; + return { ...state, usersExist }; } /** @@ -265,7 +279,9 @@ export async function liftPlatformProjectZeroTrustFenceConnection( await client.query('DELETE FROM "platform_settings" WHERE "key" = $1', [UNINSTALL_SETTING]); } -function formatApplications(applications: ReadonlyArray): string { +export function formatZeroTrustApplications( + applications: ReadonlyArray, +): string { const lines = applications .slice(0, LISTED_APPLICATIONS) .map( @@ -295,10 +311,13 @@ function unfinishedDeletionLines(state: PlatformProjectZeroTrustState): Readonly /** * Refuse to uninstall while project Zero Trust still owns Access applications, * or while its state cannot be read. Runs before uninstall changes anything. + * With `canTurnOff`, enabled Zero Trust on a platform no user has signed in to + * is not refused: the applications uninstall must turn off are returned. */ export async function assertProjectZeroTrustAllowsUninstall( read: (() => Promise) | undefined, -): Promise { + options: Readonly<{ canTurnOff?: boolean }> = {}, +): Promise<{ turnOff: ReadonlyArray } | undefined> { const state = await readPlatformDatabase(read, (reason) => [ `platform: Refusing to uninstall because the project Zero Trust state could not be read from the platform database: ${reason}`, @@ -306,6 +325,9 @@ export async function assertProjectZeroTrustAllowsUninstall( 'Make the installation database reachable (export VOID_PLATFORM_DATABASE_URL if this machine has not recorded it), then retry. If project Zero Trust was ever enabled, run `void platform zero-trust disable` first.', ].join('\n'), ); + if (state.active && state.usersExist === false && options.canTurnOff) { + return { turnOff: state.applications }; + } if (state.active) { const covered = state.destinations.length ? ` on ${state.destinations.join(' and ')}` @@ -314,10 +336,13 @@ export async function assertProjectZeroTrustAllowsUninstall( [ 'platform: Refusing to uninstall while project Zero Trust is enabled or still changing.', `Uninstall turns off the platform API, so Void could no longer remove its Cloudflare Access applications, and Access would keep asking for sign-in${covered}, including hostnames Void does not serve.`, - 'Run `void platform zero-trust disable`, rerun it until `void platform zero-trust status` reports Zero Trust disabled with no operation running, then retry the uninstall.', + // Without a user there is no administrator to run zero-trust disable. + state.usersExist === false + ? 'No platform user exists yet. Run this command on the computer that installed the platform; it keeps the authority to turn off project Zero Trust.' + : 'Run `void platform zero-trust disable`, rerun it until `void platform zero-trust status` reports Zero Trust disabled with no operation running, then retry the uninstall.', 'If the platform API does not answer, bring it back with `void platform enable` or `void platform repair` first.', ...(state.applications.length - ? ['Void-managed Access applications:', formatApplications(state.applications)] + ? ['Void-managed Access applications:', formatZeroTrustApplications(state.applications)] : []), ...unfinishedDeletionLines(state), DASHBOARD_HINT, @@ -330,7 +355,7 @@ export async function assertProjectZeroTrustAllowsUninstall( 'platform: Refusing to uninstall while Void-managed Access applications from project Zero Trust remain.', 'Zero Trust is disabled, but it could not remove the applications of some projects. Retry the uninstall once they are gone.', 'Void-managed Access applications:', - formatApplications(state.applications), + formatZeroTrustApplications(state.applications), ...(state.applications.some((application) => !application.deleting) ? [ `Hourly maintenance retries ${state.unfinishedDeletions.length ? 'the other applications' : 'them'} while the platform is enabled, and clears Void's records of any you delete in the dashboard.`, @@ -341,4 +366,5 @@ export async function assertProjectZeroTrustAllowsUninstall( ].join('\n'), ); } + return undefined; } diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index bc38fdf96..2771bf915 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -2678,7 +2678,7 @@ async function reconcileD1( if (!token) { throw new Error( 'provision: D1 read replication requires a Cloudflare API bearer with D1 Edit permission. ' + - 'Log in with `wrangler login` or set CLOUDFLARE_API_TOKEN, then retry.', + 'Run `void cloudflare login`, or set CLOUDFLARE_API_TOKEN, then retry.', ); } await enableD1ReadReplicationViaApi(context, token); diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index f2d1b7d01..431a3eec2 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -41,6 +41,7 @@ import { isViteBuildOrPreview, getOrCreateDevTriggerToken, devTriggerSecretGuardPlugin, + selectVoidCloudflareLogin, } from './plugin-dev-config.ts'; import { devTriggersHintPlugin } from './plugin-dev-triggers-hint.ts'; import { drizzlePlugin } from './plugin-drizzle.ts'; @@ -92,6 +93,11 @@ export function voidPlugin(options?: { // Resolve root relative to CWD — this runs at config time const root = process.cwd(); const directCloudflareDestination = readDeploymentPlatform(root) === 'cloudflare'; + if (directCloudflareDestination) { + // Here, not in a hook: `astro preview` runs no plugin hooks. Every command + // needs it, because build prerendering can open a remote session too. + selectVoidCloudflareLogin(); + } warnIfPackageJsonIsNotEsm(root); // Token only matters in serve — the dispatch module that uses it is // DCE'd from production worker bundles. In build/preview we still need @@ -410,13 +416,6 @@ export function voidPlugin(options?: { application = getApplicationAnalysis(routeEnvironment); bindings = application.bindings; authEnabled = application.authEnabled; - // `void cloudflare login` stores OAuth in Wrangler's OS-keychain-backed - // credential store. The Cloudflare Vite plugin runs in this process, so - // select that same store before it opens the remote Workers AI session. - // Explicit API tokens remain Wrangler's highest-priority credential. - if (command === 'serve' && directCloudflareDestination && bindings.needsAI) { - process.env.CLOUDFLARE_AUTH_USE_KEYRING = 'true'; - } // Re-resolve for the real environment. Framework and node-target branches return before // `ssrEntry` is declared (TDZ) and must leave `workerConfig.assets` unset. if (!framework && !isNodeTarget(config.target)) { diff --git a/packages/void/src/plugin-dev-config.ts b/packages/void/src/plugin-dev-config.ts index 2b98a75e9..08d65e0e2 100644 --- a/packages/void/src/plugin-dev-config.ts +++ b/packages/void/src/plugin-dev-config.ts @@ -35,6 +35,20 @@ export function isViteBuildOrPreview(): boolean { return subcommand === 'build' || subcommand === 'preview'; } +/** + * Point Cloudflare remote bindings opened in this process at the login from + * `void connect` and `void cloudflare login`. The Cloudflare Vite plugin and + * Wrangler's platform proxy authenticate remote bindings here, in dev, preview + * and build prerendering. They read cf's login only when `CLOUDFLARE_CF_AUTH` + * is present (any value), and Wrangler's separate login otherwise. The keyring + * flag selects the encrypted cf profile that Void's login writes. Environment + * API tokens still take precedence, and the OAuth token stays in the store. + */ +export function selectVoidCloudflareLogin(): void { + process.env.CLOUDFLARE_CF_AUTH = 'true'; + process.env.CLOUDFLARE_AUTH_USE_KEYRING = 'true'; +} + const DEV_TRIGGER_TOKEN_FILE_MODE = 0o600; /** diff --git a/packages/void/src/plugin-framework.ts b/packages/void/src/plugin-framework.ts index 774fc58b3..e7a98bc90 100644 --- a/packages/void/src/plugin-framework.ts +++ b/packages/void/src/plugin-framework.ts @@ -91,9 +91,6 @@ export function createFrameworkPlugins(input: { name: 'void:wrangler-compat', enforce: 'pre', config(_, env) { - if (env.command === 'serve' && directCloudflareDestination && bindings.needsAI) { - process.env.CLOUDFLARE_AUTH_USE_KEYRING = 'true'; - } syncFrameworkCompat(ensureWranglerCompat(root, config)); if (env.command !== 'serve') { return; diff --git a/packages/void/test/fixtures/platform-access.ts b/packages/void/test/fixtures/platform-access.ts index 935fb62b1..243c36afc 100644 --- a/packages/void/test/fixtures/platform-access.ts +++ b/packages/void/test/fixtures/platform-access.ts @@ -13,7 +13,9 @@ export function platformAccessFixture(): { } { const apps = new Map(); const tokens = new Map(); - const identityProviders: Array = [{ id: 'company-idp', name: 'Company SSO' }]; + const identityProviders: Array = [ + { id: 'company-idp', type: 'okta', name: 'Company SSO' }, + ]; const policy: RecordValue = { id: 'employees', name: 'Employees and managed devices', diff --git a/packages/void/test/unit/platform-access-provision.test.ts b/packages/void/test/unit/platform-access-provision.test.ts index f6b300080..e2a1ae940 100644 --- a/packages/void/test/unit/platform-access-provision.test.ts +++ b/packages/void/test/unit/platform-access-provision.test.ts @@ -158,6 +158,106 @@ describe('Cloudflare Access provisioning', () => { } }); + it.each([ + { azureAD: { id: 'company-group', identity_provider_id: 'company-idp' } }, + { login_method: { id: 'company-idp' } }, + { auth_context: { id: 'context', ac_id: 'c1', identity_provider_id: 'company-idp' } }, + { cloudflare_account_member: {} }, + { cloudflare_account_member: { account_id: '023e105f4ecef8ad9ca31a8372d0c353' } }, + { okta: { identity_provider_id: 'subsidiary-idp', name: 'Engineering' } }, + ])('accepts %j as a company identity rule', async (rule) => { + const env = fixture(); + env.policy.include = [rule]; + delete env.policy.require; + const result = await provisionPlatformAccess( + env.client, + { ...base, login: { id: 'access', label: 'Company Access' } }, + fresh(), + () => {}, + ); + expect(result.login?.configuration.kind).toBe('cloudflare-access'); + expect(env.apps.size).toBe(1); + }); + + it.each([ + { include: [{ login_method: { id: 'subsidiary-idp' } }] }, + { + include: [{ ip: { ip: '203.0.113.0/24' } }], + require: [{ login_method: { id: 'subsidiary-idp' } }], + }, + { include: [{ azureAD: { id: 'company-group', identity_provider_id: 'subsidiary-idp' } }] }, + { + include: [{ ip: { ip: '203.0.113.0/24' } }], + require: [ + { auth_context: { id: 'context', ac_id: 'c1', identity_provider_id: 'subsidiary-idp' } }, + ], + }, + ])('accepts a company provider rule only when it is a selected provider (%j)', async (rules) => { + const env = fixture(); + env.identityProviders.push({ id: 'subsidiary-idp', type: 'azureAD', name: 'Subsidiary SSO' }); + delete env.policy.require; + Object.assign(env.policy, rules); + const login = { id: 'access', label: 'Company Access' }; + await expect( + provisionPlatformAccess(env.client, { ...base, login }, fresh(), () => {}), + ).rejects.toThrow( + 'An Access allow policy uses an identity provider that is not one of the selected identity providers.', + ); + expect(env.calls.some((call) => call.method === 'POST')).toBe(false); + const result = await provisionPlatformAccess( + env.client, + { ...base, identityProviderIds: ['company-idp', 'subsidiary-idp'], login }, + fresh(), + () => {}, + ); + expect(result.login?.configuration.kind).toBe('cloudflare-access'); + expect([...env.apps.values()][0]?.allowed_idps).toEqual(['company-idp', 'subsidiary-idp']); + }); + + it.each([{ azureAD: { id: 'company-group' } }, { auth_context: { id: 'context', ac_id: 'c1' } }])( + 'rejects %j without its identity provider', + async (rule) => { + const env = fixture(); + env.policy.include = [rule]; + delete env.policy.require; + await expect( + provisionPlatformAccess( + env.client, + { ...base, login: { id: 'access', label: 'Company Access' } }, + fresh(), + () => {}, + ), + ).rejects.toThrow('Select Access allow policies restricted to company identities.'); + expect(env.calls.some((call) => call.method === 'POST')).toBe(false); + }, + ); + + it.each([ + { type: 'onetimepin' }, + { type: 'github' }, + { type: 'cloudflare', config: { restrict_to_account_members: false } }, + { type: 'oidc', config: { auth_url: 'https://accounts.google.com/o/oauth2/auth' } }, + { type: 'saml', config: { sso_target_url: 'https://idp.example.net/sso/saml' } }, + ])('rejects a policy that admits anyone who signs in with %j', async (provider) => { + const env = fixture(); + env.identityProviders.push({ id: 'public-login', name: 'Public login', ...provider }); + env.policy.include = [{ login_method: { id: 'public-login' } }]; + delete env.policy.require; + await expect( + provisionPlatformAccess( + env.client, + { + ...base, + identityProviderIds: ['company-idp', 'public-login'], + login: { id: 'access', label: 'Company Access' }, + }, + fresh(), + () => {}, + ), + ).rejects.toThrow('Select Access allow policies restricted to company identities.'); + expect(env.calls.some((call) => call.method === 'POST')).toBe(false); + }); + it('rejects more specific overlapping Access apps before creating even an OIDC app', async () => { const env = fixture(); env.apps.set('foreign', { diff --git a/packages/void/test/unit/platform-access-setup.test.ts b/packages/void/test/unit/platform-access-setup.test.ts index b2a0ffe75..6a36c6eab 100644 --- a/packages/void/test/unit/platform-access-setup.test.ts +++ b/packages/void/test/unit/platform-access-setup.test.ts @@ -4,6 +4,7 @@ import { } from '@void/platform'; import { CANCEL_SYMBOL } from '@voidzero-dev/vite-plus-prompts'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { platformAccessNonce } from '../../src/cli/platform-access-provision.ts'; import { activatePlatformAccessCredentials, checkpointPlatformAccessResources, @@ -11,19 +12,28 @@ import { platformAccessResourceInventory, preparePlatformAccessSetup, } from '../../src/cli/platform-access-setup.ts'; -import type { PlatformCloudflareClient } from '../../src/cli/platform-cloudflare.ts'; +import { + PlatformCloudflareApiError, + type PlatformCloudflareClient, +} from '../../src/cli/platform-cloudflare.ts'; import { platformAccessFixture } from '../fixtures/platform-access.ts'; const prompts = vi.hoisted(() => ({ select: vi.fn(), multiselect: vi.fn(), confirm: vi.fn(), + text: vi.fn(), note: vi.fn(), info: vi.fn(), + warn: vi.fn(), })); vi.mock('@voidzero-dev/vite-plus-prompts', async (original) => { const actual = await original(); - return { ...actual, ...prompts, log: { ...actual.log, info: prompts.info } }; + return { + ...actual, + ...prompts, + log: { ...actual.log, info: prompts.info, warn: prompts.warn }, + }; }); const plan = { @@ -192,6 +202,11 @@ describe('interactive Access policy creation', () => { let policies = initialPolicies; const request = vi.fn(async (path: string, init?: RequestInit): Promise => { const target = path.split('?')[0]!; + if (target === '/accounts/account/access/policies' && init?.method === 'POST') { + const created = { ...JSON.parse(String(init.body)), id: 'company-domain' }; + policies = [...policies, created]; + return structuredClone(created) as T; + } if (target === '/accounts/account/access/policies') { return structuredClone(policies) as T; } @@ -235,7 +250,7 @@ describe('interactive Access policy creation', () => { const fixture = setup(); prompts.select.mockImplementation( async ({ options }) => - options.find((option: { label: string }) => option.label === 'Create new').value, + options.find((option: { label: string }) => option.label === 'Create in Cloudflare').value, ); prompts.confirm.mockImplementation(async () => { expect(fixture.env.apps.size).toBe(0); @@ -250,7 +265,8 @@ describe('interactive Access policy creation', () => { expect(prompts.select).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ options: [ - expect.objectContaining({ label: 'Create new' }), + expect.objectContaining({ label: 'Allow my company’s email domain' }), + expect.objectContaining({ label: 'Create in Cloudflare' }), expect.objectContaining({ label: 'Refresh list' }), ], }), @@ -296,7 +312,8 @@ describe('interactive Access policy creation', () => { expect(options).toContainEqual(expect.objectContaining({ value: 'employees' })); return [ 'employees', - options.find((option: { label: string }) => option.label === 'Create new').value, + options.find((option: { label: string }) => option.label === 'Create in Cloudflare') + .value, ]; } expect(initialValues).toEqual(['employees']); @@ -326,14 +343,15 @@ describe('interactive Access policy creation', () => { let menus = 0; prompts.select.mockImplementation(async ({ options }) => { expect(options.map((option: { label: string }) => option.label)).toEqual([ - 'Create new', + 'Allow my company’s email domain', + 'Create in Cloudflare', 'Refresh list', ]); if (menus++ === 0) { - return options[0].value; + return options[1].value; } fixture.setPolicies([fixture.env.policy]); - return options[1].value; + return options[2].value; }); prompts.confirm.mockResolvedValue(true); await preparePlatformAccessSetup(fixture.input); @@ -361,7 +379,8 @@ describe('interactive Access policy creation', () => { if (policyPrompts++ === 0) { return [ 'employees', - options.find((option: { label: string }) => option.label === 'Create new').value, + options.find((option: { label: string }) => option.label === 'Create in Cloudflare') + .value, ]; } expect(initialValues).toEqual(['employees']); @@ -381,7 +400,10 @@ describe('interactive Access policy creation', () => { async (stage) => { const fixture = setup(); prompts.select.mockImplementation(async ({ options }) => - stage === 'picker' ? CANCEL_SYMBOL : options[0].value, + stage === 'picker' + ? CANCEL_SYMBOL + : options.find((option: { label: string }) => option.label === 'Create in Cloudflare') + .value, ); prompts.confirm.mockResolvedValue(CANCEL_SYMBOL); await expect(preparePlatformAccessSetup(fixture.input)).rejects.toThrow( @@ -414,7 +436,13 @@ describe('interactive Access policy creation', () => { it('explains how to attach the new policy when connecting existing Access applications', async () => { const fixture = setup(); - prompts.select.mockImplementation(async ({ options }) => options[0].value); + prompts.select.mockImplementation(async ({ options }) => { + expect(options.map((option: { label: string }) => option.label)).toEqual([ + 'Create in Cloudflare', + 'Refresh list', + ]); + return options[0].value; + }); prompts.confirm .mockImplementationOnce(async () => { fixture.setPolicies([fixture.env.policy]); @@ -437,4 +465,292 @@ describe('interactive Access policy creation', () => { fixture.request.mock.calls.every(([, init]) => !init?.method || init.method === 'GET'), ).toBe(true); }); + + const broad = { id: 'broad', name: 'Everyone', decision: 'allow', include: [{ everyone: {} }] }; + const policyPrompts = () => + prompts.multiselect.mock.calls.filter(([input]) => + input.message.startsWith('Company Access policies'), + ); + const action = + (label: string) => + async ({ options }: { options: Array<{ label: string; value: string }> }) => + options.find((option) => option.label === label)!.value; + + it('creates an Allow policy for the company email domains and preselects it', async () => { + const fixture = setup(); + prompts.select.mockImplementation(action('Allow my company’s email domain')); + prompts.text.mockResolvedValue('Example.com, @corp.example.com example.com'); + prompts.multiselect.mockImplementation(async ({ message, initialValues }) => { + if (message === 'Company identity providers:') { + return ['company-idp']; + } + expect(initialValues).toEqual(['company-domain']); + return ['company-domain']; + }); + await preparePlatformAccessSetup(fixture.input); + const posts = fixture.request.mock.calls.filter(([, init]) => init?.method === 'POST'); + expect(posts.map(([path]) => path)).toEqual([ + '/accounts/account/access/policies', + '/accounts/account/access/apps', + ]); + expect(JSON.parse(String(posts[0]![1]!.body))).toEqual({ + name: 'Emails ending in @example.com and 1 more', + decision: 'allow', + include: [ + { email_domain: { domain: 'example.com' } }, + { email_domain: { domain: 'corp.example.com' } }, + ], + }); + expect(prompts.info).toHaveBeenCalledWith( + expect.stringContaining( + 'Created the Access policy “Emails ending in @example.com and 1 more” in Cloudflare.', + ), + ); + expect(JSON.parse(fixture.checkpoint.accessSetupSpecJson!).policyIds).toEqual([ + 'company-domain', + ]); + expect([...fixture.env.apps.values()][0]?.policies).toEqual([ + { id: 'company-domain', precedence: 1 }, + ]); + }); + + it.each([400, 403])( + 'returns to the picker when Cloudflare refuses to create the policy (HTTP %i)', + async (status) => { + const fixture = setup(); + const respond = fixture.request.getMockImplementation()!; + fixture.request.mockImplementation(async (path, init) => { + if (init?.method === 'POST' && path === '/accounts/account/access/policies') { + throw new PlatformCloudflareApiError('Cloudflare API error: refused.', status); + } + return respond(path, init); + }); + prompts.select + .mockImplementationOnce(action('Allow my company’s email domain')) + .mockImplementationOnce(action('Create in Cloudflare')); + prompts.text.mockResolvedValue('example.com'); + prompts.confirm.mockImplementation(async () => { + fixture.setPolicies([fixture.env.policy]); + return true; + }); + await preparePlatformAccessSetup(fixture.input); + expect(prompts.warn).toHaveBeenCalledWith( + 'Cloudflare API error: refused. Choose “Create in Cloudflare” instead.', + ); + expect(prompts.select).toHaveBeenCalledTimes(2); + expect(JSON.parse(fixture.checkpoint.accessSetupSpecJson!).policyIds).toEqual(['employees']); + }, + ); + + it.each(['creating', 'checking'])( + 'stops on a network failure while %s a policy instead of asking again', + async (stage) => { + const fixture = setup(stage === 'checking' ? [broad] : []); + const respond = fixture.request.getMockImplementation()!; + fixture.request.mockImplementation(async (path, init) => { + if ( + stage === 'creating' + ? init?.method === 'POST' && path === '/accounts/account/access/policies' + : path === '/accounts/account/access/policies/broad' + ) { + throw new TypeError('fetch failed'); + } + return respond(path, init); + }); + prompts.select.mockImplementation(action('Allow my company’s email domain')); + prompts.text.mockResolvedValue('example.com'); + prompts.multiselect.mockImplementation(async ({ message }) => + message === 'Company identity providers:' ? ['company-idp'] : ['broad'], + ); + await expect(preparePlatformAccessSetup(fixture.input)).rejects.toThrow('fetch failed'); + expect(prompts.warn).not.toHaveBeenCalled(); + expect(prompts.select.mock.calls.length + policyPrompts().length).toBe(1); + }, + ); + + it('asks again when a picked policy does not restrict company identities', async () => { + const fixture = setup(); + fixture.setPolicies([fixture.env.policy, broad]); + let picks = 0; + prompts.multiselect.mockImplementation(async ({ message, initialValues }) => { + if (message === 'Company identity providers:') { + return ['company-idp']; + } + if (picks++ === 0) { + return ['broad']; + } + expect(initialValues).toEqual(['broad']); + return ['employees']; + }); + await preparePlatformAccessSetup(fixture.input); + expect(prompts.warn).toHaveBeenCalledExactlyOnceWith( + 'Company Access policies must not allow Everyone or any valid service token. Choose other policies, or fix the policy in Cloudflare and refresh the list.', + ); + expect(policyPrompts()).toHaveLength(2); + expect(JSON.parse(fixture.checkpoint.accessSetupSpecJson!).policyIds).toEqual(['employees']); + expect( + fixture.request.mock.calls + .filter(([, init]) => init?.method === 'POST') + .map(([path]) => path), + ).toEqual(['/accounts/account/access/apps']); + }); + + it('asks again when a picked policy admits anyone who receives a one-time PIN', async () => { + const fixture = setup(); + fixture.env.identityProviders.push({ id: 'pin', type: 'onetimepin', name: 'One-time PIN' }); + fixture.setPolicies([ + fixture.env.policy, + { + id: 'pin-only', + name: 'PIN', + decision: 'allow', + include: [{ login_method: { id: 'pin' } }], + }, + ]); + let picks = 0; + prompts.multiselect.mockImplementation(async ({ message }) => { + if (message === 'Company identity providers:') { + return ['company-idp', 'pin']; + } + return picks++ === 0 ? ['pin-only'] : ['employees']; + }); + await preparePlatformAccessSetup(fixture.input); + expect(prompts.warn).toHaveBeenCalledExactlyOnceWith( + 'Select Access allow policies restricted to company identities. Choose other policies, or fix the policy in Cloudflare and refresh the list.', + ); + expect(JSON.parse(fixture.checkpoint.accessSetupSpecJson!).policyIds).toEqual(['employees']); + }); + + it('asks again when a picked policy uses a login method that was not selected', async () => { + const fixture = setup(); + fixture.env.identityProviders.push({ id: 'subsidiary-idp', type: 'okta', name: 'Subsidiary' }); + fixture.setPolicies([ + fixture.env.policy, + { + id: 'subsidiary', + name: 'Subsidiary SSO', + decision: 'allow', + include: [{ login_method: { id: 'subsidiary-idp' } }], + }, + ]); + let picks = 0; + prompts.multiselect.mockImplementation(async ({ message }) => { + if (message === 'Company identity providers:') { + return ['company-idp']; + } + return picks++ === 0 ? ['subsidiary'] : ['employees']; + }); + await preparePlatformAccessSetup(fixture.input); + expect(prompts.warn).toHaveBeenCalledExactlyOnceWith( + 'An Access allow policy uses an identity provider that is not one of the selected identity providers. Choose other policies, or fix the policy in Cloudflare and refresh the list.', + ); + expect(JSON.parse(fixture.checkpoint.accessSetupSpecJson!).policyIds).toEqual(['employees']); + }); + + it('forgets a rejected saved pick so the next interactive run asks again', async () => { + const fixture = setup(); + fixture.setPolicies([fixture.env.policy, broad]); + fixture.checkpoint.accessSetupSpecJson = JSON.stringify({ + mode: 'create', + identityProviderIds: ['company-idp'], + policyIds: ['broad'], + }); + const resumed = { ...fixture.input, spec: undefined }; + await expect(preparePlatformAccessSetup(resumed)).rejects.toThrow( + 'must not allow Everyone or any valid service token. Choose other policies when you run the command again, or fix the policy in Cloudflare.', + ); + expect(prompts.multiselect).not.toHaveBeenCalled(); + expect(JSON.parse(fixture.checkpoint.accessSetupSpecJson)).toEqual({ mode: 'create' }); + await preparePlatformAccessSetup(resumed); + expect(policyPrompts()).toHaveLength(1); + expect(JSON.parse(fixture.checkpoint.accessSetupSpecJson)).toEqual({ + mode: 'create', + identityProviderIds: ['company-idp'], + policyIds: ['employees'], + }); + expect(fixture.env.apps.size).toBe(1); + }); + + it('forgets a saved policy that was deleted in Cloudflare', async () => { + const fixture = setup(); + fixture.setPolicies([fixture.env.policy]); + const spec = { + mode: 'create', + identityProviderIds: ['company-idp'], + policyIds: ['old-policy'], + }; + fixture.checkpoint.accessSetupSpecJson = JSON.stringify(spec); + const respond = fixture.request.getMockImplementation()!; + let status = 503; + fixture.request.mockImplementation(async (path, init) => { + if (path === '/accounts/account/access/policies/old-policy') { + throw new PlatformCloudflareApiError('Cloudflare API error.', status, path); + } + return respond(path, init); + }); + const resumed = { ...fixture.input, spec: undefined }; + await expect(preparePlatformAccessSetup(resumed)).rejects.toThrow(PlatformCloudflareApiError); + expect(JSON.parse(fixture.checkpoint.accessSetupSpecJson)).toEqual(spec); + status = 404; + await expect(preparePlatformAccessSetup(resumed)).rejects.toThrow( + 'The selected Access policy old-policy is unavailable in this account. Choose other policies when you run the command again', + ); + expect(JSON.parse(fixture.checkpoint.accessSetupSpecJson)).toEqual({ mode: 'create' }); + await preparePlatformAccessSetup(resumed); + expect(policyPrompts()).toHaveLength(1); + expect(JSON.parse(fixture.checkpoint.accessSetupSpecJson).policyIds).toEqual(['employees']); + }); + + it('keeps a rejected saved pick when Access resources already depend on it', async () => { + const fixture = setup(); + fixture.setPolicies([fixture.env.policy, broad]); + const spec = { mode: 'create', identityProviderIds: ['company-idp'], policyIds: ['broad'] }; + fixture.checkpoint.accessSetupSpecJson = JSON.stringify(spec); + fixture.checkpoint.accessProvisioningJson = JSON.stringify({ + version: 1, + accountId: 'account', + installationId: plan.installationId, + nonce: platformAccessNonce('owned-installation'), + resources: { + 'login-access': { name: 'login', fingerprint: 'previous', id: 'app-1', pending: false }, + }, + }); + await expect(preparePlatformAccessSetup({ ...fixture.input, spec: undefined })).rejects.toThrow( + 'Fix the selected policy in Cloudflare, then resume.', + ); + expect(JSON.parse(fixture.checkpoint.accessSetupSpecJson)).toEqual(spec); + expect(prompts.multiselect).not.toHaveBeenCalled(); + }); + + it('keeps a rejected pick that the Access setup file owns', async () => { + const fixture = setup(); + fixture.setPolicies([fixture.env.policy, broad]); + const spec = { + mode: 'create' as const, + identityProviderIds: ['company-idp'], + policyIds: ['broad'], + }; + await expect(preparePlatformAccessSetup({ ...fixture.input, spec })).rejects.toThrow( + 'Fix the selected policy in Cloudflare, then resume.', + ); + expect(JSON.parse(fixture.checkpoint.accessSetupSpecJson!)).toEqual(spec); + }); + + it('keeps the saved pick when provisioning fails for another reason', async () => { + const fixture = setup(); + fixture.setPolicies([fixture.env.policy]); + const spec = { mode: 'create', identityProviderIds: ['company-idp'], policyIds: ['employees'] }; + fixture.checkpoint.accessSetupSpecJson = JSON.stringify(spec); + const respond = fixture.request.getMockImplementation()!; + fixture.request.mockImplementation(async (path, init) => { + if (init?.method === 'POST' && path === '/accounts/account/access/apps') { + throw new TypeError('fetch failed'); + } + return respond(path, init); + }); + const failure = preparePlatformAccessSetup({ ...fixture.input, spec: undefined }); + await expect(failure).rejects.toThrow(TypeError); + await expect(failure).rejects.toThrow(/^fetch failed$/); + expect(JSON.parse(fixture.checkpoint.accessSetupSpecJson)).toEqual(spec); + }); }); diff --git a/packages/void/test/unit/platform-app-access.test.ts b/packages/void/test/unit/platform-app-access.test.ts index f11476534..962f3ae73 100644 --- a/packages/void/test/unit/platform-app-access.test.ts +++ b/packages/void/test/unit/platform-app-access.test.ts @@ -1,7 +1,9 @@ +import { createHmac } from 'node:crypto'; import { createPlatformDeploymentManifest, parsePlatformAppAccessSetup, parsePlatformDeploymentManifest, + PLATFORM_APP_ACCESS_TURN_OFF_PROOF_PREFIX, } from '@void/platform'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import { @@ -9,6 +11,7 @@ import { prepareInstallationAppAccess, selectInstallationAppAccess, PlatformAppAccessCredentialError, + turnOffInstalledProjectZeroTrust, } from '../../src/cli/platform-app-access.ts'; import { parsePlatformArgs } from '../../src/cli/platform-args.ts'; import { PlatformCloudflareClient } from '../../src/cli/platform-cloudflare.ts'; @@ -298,6 +301,66 @@ describe('guided installation app access', () => { ), ).rejects.toBeInstanceOf(PlatformAppAccessCredentialError); }); + it('turns off project Zero Trust with a proof only the turn-off route accepts', async () => { + const input = { + apiUrl: 'https://platform.example.test', + installationId: 'team', + probeToken: 'proof', + setupToken: 'one-time-setup-authority'.repeat(3), + updateCommand: 'void platform upgrade team', + }; + const fetcher = vi.fn(async (_url: URL | RequestInfo, _init?: RequestInit) => + Response.json({ installationId: 'team', turnedOff: true }), + ); + await turnOffInstalledProjectZeroTrust(input, fetcher); + const [url, init] = fetcher.mock.calls[0]!; + expect(String(url)).toBe('https://platform.example.test/internal/platform/app-access/turn-off'); + expect(init).toMatchObject({ + method: 'POST', + redirect: 'manual', + body: '{"version":1}', + headers: { + 'X-Void-Platform-Probe': 'proof', + 'X-Void-App-Access-Setup': createHmac('sha256', input.setupToken) + .update(`${PLATFORM_APP_ACCESS_TURN_OFF_PROOF_PREFIX}\nteam\n{"version":1}`) + .digest('base64url'), + }, + }); + const answer = + (body: unknown, status = 200) => + async () => + body === null ? new Response('404 Not Found', { status }) : Response.json(body, { status }); + // Runtimes from before the turn-off route answer in one of these ways. + for (const old of [ + answer(null, 404), + answer({ error: 'Platform disabled' }, 503), + answer({ error: 'access_human_identity_required' }, 403), + ]) { + await expect(turnOffInstalledProjectZeroTrust(input, old)).rejects.toThrow( + 'Run `void platform upgrade team`, then retry the uninstall.', + ); + } + await expect( + turnOffInstalledProjectZeroTrust(input, answer({ code: 'users_exist' }, 409)), + ).rejects.toThrow('Run `void platform zero-trust disable`, then retry the uninstall.'); + await expect( + turnOffInstalledProjectZeroTrust(input, answer({ code: 'app_access_credentials' }, 403)), + ).rejects.toThrow('Cloudflare rejected the saved app access token'); + await expect( + turnOffInstalledProjectZeroTrust(input, answer({ installationId: 'team', turnedOff: false })), + ).rejects.toThrow('still turning off'); + await expect( + turnOffInstalledProjectZeroTrust(input, answer({ installationId: 'other', turnedOff: true })), + ).rejects.toThrow('Turning off project Zero Trust failed'); + await expect( + turnOffInstalledProjectZeroTrust( + input, + answer({ installationId: 'team', turnedOff: true, usersExist: true }), + ), + ).rejects.toThrow( + 'A platform user signed in while project Zero Trust was turning off, so the uninstall stopped.', + ); + }); it('rejects tokens in unmanaged payloads and contradictory public protection', () => { const value = { version: 1, diff --git a/packages/void/test/unit/platform-args.test.ts b/packages/void/test/unit/platform-args.test.ts index da40dbabf..b83ed448b 100644 --- a/packages/void/test/unit/platform-args.test.ts +++ b/packages/void/test/unit/platform-args.test.ts @@ -52,6 +52,23 @@ describe('platform args', () => { ).toThrow(); } }); + it('accepts replacement login settings only in repair', () => { + expect(parsePlatformArgs(['repair', 'team', '--auth-config', 'auth.json'])).toEqual({ + subcommand: 'repair', + selector: 'team', + plan: false, + runtime: '', + yes: false, + authConfig: 'auth.json', + }); + expect(parsePlatformArgs(['repair'])).not.toHaveProperty('authConfig'); + expect(() => parsePlatformArgs(['repair', '--auth-config'])).toThrow('requires a value'); + for (const command of ['upgrade', 'enable', 'disable', 'uninstall']) { + expect(() => parsePlatformArgs([command, '--auth-config', 'auth.json'])).toThrow( + "Unknown flag '--auth-config'", + ); + } + }); it('parses resumable domain setup without confusing app-domain commands', () => { expect( parsePlatformArgs(['domain', 'set', 'example.com', '--installation', 'team', '--plan']), diff --git a/packages/void/test/unit/platform-database-lock.test.ts b/packages/void/test/unit/platform-database-lock.test.ts index ba2733ff9..70a237501 100644 --- a/packages/void/test/unit/platform-database-lock.test.ts +++ b/packages/void/test/unit/platform-database-lock.test.ts @@ -92,3 +92,12 @@ it('refuses leftover migration history before claiming and still resumes its own lock = undefined; await expect(claimPlatformDatabase(url, 'test-install', 'test-owner')).resolves.toBeUndefined(); }, 30_000); + +it('tells a reinstall on the same database to use a new empty database', async () => { + const url = 'postgresql://owner:pw@db.example.com/platform?sslmode=require'; + await claimPlatformDatabase(url, 'test-install', 'test-owner'); + // Uninstall leaves the claim and the data, and a reinstall gets a new owner. + await expect(claimPlatformDatabase(url, 'test-install', 'next-owner')).rejects.toThrow( + 'platform: This PostgreSQL database is already claimed by Void platform installation test-install. Uninstall keeps the database and its data, so use a dedicated empty database for each installation.', + ); +}, 30_000); diff --git a/packages/void/test/unit/platform-install-auth.test.ts b/packages/void/test/unit/platform-install-auth.test.ts index 6441eb502..71fa07d5c 100644 --- a/packages/void/test/unit/platform-install-auth.test.ts +++ b/packages/void/test/unit/platform-install-auth.test.ts @@ -2,16 +2,26 @@ import { createHash } from 'node:crypto'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { parsePlatformAuthenticationSetup } from '@void/platform'; +import { password, text } from '@voidzero-dev/vite-plus-prompts'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; import type { PlatformCloudflareClient } from '../../src/cli/platform-cloudflare.ts'; import { + authenticationSetupRequestHash, collectInstallationAuthentication, + replacePendingInstallationAuthentication, selectInstallationAuthentication, platformManagementAccessForAuthentication, } from '../../src/cli/platform-install-auth.ts'; import { createPlatformInstallationSecrets } from '../../src/cli/platform-installer.ts'; import type { PlatformInstallPlan } from '../../src/cli/platform-plan.ts'; +vi.mock('@voidzero-dev/vite-plus-prompts', async (original) => ({ + ...(await original()), + password: vi.fn(), + text: vi.fn(), +})); + let directory: string; const plan = { apiUrl: 'https://api.example.com', @@ -232,3 +242,205 @@ describe('installer login selection', () => { ); }); }); + +describe('pending login replacement', () => { + const sha256 = (value: string) => createHash('sha256').update(value).digest('hex'); + const accessConnection = { + configuration: { + id: 'access', + kind: 'cloudflare-access', + label: 'Cloudflare Access', + clientId: 'access-client', + issuer: 'https://team.cloudflareaccess.com/cdn-cgi/access/sso/oidc/access-client', + }, + clientSecret: 'access-secret', + }; + const protection = { + teamDomain: 'https://team.cloudflareaccess.com', + applications: [{ origin: 'https://api.example.com', audience: 'api-audience' }], + }; + const previous = JSON.stringify( + parsePlatformAuthenticationSetup({ + connections: [ + { + configuration: { + id: 'google', + kind: 'google', + label: 'Google', + clientId: 'old-client', + allowedDomains: ['example.com'], + }, + clientSecret: 'old-google-secret', + }, + accessConnection, + ], + administratorConnectionId: 'google', + admission: { mode: 'restricted' }, + protection, + bootstrapCredentialHash: sha256('previous-code'), + }), + ); + const authentication = { + providers: ['google', 'cloudflare-access'] as Array<'google' | 'cloudflare-access'>, + configurable: true as const, + accessProtection: true as const, + }; + const googleFile = { + configuration: { + id: 'google', + kind: 'google', + label: 'Google', + clientId: 'new-client', + allowedDomains: ['example.com'], + }, + clientSecretEnv: 'TEST_NEW_GOOGLE_SECRET', + }; + async function writeAuthFile(value: unknown): Promise { + const file = join(directory, 'replacement.json'); + await writeFile(file, JSON.stringify(value)); + return file; + } + function replace(file?: string) { + return replacePendingInstallationAuthentication({ + ...(file ? { file } : {}), + authentication, + setupJson: previous, + apiUrl: 'https://api.example.com', + }); + } + + it('replaces the login settings from a file with a new setup code', async () => { + vi.stubEnv('TEST_NEW_GOOGLE_SECRET', 'new-google-secret'); + const output = vi.spyOn(console, 'log').mockImplementation(() => {}); + // Listed in another order than installed, with the Access login kept by ID. + const file = await writeAuthFile({ + connections: [{ configuration: { id: 'access', kind: 'cloudflare-access' } }, googleFile], + protection, + }); + const result = await replace(file); + const setup = parsePlatformAuthenticationSetup(JSON.parse(result.authenticationSetupJson)); + expect(setup.resetPending).toBe(true); + expect(setup.bootstrapCredentialHash).toBe(sha256(result.authBootstrapCode)); + expect(setup.bootstrapCredentialHash).not.toBe(sha256('previous-code')); + expect(setup.connections.map((entry) => entry.configuration.id)).toEqual(['access', 'google']); + expect(setup.connections[1]).toMatchObject({ + configuration: { clientId: 'new-client' }, + clientSecret: 'new-google-secret', + }); + expect(setup.connections[0]).toEqual(JSON.parse(previous).connections[1]); + expect(setup.protection).toEqual(protection); + expect(setup.administratorConnectionId).toBe('google'); + expect(output).toHaveBeenCalledWith( + expect.stringContaining('https://api.example.com/auth/callback'), + ); + expect(JSON.stringify(output.mock.calls)).not.toContain('new-google-secret'); + // The platform records the same hash whether or not the request resets setup. + expect(authenticationSetupRequestHash(result.authenticationSetupJson)).toBe( + authenticationSetupRequestHash(JSON.stringify({ ...setup, resetPending: undefined })), + ); + }); + + it('refuses a file that changes login methods or Access protection', async () => { + vi.stubEnv('TEST_NEW_GOOGLE_SECRET', 'new-google-secret'); + const otherMethod = await writeAuthFile({ + connections: [ + { configuration: { id: 'access', kind: 'cloudflare-access' } }, + { + configuration: { + kind: 'oidc', + label: 'SSO', + issuer: 'https://sso.example.com', + clientId: 'c', + }, + clientSecretEnv: 'TEST_NEW_GOOGLE_SECRET', + }, + ], + protection, + }); + await expect(replace(otherMethod)).rejects.toThrow( + 'Repair changes login settings, not which login methods or Access protection', + ); + const unprotected = await writeAuthFile({ + connections: [{ configuration: { id: 'access', kind: 'cloudflare-access' } }, googleFile], + }); + await expect(replace(unprotected)).rejects.toThrow( + 'not which login methods or Access protection', + ); + }); + + it('keeps every installed connection when several share a kind', async () => { + vi.stubEnv('TEST_NEW_OIDC_SECRET', 'new-oidc-secret'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const oidc = (id: string) => ({ + configuration: { + id, + kind: 'oidc', + label: id, + issuer: `https://${id}.example.com`, + clientId: `${id}-client`, + }, + }); + const installed = JSON.stringify( + parsePlatformAuthenticationSetup({ + connections: [ + { ...oidc('company'), clientSecret: 'company-secret' }, + { ...oidc('partner'), clientSecret: 'partner-secret' }, + ], + administratorConnectionId: 'company', + admission: { mode: 'restricted' }, + protection: null, + bootstrapCredentialHash: sha256('previous-code'), + }), + ); + const replaceInstalled = async (ids: Array) => + replacePendingInstallationAuthentication({ + file: await writeAuthFile({ + connections: ids.map((id) => ({ ...oidc(id), clientSecretEnv: 'TEST_NEW_OIDC_SECRET' })), + }), + authentication: { providers: ['oidc'], configurable: true }, + setupJson: installed, + apiUrl: 'https://api.example.com', + }); + for (const ids of [['company'], ['company', 'partner', 'vendor'], ['company', 'vendor']]) { + await expect(replaceInstalled(ids)).rejects.toThrow( + 'List each installed connection with its ID: company, partner.', + ); + } + const setup = parsePlatformAuthenticationSetup( + JSON.parse((await replaceInstalled(['partner', 'company'])).authenticationSetupJson), + ); + expect(setup.connections.map((entry) => entry.configuration.id)).toEqual([ + 'partner', + 'company', + ]); + expect(setup.administratorConnectionId).toBe('company'); + }); + + it('asks for each setting with the saved value and keeps a secret left blank', async () => { + const tty = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + try { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.mocked(text) + .mockResolvedValueOnce('changed-client') + .mockResolvedValueOnce('example.com, corp.example.com'); + vi.mocked(password).mockResolvedValueOnce(''); + const setup = parsePlatformAuthenticationSetup( + JSON.parse((await replace()).authenticationSetupJson), + ); + expect(text).toHaveBeenCalledWith(expect.objectContaining({ initialValue: 'old-client' })); + expect(text).toHaveBeenCalledWith(expect.objectContaining({ initialValue: 'example.com' })); + expect(setup.connections[0]).toMatchObject({ + configuration: { + clientId: 'changed-client', + allowedDomains: ['example.com', 'corp.example.com'], + }, + clientSecret: 'old-google-secret', + }); + expect(setup.connections[1]).toEqual(JSON.parse(previous).connections[1]); + expect(setup.resetPending).toBe(true); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: tty, configurable: true }); + } + }); +}); diff --git a/packages/void/test/unit/platform-installer-errors.test.ts b/packages/void/test/unit/platform-installer-errors.test.ts index 34aaf5595..c1e55e3e3 100644 --- a/packages/void/test/unit/platform-installer-errors.test.ts +++ b/packages/void/test/unit/platform-installer-errors.test.ts @@ -42,6 +42,44 @@ it('reports structured provider errors when stderr is empty using existing crede ); }); +it('points to the Analytics Engine setup when Cloudflare refuses its binding', () => { + const raw = + 'You need to enable Analytics Engine. Head to the Cloudflare Dashboard to enable: https://dash.cloudflare.com/account/workers/analytics-engine [code: 10089]'; + const hint = + ' If the Analytics Engine page offers no way to enable it, follow https://void.cloud/guide/platform/installation/prerequisites#if-cloudflare-rejects-analytics-engine, then retry.'; + expect(() => + assertCleanPlatformWrangler( + { status: 1, stdout: '', stderr: raw, errorCode: 10089 }, + 'bootstrap void-team-tail', + ), + ).toThrow(`platform: Wrangler failed to bootstrap void-team-tail (${raw}).${hint}`); + expect(() => + assertCleanPlatformWrangler( + { + status: 1, + stdout: '', + stderr: '', + errors: [{ code: 10089, message: 'You need to enable Analytics Engine.' }], + }, + 'bootstrap void-team-tail', + ), + ).toThrow(hint); + expect(() => + assertCleanPlatformWrangler( + { status: 1, stdout: '', stderr: 'You need to enable Analytics Engine. [10089]' }, + 'bootstrap void-team-tail', + ), + ).toThrow(hint); + expect(() => + assertCleanPlatformWrangler( + { status: 1, stdout: '', stderr: 'Authentication error [code: 10000]', errorCode: 10000 }, + 'bootstrap void-team-tail', + ), + ).toThrow( + /^platform: Wrangler failed to bootstrap void-team-tail \(Authentication error \[code: 10000\]\)\.$/, + ); +}); + it('retains the exit status when stdout contains only binding values', () => { expect(() => assertCleanPlatformWrangler( diff --git a/packages/void/test/unit/platform-installer.test.ts b/packages/void/test/unit/platform-installer.test.ts index 893c324a6..9494ac5ab 100644 --- a/packages/void/test/unit/platform-installer.test.ts +++ b/packages/void/test/unit/platform-installer.test.ts @@ -478,7 +478,9 @@ describe('platform installer', () => { let verifiedHealthAccess = 0; let claimed = false; let prepared = false; + const progress: Array = []; const result = await installPlatform(plan, secrets, null, { + onProgress: (message) => progress.push(message), verifyAccessProtection: async (input) => { verifiedProtection++; expect(input.protection).toEqual(JSON.parse(setupJson).protection); @@ -596,6 +598,11 @@ describe('platform installer', () => { }, }); expect(initialized).toBe(1); + // The setup page answers only once traffic opens, after this progress line. + expect(progress).toContain( + 'Administrator setup prepared; its link and code are shown when this finishes', + ); + expect(progress.filter((message) => message.includes('/setup'))).toEqual([]); expect(verifiedProtection).toBe(protection ? 1 : 0); expect(verifiedHealthAccess).toBe(protection ? 1 : 0); expect(result.manifest.status).toBe('installed'); @@ -956,6 +963,46 @@ describe('platform installer', () => { }); }); + it('generates missing identity keys only for a fresh install that keeps recovery data', () => { + const { + jwtSecret: _jwtSecret, + projectSecretKey: _projectSecretKey, + ...input + } = FRESH_SECRETS_INPUT; + const generated = createPlatformInstallationSecrets({ + ...input, + emailEnabled: true, + generateIdentityKeys: true, + }); + expect(Buffer.byteLength(generated.jwtSecret, 'utf8')).toBeGreaterThanOrEqual(32); + expect(Buffer.from(generated.projectSecretKey, 'base64')).toHaveLength(32); + expect(generated.projectSecretKeysJson).toBe( + JSON.stringify({ v1: generated.projectSecretKey }), + ); + expect( + new Set([generated.jwtSecret, generated.projectSecretKey, generated.emailSigningSecret]).size, + ).toBe(3); + + expect( + createPlatformInstallationSecrets({ + ...input, + generateIdentityKeys: true, + jwtSecret: TEST_PLATFORM_JWT_SECRET, + checkpoint: { projectSecretKey: TEST_PLATFORM_PROJECT_SECRET_KEY }, + }), + ).toMatchObject({ + jwtSecret: TEST_PLATFORM_JWT_SECRET, + projectSecretKey: TEST_PLATFORM_PROJECT_SECRET_KEY, + }); + expect( + createPlatformInstallationSecrets({ + ...input, + existingInstallation: true, + generateIdentityKeys: true, + }), + ).toMatchObject({ jwtSecret: '', projectSecretKey: '' }); + }); + it('generates and preserves a dedicated email signing key only for email installations', () => { const emailDisabled = freshSecrets({ emailSigningSecret: TEST_PLATFORM_EMAIL_SIGNING_SECRET, @@ -3395,6 +3442,7 @@ describe('platform installer', () => { applications: [], destinations: [], unfinishedDeletions: [], + usersExist: false, }); expect(queries.find(({ sql }) => sql.includes('FROM "platform_settings"'))?.values).toEqual([ 'zeroTrust.configuration', diff --git a/packages/void/test/unit/platform-management-upgrade.test.ts b/packages/void/test/unit/platform-management-upgrade.test.ts index f4b9c5308..634b41df3 100644 --- a/packages/void/test/unit/platform-management-upgrade.test.ts +++ b/packages/void/test/unit/platform-management-upgrade.test.ts @@ -1,6 +1,11 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { stripVTControlCharacters } from 'node:util'; import { createPlatformDeploymentManifest, + parsePlatformAuthenticationSetup, createPlatformInstallationManifest, recordInstallationResource, recordInstallationStep, @@ -21,6 +26,7 @@ const state = vi.hoisted(() => ({ events: [] as Array, manifest: undefined as PlatformInstallationManifest | undefined, prepareDatabase: vi.fn(), + readAdministratorSetup: vi.fn(), readAuthenticationState: vi.fn(), readPlanZeroTrust: vi.fn(), readProjectZeroTrust: vi.fn(), @@ -114,6 +120,7 @@ vi.mock('../../src/cli/platform-installer.ts', async (importOriginal) => { authoritativeManifest: state.manifest, checkpoint: vi.fn(), prepareDatabase: state.prepareDatabase, + readAdministratorSetup: state.readAdministratorSetup, readAuthenticationState: state.readAuthenticationState, readProjectZeroTrust: state.readProjectZeroTrust, }, @@ -198,13 +205,16 @@ vi.mock('../../src/cli/platform-registry.ts', async (importOriginal) => { acquirePlatformLifecycleLock: vi.fn(() => () => state.events.push('local-release')), listInstallationManifests: vi.fn(() => [state.manifest]), readInstallationRecoverySecrets: vi.fn(() => ({ databaseUrl: 'postgres://database' })), + readPendingInstallationSecrets: vi.fn(), resolveInstallationManifest: vi.fn(() => state.manifest), writeInstallationManifest: vi.fn(), + writePendingInstallationSecrets: vi.fn(), }; }); -import { log, note } from '@voidzero-dev/vite-plus-prompts'; +import { confirm, log, note } from '@voidzero-dev/vite-plus-prompts'; import { PlatformCloudflareApiError } from '../../src/cli/platform-cloudflare.ts'; +import { authenticationSetupRequestHash } from '../../src/cli/platform-install-auth.ts'; import { acquirePlatformDatabaseLifecycleLock, installPlatform, @@ -217,7 +227,9 @@ import { createPlatformInstallPlan } from '../../src/cli/platform-plan.ts'; import { acquirePlatformLifecycleLock, readInstallationRecoverySecrets, + readPendingInstallationSecrets, writeInstallationManifest, + writePendingInstallationSecrets, } from '../../src/cli/platform-registry.ts'; const upgrade = (overrides: { plan?: boolean; yes?: boolean } = {}) => @@ -633,6 +645,252 @@ describe('installed platform upgrade', () => { ); }); + describe('repair while first administrator setup is pending', () => { + const previousCode = 'a'.repeat(64); + const previousSetup = JSON.stringify( + parsePlatformAuthenticationSetup({ + connections: [ + { + configuration: { + id: 'company', + kind: 'oidc', + label: 'Company SSO', + issuer: 'https://sso.example.com', + clientId: 'old-client', + }, + clientSecret: 'old-secret', + }, + ], + administratorConnectionId: 'company', + admission: { mode: 'restricted' }, + protection: null, + bootstrapCredentialHash: createHash('sha256').update(previousCode).digest('hex'), + }), + ); + const recovery = { + databaseUrl: 'postgres://database', + authenticationSetupJson: previousSetup, + authSetupToken: 'b'.repeat(64), + authBootstrapCode: previousCode, + }; + let directory: string; + let authFile: string; + const repair = (overrides: { authConfig?: string; plan?: boolean; yes?: boolean } = {}) => + runInstalledPlatformCommand({ + subcommand: 'repair', + selector: state.manifest!.id, + plan: false, + runtime: '', + yes: true, + ...overrides, + }); + const replacement = () => state.createSecrets.mock.calls.at(-1)![0]; + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'void-repair-login-')); + authFile = join(directory, 'auth.json'); + await writeFile( + authFile, + JSON.stringify({ + connections: [ + { + configuration: { + id: 'company', + kind: 'oidc', + label: 'Company SSO', + issuer: 'https://sso.example.com', + clientId: 'new-client', + }, + clientSecretEnv: 'TEST_REPAIR_LOGIN_SECRET', + }, + ], + }), + ); + vi.stubEnv('TEST_REPAIR_LOGIN_SECRET', 'new-secret'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.mocked(readInstallationRecoverySecrets).mockReturnValue(recovery); + state.readAdministratorSetup.mockReset().mockResolvedValue({ + pending: true, + usersExist: false, + requestHash: authenticationSetupRequestHash(previousSetup), + }); + state.manifest = { + ...state.manifest!, + deployment: createPlatformDeploymentManifest({ + ...state.manifest!.deployment, + authentication: { providers: ['oidc'], configurable: true }, + capabilities: { ...state.manifest!.deployment.capabilities, githubOAuth: false }, + }), + }; + }); + afterEach(async () => { + await rm(directory, { recursive: true, force: true }); + }); + + it('sends new login settings with a new setup code and keeps the setup authority', async () => { + vi.mocked(installPlatform).mockResolvedValueOnce({ + manifest: state.manifest!, + setupPending: true, + waitingForDns: false, + waitingForSandboxDrain: false, + zoneNameServers: [], + }); + await repair({ authConfig: authFile }); + const input = replacement(); + const setup = parsePlatformAuthenticationSetup(JSON.parse(input.authenticationSetupJson)); + expect(setup.resetPending).toBe(true); + expect(setup.connections[0]).toMatchObject({ + configuration: { clientId: 'new-client' }, + clientSecret: 'new-secret', + }); + expect(input.authBootstrapCode).toMatch(/^[a-f0-9]{64}$/); + expect(input.authBootstrapCode).not.toBe(previousCode); + expect(setup.bootstrapCredentialHash).toBe( + createHash('sha256').update(input.authBootstrapCode).digest('hex'), + ); + expect(input).not.toHaveProperty('authSetupToken'); + expect(input.checkpoint).toBe(recovery); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining('/auth/callback')); + expect(log.info).toHaveBeenCalledWith( + `If sign-in fails, run \`void platform repair ${state.manifest!.id}\` to change the login settings.`, + ); + }); + + it.each([ + [ + 'once the first administrator signed in', + { pending: false, usersExist: true, requestHash: 'accepted' }, + 'The first administrator has already signed in. Change login methods with `void platform config auth`.', + ], + [ + 'when setup was never started', + { pending: false, usersExist: false, requestHash: null }, + 'Administrator setup was never started. Resume the install with `void platform install --resume --name', + ], + ])('refuses before any change %s', async (_label, setup, message) => { + state.readAdministratorSetup.mockResolvedValueOnce(setup); + await expect(repair({ authConfig: authFile })).rejects.toThrow(message); + expect(state.createSecrets).not.toHaveBeenCalled(); + expect(installPlatform).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'a GitHub-only installation', + () => { + state.manifest = { + ...state.manifest!, + deployment: createPlatformDeploymentManifest({ + ...state.manifest!.deployment, + authentication: { providers: ['github'] }, + capabilities: { ...state.manifest!.deployment.capabilities, githubOAuth: true }, + }), + }; + }, + 'Set VOID_PLATFORM_GITHUB_CLIENT_ID and VOID_PLATFORM_GITHUB_CLIENT_SECRET', + ], + [ + 'a computer without the saved setup authority', + () => { + vi.mocked(readInstallationRecoverySecrets).mockReturnValue({ + databaseUrl: 'postgres://database', + }); + }, + 'needs the setup authority saved on the computer that installed the platform', + ], + ])('refuses %s before planning', async (_label, arrange, message) => { + arrange(); + await expect(repair({ authConfig: authFile })).rejects.toThrow(message); + expect(createPlatformInstallPlan).not.toHaveBeenCalled(); + expect(installPlatform).not.toHaveBeenCalled(); + }); + + it('refuses on a disabled platform before planning', async () => { + state.manifest = { ...state.manifest!, desiredState: 'disabled', status: 'disabled' }; + await expect(repair({ authConfig: authFile })).rejects.toThrow( + `Run \`void platform enable ${state.manifest.id}\` first`, + ); + expect(createPlatformInstallPlan).not.toHaveBeenCalled(); + expect(installPlatform).not.toHaveBeenCalled(); + }); + + it('checks the file in a plan without reading the database', async () => { + await repair({ authConfig: authFile, plan: true, yes: false }); + expect(log.info).toHaveBeenCalledWith( + `Login settings will be replaced from ${authFile}; a new setup code is printed at the end.`, + ); + expect(acquirePlatformDatabaseLifecycleLock).not.toHaveBeenCalled(); + expect(state.readAdministratorSetup).not.toHaveBeenCalled(); + await writeFile( + authFile, + JSON.stringify({ connections: [{ configuration: { kind: 'github', clientId: 'id' } }] }), + ); + await expect(repair({ authConfig: authFile, plan: true, yes: false })).rejects.toThrow( + 'not which login methods', + ); + }); + + it('asks in a terminal only while setup is pending, and No keeps the saved settings', async () => { + const tty = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + try { + vi.mocked(confirm).mockResolvedValueOnce(false); + await repair({ yes: false }); + expect(confirm).toHaveBeenCalledExactlyOnceWith({ + message: + 'First administrator setup is not finished. Change the login settings before continuing?', + initialValue: false, + }); + expect(replacement()).not.toHaveProperty('authenticationSetupJson'); + + vi.mocked(confirm).mockClear(); + state.progress.stop.mockClear(); + state.readAdministratorSetup.mockResolvedValueOnce({ + pending: false, + usersExist: true, + requestHash: 'accepted', + }); + await repair({ yes: false }); + expect(confirm).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { value: tty, configurable: true }); + } + }); + + it('keeps the saved login settings when the first administrator signs in during repair', async () => { + const pending = { ...recovery, authenticationSetupJson: 'new', authBootstrapCode: 'new' }; + vi.mocked(readPendingInstallationSecrets).mockReturnValueOnce(pending); + state.readAdministratorSetup + .mockResolvedValueOnce({ + pending: true, + usersExist: false, + requestHash: authenticationSetupRequestHash(previousSetup), + }) + .mockResolvedValueOnce({ + pending: false, + usersExist: true, + requestHash: authenticationSetupRequestHash(previousSetup), + }); + vi.mocked(installPlatform).mockRejectedValueOnce( + new Error('platform: Authentication setup failed (HTTP 409).'), + ); + await expect(repair({ authConfig: authFile })).rejects.toThrow( + 'The first administrator signed in before the new login settings were applied. Nothing changed.', + ); + expect(writePendingInstallationSecrets).toHaveBeenCalledExactlyOnceWith(state.manifest, { + ...pending, + authenticationSetupJson: previousSetup, + authBootstrapCode: previousCode, + }); + }); + + it('keeps the new login settings for a retry when the platform has not refused them', async () => { + vi.mocked(installPlatform).mockRejectedValueOnce(new Error('Worker upload failed')); + await expect(repair({ authConfig: authFile })).rejects.toThrow('Worker upload failed'); + expect(writePendingInstallationSecrets).not.toHaveBeenCalled(); + }); + }); + describe('uninstall with project Zero Trust', () => { const enabled = { active: true, diff --git a/packages/void/test/unit/platform-management.test.ts b/packages/void/test/unit/platform-management.test.ts index e3823a4e1..807f83f53 100644 --- a/packages/void/test/unit/platform-management.test.ts +++ b/packages/void/test/unit/platform-management.test.ts @@ -727,6 +727,127 @@ describe('uninstall with project Zero Trust', () => { expectNothingChanged(fake); }); + describe('before any platform user exists', () => { + const noUser = () => ({ + ...projectZeroTrustUninstallState(zeroTrustSetting(), []), + usersExist: false, + }); + + it('turns off Zero Trust through the platform API before taking the lock', async () => { + const manifest = installedManifest(); + persistInstallation(manifest, directories); + const fake = client(manifest); + const events: Array = []; + const putKvValue = fake.putKvValue; + fake.putKvValue = async (account, namespace, key, value) => { + if (key === PLATFORM_DISABLED_KEY) { + events.push('disable traffic'); + } + await putKvValue(account, namespace, key, value); + }; + let turnedOff = false; + await uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + ...zeroTrustLock(events).dependencies, + readProjectZeroTrust: async () => { + events.push('check'); + return turnedOff ? projectZeroTrustUninstallState(disabledZeroTrust, []) : noUser(); + }, + turnOffProjectZeroTrust: async () => { + events.push('turn off'); + turnedOff = true; + }, + }); + expect(events).toEqual([ + 'check', + 'turn off', + 'lock', + 'check', + 'fence', + 'disable traffic', + 'unlock', + ]); + expect(listInstallationManifests()[0]?.status).toBe('uninstalled'); + }); + + it('lists the applications it will delete in the plan without changing anything', async () => { + const manifest = installedManifest(); + persistInstallation(manifest, directories); + const fake = client(manifest); + const turnOff = vi.fn(); + await uninstallPlatform( + fake, + manifest, + { ...uninstallArgs, plan: true }, + { + interactive: false, + readProjectZeroTrust: async () => noUser(), + turnOffProjectZeroTrust: turnOff, + }, + ); + const plan = noteText('Uninstall plan'); + expect(plan).toContain( + 'Turn off project Zero Trust; no platform user exists yet. Delete its Access applications:', + ); + expect(plan).toContain(`shared project application: ${wildcardName} (wildcard-app)`); + expect(turnOff).not.toHaveBeenCalled(); + expectNothingChanged(fake); + }); + + it('keeps refusing once a platform user exists', async () => { + const manifest = installedManifest(); + persistInstallation(manifest, directories); + const fake = client(manifest); + const turnOff = vi.fn(); + await expect( + uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + ...unlockedProjectZeroTrust, + readProjectZeroTrust: async () => ({ ...noUser(), usersExist: true }), + turnOffProjectZeroTrust: turnOff, + }), + ).rejects.toThrow('Run `void platform zero-trust disable`'); + expect(turnOff).not.toHaveBeenCalled(); + expectNothingChanged(fake); + }); + + it('points to the installing computer instead of an administrator command', async () => { + const manifest = installedManifest(); + persistInstallation(manifest, directories); + const fake = client(manifest); + const error = await uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + ...unlockedProjectZeroTrust, + readProjectZeroTrust: async () => noUser(), + }).catch((reason: unknown) => reason as Error); + expect(error!.message).toContain('Refusing to uninstall while project Zero Trust is enabled'); + expect(error!.message).toContain( + 'No platform user exists yet. Run this command on the computer that installed the platform', + ); + expect(error!.message).not.toContain('Run `void platform zero-trust disable`'); + expectNothingChanged(fake); + }); + + it('stops before any change when turning off fails', async () => { + const manifest = installedManifest(); + persistInstallation(manifest, directories); + const fake = client(manifest); + const events: Array = []; + await expect( + uninstallPlatform(fake, manifest, uninstallArgs, { + interactive: false, + ...zeroTrustLock(events).dependencies, + readProjectZeroTrust: async () => noUser(), + turnOffProjectZeroTrust: async () => { + throw new Error('platform: Project Zero Trust is still turning off.'); + }, + }), + ).rejects.toThrow('Project Zero Trust is still turning off.'); + expect(events).toEqual([]); + expectNothingChanged(fake); + }); + }); + it('refuses without a database session that can hold off Zero Trust changes', async () => { const manifest = installedManifest(); persistInstallation(manifest, directories); @@ -750,7 +871,11 @@ describe('uninstall with project Zero Trust', () => { }); describe('project Zero Trust database read', () => { - function connection(tables: { settings: boolean; projects: boolean }, setting: unknown) { + function connection( + tables: { settings: boolean; projects: boolean; users?: boolean }, + setting: unknown, + usersExist = false, + ) { const queries: Array = []; return { queries, @@ -759,6 +884,9 @@ describe('project Zero Trust database read', () => { if (sql.includes('to_regclass')) { return { rows: [tables] }; } + if (sql.includes('FROM "users"')) { + return { rows: [{ present: usersExist }] }; + } if (sql.includes('"platform_settings"')) { expect(values).toEqual(['zeroTrust.configuration']); return { rows: setting === undefined ? [] : [{ value: setting }] }; @@ -781,6 +909,27 @@ describe('project Zero Trust database read', () => { expect(db.queries).toHaveLength(1); }); + it('reads whether a platform user exists only while Zero Trust is active', async () => { + const active = connection({ settings: true, projects: true, users: true }, zeroTrustSetting()); + expect(await readPlatformProjectZeroTrustStateConnection(active as never)).toMatchObject({ + active: true, + usersExist: false, + }); + const withUser = connection( + { settings: true, projects: true, users: true }, + zeroTrustSetting(), + true, + ); + expect(await readPlatformProjectZeroTrustStateConnection(withUser as never)).toMatchObject({ + usersExist: true, + }); + const inactive = connection({ settings: true, projects: true, users: true }, disabledZeroTrust); + expect(await readPlatformProjectZeroTrustStateConnection(inactive as never)).not.toHaveProperty( + 'usersExist', + ); + expect(inactive.queries.some((sql) => sql.includes('FROM "users"'))).toBe(false); + }); + it('reads the saved configuration and project applications', async () => { const db = connection({ settings: true, projects: true }, zeroTrustSetting()); const state = await readPlatformProjectZeroTrustStateConnection(db as never); diff --git a/packages/void/test/unit/platform-plan.test.ts b/packages/void/test/unit/platform-plan.test.ts index 48577829d..70bc9fdf6 100644 --- a/packages/void/test/unit/platform-plan.test.ts +++ b/packages/void/test/unit/platform-plan.test.ts @@ -17,6 +17,7 @@ import { PlatformCloudflareClient, } from '../../src/cli/platform-cloudflare.ts'; import { platformDomainTarget } from '../../src/cli/platform-domain.ts'; +import { recordProvisionIntent } from '../../src/cli/platform-installer-resources.ts'; import { installPlatform } from '../../src/cli/platform-installer.ts'; import { verifyPlatformAiGatewayPlan } from '../../src/cli/platform-lifecycle.ts'; import { @@ -1165,6 +1166,29 @@ describe('platform installation planning', () => { }); }); + it('refuses a same-named AI Gateway after an interrupted create', async () => { + const initial = await createPlatformInstallPlan(client(), input, null); + const action = initial.actions.find((entry) => entry.key === 'ai-gateway')!; + const name = action.name; + const manifest = recordProvisionIntent( + manifestFor(initial), + action, + '2026-09-14T00:00:01.000Z', + ); + // Gateways carry no ownership marker, so a creation time just after the + // intent does not show who created this one. + const withGateway = client({ + listAiGateways: async () => [{ createdAt: '2026-09-14T00:00:03.000Z', id: name, name }], + }); + + await expect(createPlatformInstallPlan(withGateway, input, manifest)).rejects.toThrow( + `platform: ai-gateway "${name}" already exists, and Void cannot tell whether this installation created it. Nothing was overwritten. If an earlier failed attempt of this installation created it, delete it in the Cloudflare dashboard, then rerun the command. Otherwise, choose another installation name.`, + ); + await expect(createPlatformInstallPlan(withGateway, input, null)).rejects.toThrow( + 'Resume the owning installation, or choose another installation name.', + ); + }); + it('refuses an intent-only resource after an ambiguous create response', async () => { const initial = await createPlatformInstallPlan(client(), input, legacyInstallation()); let manifest = manifestFor(initial); @@ -1193,7 +1217,7 @@ describe('platform installation planning', () => { input, manifest, ), - ).rejects.toThrow('not owned'); + ).rejects.toThrow('cannot tell whether this installation created it'); const recovered = await createPlatformInstallPlan( client({ @@ -1372,7 +1396,7 @@ describe('platform installation planning', () => { input, manifest, ), - ).rejects.toThrow('not owned'); + ).rejects.toThrow('cannot tell whether this installation created it'); }); it('resumes an intent-owned dispatch Worker after its public route was uploaded', async () => { diff --git a/packages/void/test/unit/platform-setup-flow.test.ts b/packages/void/test/unit/platform-setup-flow.test.ts index 4845591d3..1548a1323 100644 --- a/packages/void/test/unit/platform-setup-flow.test.ts +++ b/packages/void/test/unit/platform-setup-flow.test.ts @@ -41,6 +41,7 @@ const mocks = vi.hoisted(() => ({ password: vi.fn(), operatorCredential: vi.fn(), open: vi.fn(), + claim: vi.fn(), install: vi.fn(), preflight: vi.fn(), plan: vi.fn(), @@ -90,6 +91,7 @@ vi.mock('../../src/cli/platform-cloudflare.ts', async (original) => ({ })); vi.mock('../../src/cli/platform-installer.ts', async (original) => ({ ...(await original()), + claimPlatformDatabase: mocks.claim, installPlatform: mocks.install, resolveBootstrapGitHubId: async () => '12345', readPlatformDatabaseLifecycleManifest: mocks.database, @@ -1151,6 +1153,87 @@ describe('platform credential onboarding', () => { ); }); + it('names the database variable while a refused database can still be replaced', async () => { + const info = vi.spyOn(log, 'info').mockImplementation(() => {}); + const hint = + 'To use another PostgreSQL database, set VOID_PLATFORM_DATABASE_URL to its URL first.'; + const refused = + 'platform: This PostgreSQL database is already claimed by Void platform installation old-team.'; + mocks.install.mockImplementationOnce(async (_plan, _secrets, manifest) => { + writePendingInstallationSecrets(manifest, { + ...readPendingInstallationSecrets(manifest), + databaseClaimState: 'failed', + }); + throw new Error(refused); + }); + await expect(runPlatformLifecycleCommand(parsePlatformArgs(args))).rejects.toThrow(refused); + expect(info).toHaveBeenCalledWith(hint); + const manifest = listInstallationManifests()[0]!; + const saved = readPendingInstallationSecrets(manifest)!.databaseUrl; + + // A resume reuses the saved URL without asking for it again. + info.mockClear(); + mocks.password.mockClear(); + mocks.database.mockRejectedValueOnce( + new Error( + 'platform: The PostgreSQL database ownership claim does not match this installation.', + ), + ); + const resume = parsePlatformArgs(['install', '--resume', '--name', manifest.id]); + await expect(runPlatformLifecycleCommand(resume)).rejects.toThrow( + 'ownership claim does not match', + ); + expect(mocks.database).toHaveBeenCalledWith(saved, manifest.id, manifest.ownershipNonce); + expect( + mocks.password.mock.calls.some(([input]) => input.message.startsWith('PostgreSQL')), + ).toBe(false); + expect(info).toHaveBeenCalledWith(hint); + + const replacement = 'postgres://user:password@replacement.example.com/platform'; + vi.stubEnv('VOID_PLATFORM_DATABASE_URL', replacement); + mocks.database.mockResolvedValueOnce(null); + await runPlatformLifecycleCommand(resume); + expect(mocks.database).toHaveBeenLastCalledWith( + replacement, + manifest.id, + manifest.ownershipNonce, + ); + expect(mocks.install.mock.lastCall![1].databaseUrl).toBe(replacement); + }); + + it('keeps the database hint out of failures after the database is claimed', async () => { + const info = vi.spyOn(log, 'info').mockImplementation(() => {}); + const manifest = await interruptedInstall(); + mocks.database.mockResolvedValueOnce(null); + mocks.install.mockRejectedValueOnce(new Error('Worker upload failed')); + await expect( + runPlatformLifecycleCommand( + parsePlatformArgs(['install', '--resume', '--name', manifest.id]), + ), + ).rejects.toThrow('Worker upload failed'); + expect(info.mock.calls.some(([message]) => message.startsWith('Continue with:'))).toBe(true); + expect( + info.mock.calls.some(([message]) => message.includes('VOID_PLATFORM_DATABASE_URL')), + ).toBe(false); + }); + + it('keeps the database hint out of failures after this run claims the database', async () => { + const info = vi.spyOn(log, 'info').mockImplementation(() => {}); + mocks.install.mockImplementationOnce(async (_plan, secrets, manifest, dependencies) => { + await dependencies.claimDatabase?.(secrets.databaseUrl, manifest.id, manifest.ownershipNonce); + // Later checkpoints save the values without the claim state. + writePendingInstallationSecrets(manifest, { databaseUrl: secrets.databaseUrl }); + throw new Error('App access token rejected'); + }); + await expect(runPlatformLifecycleCommand(parsePlatformArgs(args))).rejects.toThrow( + 'App access token rejected', + ); + expect(info.mock.calls.some(([message]) => message.startsWith('Continue with:'))).toBe(true); + expect( + info.mock.calls.some(([message]) => message.includes('VOID_PLATFORM_DATABASE_URL')), + ).toBe(false); + }); + it.each(['void', 'void-dev'])( 'includes a %s resume command without a duplicate recovery hint', async (command) => { @@ -1201,7 +1284,7 @@ describe('platform credential onboarding', () => { }, ); - it('explains database and key inputs without displaying their values', async () => { + it('explains the database input and generates identity keys without displaying values', async () => { const info = vi.spyOn(log, 'info').mockImplementation(() => {}); await runPlatformLifecycleCommand(parsePlatformArgs(args)); expect(mocks.password).toHaveBeenCalledWith( @@ -1210,31 +1293,79 @@ describe('platform credential onboarding', () => { placeholder: 'postgresql://user:pw@…/database', }), ); - expect(mocks.password).toHaveBeenCalledWith( - expect.objectContaining({ message: 'JWT signing key:' }), - ); - expect(mocks.password).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Project encryption key:' }), - ); expect(mocks.text).toHaveBeenCalledWith(expect.objectContaining({ message: 'Access Key ID:' })); expect(mocks.password).toHaveBeenCalledWith( expect.objectContaining({ message: 'Secret Access Key:' }), ); + const prompts = [...mocks.password.mock.calls, ...mocks.text.mock.calls].map( + ([input]) => input.message, + ); + expect(prompts).not.toContain('JWT signing key:'); + expect(prompts).not.toContain('Project encryption key:'); + const secrets = mocks.install.mock.calls[0]![1]; + expect(Buffer.byteLength(secrets.jwtSecret, 'utf8')).toBeGreaterThanOrEqual(32); + expect(Buffer.from(secrets.projectSecretKey, 'base64').toString('base64')).toBe( + secrets.projectSecretKey, + ); + expect(Buffer.from(secrets.projectSecretKey, 'base64')).toHaveLength(32); + const saved = readPendingInstallationSecrets(listInstallationManifests()[0]!)!; + expect(saved.jwtSecret).toBe(secrets.jwtSecret); + expect(saved.projectSecretKey).toBe(secrets.projectSecretKey); const descriptions = stripVTControlCharacters( info.mock.calls.map(([message]) => message).join('\n'), ); expect(descriptions).toContain( 'https://void.cloud/guide/platform/installation/prerequisites#platform-database', ); + expect(descriptions).toContain( + 'Void generated the JWT signing key and project encryption key and keeps them in this machine’s encrypted recovery data.', + ); + expect(descriptions).not.toContain('openssl rand'); + expect(descriptions).not.toContain(environment.VOID_PLATFORM_DATABASE_URL); + expect(descriptions).not.toContain(secrets.jwtSecret); + expect(descriptions).not.toContain(secrets.projectSecretKey); + }); + it('asks for missing identity keys with guidance when resuming a started install', async () => { + const info = vi.spyOn(log, 'info').mockImplementation(() => {}); + const manifest = await interruptedInstall(); + for (const [key, value] of Object.entries(environment)) { + if (key !== 'VOID_PLATFORM_JWT_SECRET' && key !== 'VOID_PLATFORM_PROJECT_SECRET_KEY') { + vi.stubEnv(key, value); + } + } + await runPlatformLifecycleCommand( + parsePlatformArgs(['install', '--resume', '--name', manifest.id]), + ); + expect(mocks.password.mock.calls.map(([input]) => input.message)).toEqual([ + 'JWT signing key:', + 'Project encryption key:', + ]); + const descriptions = stripVTControlCharacters( + info.mock.calls.map(([message]) => message).join('\n'), + ); expect(descriptions).toContain( 'Create a JWT signing key for platform login tokens. Run openssl rand -base64 32', ); expect(descriptions).toContain( 'Generate a project encryption key for app secrets. Run openssl rand -base64 32 separately', ); - expect(descriptions).not.toContain(environment.VOID_PLATFORM_DATABASE_URL); - expect(descriptions).not.toContain(environment.VOID_PLATFORM_JWT_SECRET); - expect(descriptions).not.toContain(environment.VOID_PLATFORM_PROJECT_SECRET_KEY); + expect(descriptions).not.toContain('Void generated'); + expect(mocks.install.mock.calls[0]![1]).toMatchObject({ + jwtSecret: environment.VOID_PLATFORM_JWT_SECRET, + projectSecretKey: environment.VOID_PLATFORM_PROJECT_SECRET_KEY, + }); + }); + it('requires identity keys from the environment on a non-interactive install', async () => { + for (const [key, value] of Object.entries(environment)) { + if (key !== 'VOID_PLATFORM_JWT_SECRET' && key !== 'VOID_PLATFORM_PROJECT_SECRET_KEY') { + vi.stubEnv(key, value); + } + } + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + await expect( + runPlatformLifecycleCommand(parsePlatformArgs([...args, '--yes'])), + ).rejects.toThrow('JWT signing key is required in non-interactive mode'); + expect(mocks.install).not.toHaveBeenCalled(); }); it('replaces a revoked saved token during the same continued setup', async () => { const draft = await savedSetup(); @@ -1760,12 +1891,9 @@ describe('platform credential onboarding', () => { expect(saved.emailSigningSecret).not.toBe(environment.VOID_PLATFORM_JWT_SECRET); }); it('does not persist an invalid signing key or begin provisioning', async () => { - const normal = mocks.password.getMockImplementation()!; - mocks.password.mockImplementation(async (input) => - input.message.startsWith('JWT') ? 'short' : normal(input), - ); + vi.stubEnv('VOID_PLATFORM_JWT_SECRET', 'short'); await expect(runPlatformLifecycleCommand(parsePlatformArgs(args))).rejects.toThrow( - '32 random bytes', + 'at least 32 bytes', ); const draft = listInstallationManifests()[0]!; expect(isPlatformSetupDraft(draft)).toBe(true); diff --git a/packages/void/test/unit/platform-setup.test.ts b/packages/void/test/unit/platform-setup.test.ts index 26aa3e704..6a6693f53 100644 --- a/packages/void/test/unit/platform-setup.test.ts +++ b/packages/void/test/unit/platform-setup.test.ts @@ -396,11 +396,27 @@ describe('platform credential setup links', () => { plan().apiUrl + '/auth/callback', ); expect(preview).toContain('PostgreSQL database URL'); - expect(preview).toContain('JWT signing key and project encryption key'); expect(preview).not.toMatch(/paste|copy|Workers Scripts|Object Read|Register a GitHub/); expect(preview).not.toMatch(/[();]/); expect(preview.split('\n').length).toBeLessThanOrEqual(10); }); + it('lists the identity keys only for installs that cannot generate them', () => { + const tty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + try { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + expect(formatPlatformSetupPlan(plan())).not.toContain('JWT signing key'); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + expect(formatPlatformSetupPlan(plan())).toContain( + 'JWT signing key and project encryption key for non-interactive installs', + ); + } finally { + if (tty) { + Object.defineProperty(process.stdin, 'isTTY', tty); + } else { + Reflect.deleteProperty(process.stdin, 'isTTY'); + } + } + }); it('lists the selected Access credentials and required DNS token concisely', () => { const original = plan('example.com'); const preview = formatPlatformSetupPlan( @@ -427,7 +443,7 @@ describe('platform credential setup links', () => { '• R2 upload token', '• Cloudflare Access OIDC client ID and secret', '• Cloudflare Access application and installer credentials', - '• JWT signing key and project encryption key', + '• JWT signing key and project encryption key for non-interactive installs', ].join('\n'), ); expect(formatPlatformSetupPlan(plan())).not.toContain('management API token'); diff --git a/packages/void/test/unit/plugin-dev-asset-routing.test.ts b/packages/void/test/unit/plugin-dev-asset-routing.test.ts index 76ee5516f..30b044a7d 100644 --- a/packages/void/test/unit/plugin-dev-asset-routing.test.ts +++ b/packages/void/test/unit/plugin-dev-asset-routing.test.ts @@ -512,6 +512,7 @@ describe('voidPlugin dev asset routing', () => { it('uses the owner Cloudflare account for Workers AI during direct local development', async () => { vi.stubEnv('CLOUDFLARE_API_TOKEN', 'cloudflare-token'); + vi.stubEnv('CLOUDFLARE_CF_AUTH', undefined); vi.stubEnv('CLOUDFLARE_AUTH_USE_KEYRING', 'false'); vi.stubEnv('VOID_TOKEN', 'void-token-that-must-not-be-used'); const config = await resolveCloudflareConfig(createAiProjectRoot('cloudflare')); @@ -523,9 +524,25 @@ describe('voidPlugin dev asset routing', () => { __VOID_PROXY_URL: expect.anything(), }); expect(process.env.CLOUDFLARE_API_TOKEN).toBe('cloudflare-token'); + expect(process.env.CLOUDFLARE_CF_AUTH).toBe('true'); expect(process.env.CLOUDFLARE_AUTH_USE_KEYRING).toBe('true'); }); + it.each([ + ['preview', { command: 'serve', mode: 'production', isPreview: true }], + ['build', { command: 'build', mode: 'production' }], + ] as const)( + 'uses the Void Cloudflare login when a direct %s opens a remote session', + async (_label, configEnv) => { + vi.stubEnv('CLOUDFLARE_CF_AUTH', undefined); + vi.stubEnv('CLOUDFLARE_AUTH_USE_KEYRING', undefined); + await resolveCloudflareConfig(createAiProjectRoot('cloudflare'), {}, configEnv); + + expect(process.env.CLOUDFLARE_CF_AUTH).toBe('true'); + expect(process.env.CLOUDFLARE_AUTH_USE_KEYRING).toBe('true'); + }, + ); + it('passes schema-declared shell values to the dev Worker without copying unrelated shell values', async () => { const root = createProjectRoot(); writeFileSync(join(root, 'env.ts'), 'export default {};\n'); @@ -589,6 +606,7 @@ describe('voidPlugin dev asset routing', () => { }); it('enables remote mode on an explicit Workers AI binding during direct development', async () => { + vi.stubEnv('CLOUDFLARE_CF_AUTH', undefined); vi.stubEnv('CLOUDFLARE_AUTH_USE_KEYRING', 'false'); const config = await resolveCloudflareConfig(createAiProjectRoot('cloudflare'), { ai: { binding: 'AI', staging: true }, @@ -598,6 +616,8 @@ describe('voidPlugin dev asset routing', () => { }); it('keeps managed Workers AI development on the Void proxy', async () => { + vi.stubEnv('CLOUDFLARE_CF_AUTH', undefined); + vi.stubEnv('CLOUDFLARE_AUTH_USE_KEYRING', undefined); vi.stubEnv('VOID_TOKEN', 'managed-token'); const root = createAiProjectRoot('void'); vi.stubEnv('VOID_API_URL', 'https://platform.example.test'); @@ -628,16 +648,20 @@ describe('voidPlugin dev asset routing', () => { __VOID_PROJECT_ID: 'managed-project', __VOID_PROXY_URL: 'https://proxy.example.test', }); + expect(process.env.CLOUDFLARE_CF_AUTH).toBeUndefined(); + expect(process.env.CLOUDFLARE_AUTH_USE_KEYRING).toBeUndefined(); }); - it('does not enable remote AI or Cloudflare auth for a direct non-AI app', async () => { + it('adds no remote AI to a direct non-AI app but keeps its remote bindings on the Void login', async () => { + vi.stubEnv('CLOUDFLARE_CF_AUTH', undefined); vi.stubEnv('CLOUDFLARE_AUTH_USE_KEYRING', 'false'); const root = createApiOnlyProjectRoot(); saveDeploymentPlatform(root, 'cloudflare'); const config = await resolveCloudflareConfig(root); expect(config.ai).toBeUndefined(); - expect(process.env.CLOUDFLARE_AUTH_USE_KEYRING).toBe('false'); + expect(process.env.CLOUDFLARE_CF_AUTH).toBe('true'); + expect(process.env.CLOUDFLARE_AUTH_USE_KEYRING).toBe('true'); }); it('passes worker.limits.cpu_ms from void.json into the generated worker config', async () => { diff --git a/packages/void/test/unit/plugin-framework-ai.test.ts b/packages/void/test/unit/plugin-framework-ai.test.ts index 7f392779d..a9708cb62 100644 --- a/packages/void/test/unit/plugin-framework-ai.test.ts +++ b/packages/void/test/unit/plugin-framework-ai.test.ts @@ -4,6 +4,9 @@ import { join } from 'node:path'; import type { ResolvedConfig } from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +// Framework adapters own the Cloudflare runtime, so these hooks never call the real plugin. +vi.mock('@cloudflare/vite-plugin', () => import('../helpers/cloudflare-vite-plugin.ts')); + const roots: Array = []; function project( @@ -67,20 +70,36 @@ async function runFrameworkSync(root: string): Promise> return JSON.parse(readFileSync(join(root, 'wrangler.jsonc'), 'utf8')); } +async function runFrameworkCompat(root: string, command: 'serve' | 'build'): Promise { + vi.spyOn(process, 'cwd').mockReturnValue(root); + const { voidPlugin } = await import('../../src/index.ts'); + const compat = voidPlugin().find((plugin) => plugin.name === 'void:wrangler-compat'); + const hook = compat?.config; + const handler = typeof hook === 'function' ? hook : hook?.handler; + if (!handler) { + throw new Error('Expected framework Wrangler compatibility hook'); + } + handler.call({} as never, {} as never, { command } as never); +} + describe('framework Workers AI bindings', () => { - it('selects the Cloudflare keychain before framework development starts', async () => { - const root = project('cloudflare'); - vi.spyOn(process, 'cwd').mockReturnValue(root); + it.each(['serve', 'build'] as const)( + 'uses the Void Cloudflare login before a direct framework %s opens a remote session', + async (command) => { + vi.stubEnv('CLOUDFLARE_CF_AUTH', undefined); + vi.stubEnv('CLOUDFLARE_AUTH_USE_KEYRING', 'false'); + await runFrameworkCompat(project('cloudflare'), command); + expect(process.env.CLOUDFLARE_CF_AUTH).toBe('true'); + expect(process.env.CLOUDFLARE_AUTH_USE_KEYRING).toBe('true'); + }, + ); + + it('leaves Cloudflare login selection alone for managed framework development', async () => { + vi.stubEnv('CLOUDFLARE_CF_AUTH', undefined); vi.stubEnv('CLOUDFLARE_AUTH_USE_KEYRING', 'false'); - const { voidPlugin } = await import('../../src/index.ts'); - const compat = voidPlugin().find((plugin) => plugin.name === 'void:wrangler-compat'); - const hook = compat?.config; - const handler = typeof hook === 'function' ? hook : hook?.handler; - if (!handler) { - throw new Error('Expected framework Wrangler compatibility hook'); - } - handler.call({} as never, {} as never, { command: 'serve' } as never); - expect(process.env.CLOUDFLARE_AUTH_USE_KEYRING).toBe('true'); + await runFrameworkCompat(project('void'), 'serve'); + expect(process.env.CLOUDFLARE_CF_AUTH).toBeUndefined(); + expect(process.env.CLOUDFLARE_AUTH_USE_KEYRING).toBe('false'); }); it('adds remote AI for a directly connected SvelteKit project', async () => { diff --git a/packages/void/test/unit/provision-resources.test.ts b/packages/void/test/unit/provision-resources.test.ts index d8ff104ac..2e58e9fd8 100644 --- a/packages/void/test/unit/provision-resources.test.ts +++ b/packages/void/test/unit/provision-resources.test.ts @@ -242,6 +242,26 @@ describe('provisionCloudflareResources — read-first', () => { ]); }); + it('points to the Void Cloudflare login when replication has no bearer', async () => { + setup( + JSON.stringify({ + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: uuid('db1') }], + }), + ); + vi.stubEnv('CLOUDFLARE_API_TOKEN', undefined); + const { run } = makeRunner([{ match: /^auth token/, reply: () => fail('', 'Not logged in') }]); + const error = await provisionCloudflareResourcesImpl( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ).catch((e: unknown) => e as Error); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain('Run `void cloudflare login`'); + expect((error as Error).message).not.toContain('wrangler login'); + }); + it('does NOT treat a placeholder database_id ("local") as real — proceeds to provision', async () => { setup( JSON.stringify({ diff --git a/platform/packages/api/README.md b/platform/packages/api/README.md index 5835da8b5..65745adfa 100644 --- a/platform/packages/api/README.md +++ b/platform/packages/api/README.md @@ -388,6 +388,14 @@ receipts make upgrade/retry calls preserve administrator changes. Public-only ru use scoped bypass applications in Default-Deny accounts; their custom-domain rules are managed with the same project reconciliation and ownership checks. +Uninstall calls `/internal/platform/app-access/turn-off` with the same probe proof +and setup secret under its own HMAC purpose. It runs the normal Zero Trust disable +only while no platform user exists, so an installation whose first administrator +never signed in can still be removed. First administrator setup does not wait +for it, so the route reports a user who appeared during the disable, and the CLI +then stops the uninstall. The CLI calls it before it takes the Zero Trust lock, +because the disable claims the same lock. + The browser uses `POST /admin/fate` for administrator page data. It runs under the existing admin-cookie and same-origin middleware, while the CLI/operator API stays on `/operator/v1`. First loads hydrate a request-scoped Fate snapshot into diff --git a/platform/packages/api/src/admin/react/views/LoginErrorPage.tsx b/platform/packages/api/src/admin/react/views/LoginErrorPage.tsx index 0aa2e4cf8..dea3843c3 100644 --- a/platform/packages/api/src/admin/react/views/LoginErrorPage.tsx +++ b/platform/packages/api/src/admin/react/views/LoginErrorPage.tsx @@ -3,9 +3,11 @@ import { AuthPage } from './AuthPage.tsx'; export function LoginErrorPage({ message, + details = [], platformName, }: { message: string; + details?: ReadonlyArray; platformName: string; }) { return ( @@ -17,6 +19,11 @@ export function LoginErrorPage({

Login failed

{message}

+ {details.map((detail) => ( +

+ {detail} +

+ ))} Start a new sign-in diff --git a/platform/packages/api/src/admin/routes/login.ts b/platform/packages/api/src/admin/routes/login.ts index 19a9d95e3..bceeb17be 100644 --- a/platform/packages/api/src/admin/routes/login.ts +++ b/platform/packages/api/src/admin/routes/login.ts @@ -10,6 +10,7 @@ import { } from '../../auth/google.ts'; import { hmacSignState, hmacVerifyState } from '../../auth/hmac.ts'; import { signJwt } from '../../auth/jwt.ts'; +import { LoginFailure, loginFailureHint } from '../../auth/login-failure.ts'; import { startPlatformLogin } from '../../auth/login.ts'; import { getPlatformCapabilities } from '../../capabilities.ts'; import { @@ -35,6 +36,7 @@ function loginFailure( context: Context, message: string, status: 400 | 404 | 501, + details: ReadonlyArray = [], ): Promise { return renderAdminPage( context, @@ -42,6 +44,7 @@ function loginFailure( 'Login failed', { message, + details, platformName: getPlatformDisplayName(context.env, context.req.url), }, { status }, @@ -188,12 +191,26 @@ export async function handleAdminOAuthCallback(context: Context): Promis let email: string | null; let emailVerified: boolean; if (provider === 'github') { - const token = await exchangeCodeForToken( - context.env.GITHUB_CLIENT_ID, - context.env.GITHUB_CLIENT_SECRET, - code, - ); - const githubUser = await getGitHubUser(token); + let githubUser: Awaited>; + try { + const token = await exchangeCodeForToken( + context.env.GITHUB_CLIENT_ID, + context.env.GITHUB_CLIENT_SECRET, + code, + ); + githubUser = await getGitHubUser(token); + } catch (error) { + if (!(error instanceof LoginFailure)) { + throw error; + } + // Log only the failure code, never the provider response. + console.error('[auth] login failed', { code: error.code, purpose: 'admin', provider }); + const hint = loginFailureHint(error.code); + return loginFailure(context, hint.message, 400, [ + `Error code: ${error.code}`, + ...(hint.callback ? [`Callback URL: ${redirectUri}`] : []), + ]); + } providerId = String(githubUser.id); login = githubUser.login; email = githubUser.email; diff --git a/platform/packages/api/src/auth/github.ts b/platform/packages/api/src/auth/github.ts index 2a74f979e..18c872233 100644 --- a/platform/packages/api/src/auth/github.ts +++ b/platform/packages/api/src/auth/github.ts @@ -1,3 +1,13 @@ +import { LoginFailure, type LoginFailureCode } from './login-failure.ts'; + +/** GitHub token errors with a fix of their own; any other error is github_failed. */ +const GITHUB_TOKEN_ERRORS = new Set([ + 'incorrect_client_credentials', + 'redirect_uri_mismatch', + 'bad_verification_code', + 'unverified_user_email', +]); + export type GitHubUser = Readonly<{ id: number; login: string; @@ -42,12 +52,17 @@ export async function exchangeCodeForToken( }); if (!res.ok) { - throw new Error(`GitHub token exchange failed: ${res.status}`); + throw new LoginFailure('github_failed', `GitHub token exchange failed: ${res.status}`); } const data = (await res.json()) as { access_token?: string; error?: string }; if (data.error || !data.access_token) { - throw new Error(`GitHub token exchange error: ${data.error ?? 'no access_token'}`); + throw new LoginFailure( + data.error && GITHUB_TOKEN_ERRORS.has(data.error) + ? (`github_${data.error}` as LoginFailureCode) + : 'github_failed', + `GitHub token exchange error: ${data.error ?? 'no access_token'}`, + ); } return data.access_token; @@ -72,7 +87,7 @@ export async function getGitHubUser(accessToken: string): Promise { ]); if (!userRes.ok) { - throw new Error(`GitHub user fetch failed: ${userRes.status}`); + throw new LoginFailure('github_failed', `GitHub user fetch failed: ${userRes.status}`); } const user = (await userRes.json()) as { id: number; login: string; email: string | null }; diff --git a/platform/packages/api/src/auth/login-failure.ts b/platform/packages/api/src/auth/login-failure.ts new file mode 100644 index 000000000..54912252e --- /dev/null +++ b/platform/packages/api/src/auth/login-failure.ts @@ -0,0 +1,77 @@ +/** + * Login failures whose cause is known. A code is a short fixed name, never a + * provider response body, token, or secret, so it is safe to log and to show + * on the failure page. + */ +export type LoginFailureCode = + | 'login_denied' + | 'github_incorrect_client_credentials' + | 'github_redirect_uri_mismatch' + | 'github_bad_verification_code' + | 'github_unverified_user_email' + | 'github_failed' + | 'oidc_discovery_failed' + | 'oidc_token_failed' + | 'google_domain_not_allowed' + | 'oidc_group_not_allowed' + | 'login_secret_unreadable'; + +export class LoginFailure extends Error { + constructor( + readonly code: LoginFailureCode, + message: string = code, + ) { + super(message); + this.name = 'LoginFailure'; + } +} + +const hints: Record> = { + login_denied: { + message: 'Sign-in was cancelled or denied at the provider. Start a new sign-in to try again.', + }, + github_incorrect_client_credentials: { + message: 'GitHub rejected this platform’s client ID or client secret.', + callback: true, + }, + github_redirect_uri_mismatch: { + message: + 'GitHub rejected the callback URL. Register exactly the callback URL below in the OAuth app, then start a new sign-in.', + callback: true, + }, + github_bad_verification_code: { + message: 'The GitHub sign-in code expired or was already used. Start a new sign-in.', + }, + github_unverified_user_email: { + message: 'Verify the primary email of this GitHub account, then start a new sign-in.', + }, + github_failed: { + message: + 'GitHub did not complete the sign-in. Start a new sign-in; if it fails again, check the client ID, client secret, and callback URL.', + callback: true, + }, + oidc_discovery_failed: { + message: + 'The issuer URL did not return a matching OpenID configuration. Check it exactly, including any trailing slash.', + }, + oidc_token_failed: { + message: + 'The provider refused the token request. Check the client ID and client secret, and that the callback URL below is registered.', + callback: true, + }, + google_domain_not_allowed: { + message: 'This Google account does not belong to an allowed Workspace domain.', + }, + oidc_group_not_allowed: { message: 'This identity does not belong to an allowed group.' }, + login_secret_unreadable: { + message: 'The saved client secret cannot be read with this installation’s encryption keys.', + }, +}; + +/** What to tell the user, and whether the provider's registered callback URL may be the cause. */ +export function loginFailureHint(code: LoginFailureCode): Readonly<{ + message: string; + callback: boolean; +}> { + return { message: hints[code].message, callback: hints[code].callback === true }; +} diff --git a/platform/packages/api/src/auth/login.ts b/platform/packages/api/src/auth/login.ts index b3fcbe8d8..db65fe4c3 100644 --- a/platform/packages/api/src/auth/login.ts +++ b/platform/packages/api/src/auth/login.ts @@ -29,6 +29,7 @@ import { import { exchangeCodeForToken, getGitHubAuthorizeUrl, getGitHubUser } from './github.ts'; import { resolvePlatformLogin } from './identities.ts'; import { verifyJwt } from './jwt.ts'; +import { LoginFailure, loginFailureHint } from './login-failure.ts'; import { linkTestedIdentity, requireRecentAuthentication, @@ -73,7 +74,57 @@ function oidcProvider(configuration: PlatformLoginConfiguration): PlatformOidcPr return provider; } -function authenticationError(context: Context, error: unknown): Response { +/** What is known about the login attempt when it failed. */ +type LoginAttemptDetails = { purpose?: string; connectionId?: string; redirectUri?: string }; + +function authenticationError( + context: Context, + error: unknown, + attempt: LoginAttemptDetails = {}, +): Response { + // Provider responses may contain secrets or untrusted data. Log only the + // failure code, never provider bodies, tokens, or error messages. + const code = + error instanceof AuthenticationConfigurationError || error instanceof LoginFailure + ? error.code + : 'unexpected'; + console.error('[auth] login failed', { + code, + ...(code === 'unexpected' && error instanceof Error ? { name: error.name } : {}), + ...(attempt.purpose ? { purpose: attempt.purpose } : {}), + ...(attempt.connectionId ? { connectionId: attempt.connectionId } : {}), + }); + if (error instanceof LoginFailure) { + const hint = loginFailureHint(error.code); + if (!acceptsBrowserHtml(context)) { + return context.json({ error: 'login_failed', code: error.code, message: hint.message }, 400); + } + const bootstrap = attempt.purpose === 'bootstrap'; + const groups = error.code === 'oidc_group_not_allowed'; + return platformBrowserError(context, 'Login failed', hint.message, 400, { + details: [ + `Error code: ${error.code}`, + ...(hint.callback + ? [ + `Callback URL: ${attempt.redirectUri ?? context.env.AUTH_REDIRECT_URI ?? new URL('/auth/callback', context.req.url).href}`, + ] + : []), + ...(bootstrap + ? [ + groups + ? 'To change the allowed groups, run this on the computer that installed the platform, with an authentication file:' + : 'To change the login settings, run this on the computer that installed the platform:', + ] + : []), + ], + ...(bootstrap + ? { + command: `void platform repair ${context.env.PLATFORM_INSTALLATION_ID ?? ''}${groups ? ' --auth-config ' : ''}`, + returnUrl: '/setup', + } + : {}), + }); + } if (error instanceof AuthenticationConfigurationError) { if (acceptsBrowserHtml(context)) { return platformBrowserError( @@ -88,7 +139,6 @@ function authenticationError(context: Context, error: unknown): Response error.status as ContentfulStatusCode, ); } - // Provider responses may contain secrets or untrusted data. Do not echo or log them. if (acceptsBrowserHtml(context)) { return platformBrowserError( context, @@ -440,6 +490,7 @@ export async function startPlatformLogin( export async function completePlatformLogin(context: Context): Promise { const db = context.get('db'); + let details: LoginAttemptDetails = {}; try { const state = context.req.query('state'); const browserProof = getCookie(context, COOKIE); @@ -476,6 +527,11 @@ export async function completePlatformLogin(context: Context): Promise): Promise { + throw new LoginFailure('login_secret_unreadable'); + }); let identity: PlatformOidcIdentity; if (connection.kind === 'github') { const code = context.req.query('code'); - if (!code || context.req.query('error')) { - throw new Error('GitHub login failed'); + const providerError = context.req.query('error'); + if (!code || providerError) { + throw new LoginFailure( + providerError === 'access_denied' + ? 'login_denied' + : providerError === 'redirect_uri_mismatch' + ? 'github_redirect_uri_mismatch' + : 'github_failed', + 'GitHub login failed', + ); } const token = await exchangeCodeForToken( revision.configuration.clientId, @@ -750,7 +816,7 @@ export async function completePlatformLogin(context: Context): Promise { + try { + return await this.discover(); + } catch (error) { + throw new LoginFailure( + 'oidc_discovery_failed', + error instanceof Error ? error.message : undefined, + ); + } + } + private client(server: oauth.AuthorizationServer): oauth.Client { const supported = server.id_token_signing_alg_values_supported ?? ['RS256']; const algorithm = ['RS256', 'ES256', 'PS256', 'EdDSA'].find((value) => @@ -99,7 +112,7 @@ export class PlatformOidcProvider { redirectUri: string, state: string, ): Promise<{ url: string; verifier: string; nonce: string }> { - const server = await this.discover(); + const server = await this.discoverForLogin(); const verifier = oauth.generateRandomCodeVerifier(); const nonce = oauth.generateRandomNonce(); const url = new URL(server.authorization_endpoint!); @@ -130,39 +143,47 @@ export class PlatformOidcProvider { nonce: string; clientSecret: string; }): Promise { - const server = await this.discover(); + const server = await this.discoverForLogin(); const client = this.client(server); - const params = oauth.validateAuthResponse(server, client, input.callback, input.state); - const methods = server.token_endpoint_auth_methods_supported ?? ['client_secret_basic']; - const auth = methods.includes('client_secret_basic') - ? oauth.ClientSecretBasic(input.clientSecret) - : methods.includes('client_secret_post') - ? oauth.ClientSecretPost(input.clientSecret) - : null; - if (!auth) { - throw new Error('The identity provider must support client-secret authentication.'); - } - const response = await oauth.authorizationCodeGrantRequest( - server, - client, - auth, - params, - input.redirectUri, - input.verifier, - { [oauth.customFetch]: this.network }, - ); - const tokens = await oauth.processAuthorizationCodeResponse(server, client, response, { - expectedNonce: input.nonce, - requireIdToken: true, - }); - await oauth.validateApplicationLevelSignature(server, response, { - [oauth.customFetch]: this.network, - }); - const claims = oauth.getValidatedIdTokenClaims(tokens)!; - if (typeof claims.sub !== 'string' || claims.sub.length === 0 || claims.sub.length > 255) { - throw new Error( - 'The identity provider must return a nonempty subject of at most 255 characters.', + let claims: oauth.IDToken; + try { + const params = oauth.validateAuthResponse(server, client, input.callback, input.state); + const methods = server.token_endpoint_auth_methods_supported ?? ['client_secret_basic']; + const auth = methods.includes('client_secret_basic') + ? oauth.ClientSecretBasic(input.clientSecret) + : methods.includes('client_secret_post') + ? oauth.ClientSecretPost(input.clientSecret) + : null; + if (!auth) { + throw new Error('The identity provider must support client-secret authentication.'); + } + const response = await oauth.authorizationCodeGrantRequest( + server, + client, + auth, + params, + input.redirectUri, + input.verifier, + { [oauth.customFetch]: this.network }, ); + const tokens = await oauth.processAuthorizationCodeResponse(server, client, response, { + expectedNonce: input.nonce, + requireIdToken: true, + }); + await oauth.validateApplicationLevelSignature(server, response, { + [oauth.customFetch]: this.network, + }); + claims = oauth.getValidatedIdTokenClaims(tokens)!; + if (typeof claims.sub !== 'string' || claims.sub.length === 0 || claims.sub.length > 255) { + throw new Error( + 'The identity provider must return a nonempty subject of at most 255 characters.', + ); + } + } catch (error) { + // One code for the whole token step: the provider's error body is never read. + throw error instanceof oauth.AuthorizationResponseError && error.error === 'access_denied' + ? new LoginFailure('login_denied', error.message) + : new LoginFailure('oidc_token_failed', error instanceof Error ? error.message : undefined); } const groups = Array.isArray(claims.groups) && claims.groups.every((group) => typeof group === 'string') @@ -173,13 +194,19 @@ export class PlatformOidcProvider { (typeof claims.hd !== 'string' || !this.configuration.allowedDomains.includes(claims.hd.toLowerCase())) ) { - throw new Error('This Google account does not belong to an allowed Workspace domain.'); + throw new LoginFailure( + 'google_domain_not_allowed', + 'This Google account does not belong to an allowed Workspace domain.', + ); } if ( this.configuration.requiredGroups.length && !this.configuration.requiredGroups.some((group) => groups.includes(group)) ) { - throw new Error('This identity does not belong to an allowed group.'); + throw new LoginFailure( + 'oidc_group_not_allowed', + 'This identity does not belong to an allowed group.', + ); } const email = typeof claims.email === 'string' && claims.email.length <= 320 ? claims.email : null; diff --git a/platform/packages/api/src/browser-page.ts b/platform/packages/api/src/browser-page.ts index 0f955edf2..3ffb6e7e1 100644 --- a/platform/packages/api/src/browser-page.ts +++ b/platform/packages/api/src/browser-page.ts @@ -30,11 +30,21 @@ export function platformBrowserError( title: string, message: string, status: ContentfulStatusCode, + extra: Readonly<{ + /** Plain-text lines shown below the message. */ + details?: ReadonlyArray; + /** A command the reader runs to fix the problem. */ + command?: string; + /** Where "Start a new sign-in" leads instead of the usual page. */ + returnUrl?: string; + }> = {}, ): Response { context.header('Cache-Control', 'no-store'); context.header('Referrer-Policy', 'no-referrer'); let returnUrl: string | undefined; - if (context.req.path.startsWith('/auth/bootstrap') || context.req.path === '/setup') { + if (extra.returnUrl) { + returnUrl = extra.returnUrl; + } else if (context.req.path.startsWith('/auth/bootstrap') || context.req.path === '/setup') { returnUrl = '/setup'; } else if (context.env.DASHBOARD_URL) { try { @@ -51,10 +61,16 @@ export function platformBrowserError( escapeBrowserHtml(returnUrl) + '">Start a new sign-in' : ''; + const details = (extra.details ?? []) + .map((detail) => '

' + escapeBrowserHtml(detail) + '

') + .join(''); + const command = extra.command + ? '
' + escapeBrowserHtml(extra.command) + '
' + : ''; return platformBrowserPage( context, title, - browserPageStatus('error') + browserPageLead(title, message) + action, + browserPageStatus('error') + browserPageLead(title, message) + details + command + action, status, ); } diff --git a/platform/packages/api/src/index.ts b/platform/packages/api/src/index.ts index 379098821..b982912b9 100644 --- a/platform/packages/api/src/index.ts +++ b/platform/packages/api/src/index.ts @@ -82,7 +82,7 @@ import { } from './onboarding-pages.ts'; import { operatorApi } from './operator/index.ts'; import { sandboxDrainStatus } from './operator/sandbox-operations.ts'; -import { platformAppAccess } from './platform-app-access.ts'; +import { platformAppAccess, platformAppAccessTurnOff } from './platform-app-access.ts'; import { platformDomainActivation } from './platform-domain-activation.ts'; import { platformDomainPreflight } from './platform-domain-preflight.ts'; import { getPlatformApiUrl, getPlatformDisplayName } from './platform-identity.ts'; @@ -228,6 +228,8 @@ const app = new Hono() (context.req.path === '/internal/platform/email-bootstrap' && context.req.method === 'POST') || (context.req.path === '/internal/platform/app-access' && context.req.method === 'POST') || + (context.req.path === '/internal/platform/app-access/turn-off' && + context.req.method === 'POST') || (context.req.path === '/internal/platform/domain-preflight' && context.req.method === 'POST') || (context.req.path === '/internal/platform/domain-activate' && @@ -399,6 +401,7 @@ const app = new Hono() ); }) .post('/internal/platform/app-access', platformAppAccess) + .post('/internal/platform/app-access/turn-off', platformAppAccessTurnOff) .post('/internal/platform/domain-preflight', platformDomainPreflight) .post('/internal/platform/domain-activate', platformDomainActivation) .post('/internal/platform/email-bootstrap', async (context) => { diff --git a/platform/packages/api/src/middleware/platform-access.ts b/platform/packages/api/src/middleware/platform-access.ts index df8b11339..382414da9 100644 --- a/platform/packages/api/src/middleware/platform-access.ts +++ b/platform/packages/api/src/middleware/platform-access.ts @@ -77,8 +77,9 @@ export const platformAccessProtection: MiddlewareHandler = async (contex (path === '/internal/platform/sandbox-drain' && method === 'GET') || (path === '/__void/platform/deployment-execution' && method === 'GET') || (path === '/internal/platform/email-bootstrap' && method === 'POST') || - // The handler also requires its installation-specific setup HMAC. + // These handlers also require their installation-specific setup HMAC. (path === '/internal/platform/app-access' && method === 'POST') || + (path === '/internal/platform/app-access/turn-off' && method === 'POST') || (path === '/internal/platform/domain-preflight' && method === 'POST') || (path === '/internal/platform/domain-activate' && method === 'POST'))) ) { diff --git a/platform/packages/api/src/platform-app-access.ts b/platform/packages/api/src/platform-app-access.ts index 2fd779478..22cbb550b 100644 --- a/platform/packages/api/src/platform-app-access.ts +++ b/platform/packages/api/src/platform-app-access.ts @@ -1,6 +1,7 @@ import { parsePlatformAppAccessSetup, PLATFORM_APP_ACCESS_SETUP_PROOF_PREFIX, + PLATFORM_APP_ACCESS_TURN_OFF_PROOF_PREFIX, PLATFORM_DISABLED_KEY, PLATFORM_DISABLED_VALUE, type PlatformAppAccessSetup, @@ -10,13 +11,14 @@ import type { Context } from 'hono'; import type { Db } from './db.ts'; import type { ApiEnv } from './env.ts'; import { getPlatformSetting, setPlatformSetting } from './platform-settings.ts'; +import { users } from './schema.ts'; import { ZeroTrustCloudflareError } from './zero-trust/cloudflare.ts'; import { readZeroTrustConfigurationStrict, type ZeroTrustConfiguration, } from './zero-trust/configuration.ts'; import { ZeroTrustUnavailableError } from './zero-trust/errors.ts'; -import { configurePlatformZeroTrust } from './zero-trust/installation.ts'; +import { configurePlatformZeroTrust, disablePlatformZeroTrust } from './zero-trust/installation.ts'; import { lockZeroTrustConfiguration } from './zero-trust/lock.ts'; import { zeroTrustStatus } from './zero-trust/status.ts'; @@ -171,7 +173,11 @@ export async function initializePlatformAppAccess( return { ready: true }; } -export async function platformAppAccess(context: Context): Promise { +/** The signed request body, or null when the installer probe or setup proof does not match. */ +async function verifyAppAccessProof( + context: Context, + prefix: string, +): Promise { const token = context.env.PLATFORM_HEALTH_PROBE_TOKEN; if ( !token || @@ -179,12 +185,12 @@ export async function platformAppAccess(context: Context): Promise): Promise byte.charCodeAt(0), ); - if ( - !(await crypto.subtle.verify( - 'HMAC', - key, - proof, - new TextEncoder().encode( - `${PLATFORM_APP_ACCESS_SETUP_PROOF_PREFIX}\n${context.env.PLATFORM_INSTALLATION_ID}\n${body}`, - ), - )) - ) { + const verified = await crypto.subtle.verify( + 'HMAC', + key, + proof, + new TextEncoder().encode(`${prefix}\n${context.env.PLATFORM_INSTALLATION_ID}\n${body}`), + ); + return verified ? body : null; +} + +export async function platformAppAccess(context: Context): Promise { + const body = await verifyAppAccessProof(context, PLATFORM_APP_ACCESS_SETUP_PROOF_PREFIX); + if (body === null) { return context.json({ error: 'not found' }, 404); } let setup: PlatformAppAccessSetup; @@ -254,3 +262,67 @@ export async function platformAppAccess(context: Context): Promise): Promise { + const body = await verifyAppAccessProof(context, PLATFORM_APP_ACCESS_TURN_OFF_PROOF_PREFIX); + if (body === null) { + return context.json({ error: 'not found' }, 404); + } + let input: unknown; + try { + input = JSON.parse(body); + } catch { + input = null; + } + if (!input || typeof input !== 'object' || (input as { version?: unknown }).version !== 1) { + return context.json({ error: 'Invalid app access request.' }, 400); + } + const db = context.get('db'); + try { + const [user] = await db.select({ id: users.id }).from(users).limit(1); + if (user) { + return context.json( + { + error: + 'A platform user exists. An administrator turns off Zero Trust with `void platform zero-trust disable`.', + code: 'users_exist', + }, + 409, + ); + } + const configuration = await disablePlatformZeroTrust(context.env, db); + // First administrator setup does not wait for this route. Report a user + // who signed in meanwhile, so the uninstall stops instead of continuing. + const [signedIn] = await db.select({ id: users.id }).from(users).limit(1); + return context.json({ + installationId: context.env.PLATFORM_INSTALLATION_ID, + turnedOff: + !configuration.enabled && !('operation' in configuration && configuration.operation), + ...(signedIn ? { usersExist: true } : {}), + }); + } catch (error) { + console.error('[platform] app access turn-off failed', error); + if (error instanceof ZeroTrustCloudflareError && [401, 403].includes(error.status)) { + return context.json( + { + error: 'Cloudflare rejected the saved app access token.', + code: 'app_access_credentials', + }, + 403, + ); + } + if (error instanceof ZeroTrustUnavailableError) { + return context.json({ error: error.message }, 409); + } + return context.json( + { error: 'Project Zero Trust could not be turned off. Retry the uninstall.' }, + 503, + ); + } +} diff --git a/platform/packages/api/src/zero-trust/cloudflare.ts b/platform/packages/api/src/zero-trust/cloudflare.ts index 0d4ff161e..757009afc 100644 --- a/platform/packages/api/src/zero-trust/cloudflare.ts +++ b/platform/packages/api/src/zero-trust/cloudflare.ts @@ -1,3 +1,4 @@ +import { isPlatformAccessIdentityRule } from '@void/platform'; import type { ManagedAccessApplication } from './configuration.ts'; type ObjectValue = Record; @@ -32,6 +33,13 @@ function text(value: unknown, label: string): string { return value; } +/** + * Saved selections are checked again on every reconcile and domain change, so + * project Zero Trust keeps accepting every login method rule it accepted before. + * A stricter check here would stop running installations. + */ +const anyLoginMethod = (): boolean => true; + function destinationList(application: ObjectValue): ReadonlyArray { const destinations = Array.isArray(application.destinations) ? application.destinations : []; return [ @@ -45,42 +53,6 @@ function destinationList(application: ObjectValue): ReadonlyArray { ].filter((value, index, all) => all.indexOf(value) === index); } -/** - * A rule that names company identities. The CLI checks the Access policies - * that protect the platform installation itself with a separate copy in - * packages/void/src/cli/platform-access-provision.ts, because the public CLI - * cannot import platform code. This list also accepts azureAD, login_method - * and auth_context rules; review both when adding a rule type. - */ -function identityRule(rule: ObjectValue): boolean { - if (rule.email_domain) { - const domain = object(rule.email_domain, 'email domain').domain; - return typeof domain === 'string' && /^(?:[a-z0-9-]+\.)+[a-z]{2,63}$/i.test(domain); - } - if (rule.email) { - const email = object(rule.email, 'email rule').email; - return typeof email === 'string' && /^[^\s*@]+@[^\s*@]+\.[^\s*@]+$/.test(email); - } - return [ - 'email_list', - 'gsuite', - 'github-organization', - 'saml', - 'okta', - 'azureAD', - 'login_method', - 'auth_context', - 'group', - ].some( - (key) => - Object.hasOwn(rule, key) && - !!rule[key] && - typeof rule[key] === 'object' && - !Array.isArray(rule[key]) && - Object.keys(rule[key] as object).length > 0, - ); -} - type AccessApplicationSpec = Readonly<{ name: string; destinations: ReadonlyArray; @@ -274,10 +246,14 @@ export class ZeroTrustCloudflareClient { } const groupHasRequiredIdentity = Array.isArray(group.require) && - group.require.some((entry) => identityRule(object(entry, 'Access group rule'))); + group.require.some((entry) => + isPlatformAccessIdentityRule(object(entry, 'Access group rule'), anyLoginMethod), + ); if ( !groupHasRequiredIdentity && - !group.include.every((entry) => identityRule(object(entry, 'Access group rule'))) + !group.include.every((entry) => + isPlatformAccessIdentityRule(object(entry, 'Access group rule'), anyLoginMethod), + ) ) { throw new Error('Access groups must restrict company identities.'); } @@ -300,12 +276,16 @@ export class ZeroTrustCloudflareClient { await checkRules(policy.require); const hasRequiredIdentity = Array.isArray(policy.require) && - policy.require.some((entry) => identityRule(object(entry, 'Access policy rule'))); + policy.require.some((entry) => + isPlatformAccessIdentityRule(object(entry, 'Access policy rule'), anyLoginMethod), + ); if ( !Array.isArray(policy.include) || policy.include.length < 1 || (!hasRequiredIdentity && - !policy.include.every((entry) => identityRule(object(entry, 'Access policy rule')))) + !policy.include.every((entry) => + isPlatformAccessIdentityRule(object(entry, 'Access policy rule'), anyLoginMethod), + )) ) { throw new Error('Access allow policies must restrict company identities.'); } diff --git a/platform/packages/api/test/integration/admin-dashboard.test.ts b/platform/packages/api/test/integration/admin-dashboard.test.ts index 363d21961..8aafb9e8f 100644 --- a/platform/packages/api/test/integration/admin-dashboard.test.ts +++ b/platform/packages/api/test/integration/admin-dashboard.test.ts @@ -3031,6 +3031,47 @@ describe('GET /admin/login', () => { } }); + test('names the cause when GitHub refuses the admin sign-in', async () => { + const start = await app.fetch( + new Request('http://localhost/admin/login/github', { redirect: 'manual' }), + env, + ); + const state = new URL(start.headers.get('Location')!).searchParams.get('state')!; + const browserCookie = start.headers.get('Set-Cookie')!.split(';', 1)[0]!; + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL | Request): Promise => { + if (input.toString() === 'https://github.com/login/oauth/access_token') { + return Response.json({ error: 'redirect_uri_mismatch' }); + } + throw new Error(`Unexpected fetch: ${input.toString()}`); + }), + ); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const callback = await app.fetch( + new Request( + `http://localhost/auth/callback?code=test-code&state=${encodeURIComponent(state)}`, + { headers: { Cookie: browserCookie }, redirect: 'manual' }, + ), + env, + ); + expect(callback.status).toBe(400); + const html = await callback.text(); + expect(html).toContain('GitHub rejected the callback URL.'); + expect(html).toContain('Error code: github_redirect_uri_mismatch'); + expect(html).toContain('Callback URL: http://localhost/auth/callback'); + expect(errors).toHaveBeenCalledWith('[auth] login failed', { + code: 'github_redirect_uri_mismatch', + purpose: 'admin', + provider: 'github', + }); + } finally { + errors.mockRestore(); + vi.unstubAllGlobals(); + } + }); + test.each([ { reason: 'admin_suspended', expectedStatus: 403, issuesCookie: false }, { reason: 'requests_exceeded', expectedStatus: 302, issuesCookie: true }, diff --git a/platform/packages/api/test/integration/platform-app-access.test.ts b/platform/packages/api/test/integration/platform-app-access.test.ts index 4b9061faa..d4fadb6ae 100644 --- a/platform/packages/api/test/integration/platform-app-access.test.ts +++ b/platform/packages/api/test/integration/platform-app-access.test.ts @@ -2,6 +2,7 @@ import { createHmac } from 'node:crypto'; import { PLATFORM_DISABLED_KEY, PLATFORM_APP_ACCESS_SETUP_PROOF_PREFIX, + PLATFORM_APP_ACCESS_TURN_OFF_PROOF_PREFIX, type PlatformAppAccessSetup, } from '@void/platform'; import { sql } from 'drizzle-orm'; @@ -25,6 +26,7 @@ import { tryActivateDomain } from '../../src/provisioning/activate-domain.ts'; import { domains, platformAuthSettings } from '../../src/schema.ts'; import { readZeroTrustConfigurationStrict } from '../../src/zero-trust/configuration.ts'; import { configurePlatformZeroTrust } from '../../src/zero-trust/installation.ts'; +import * as zeroTrustInstallation from '../../src/zero-trust/installation.ts'; import { initializeNewProjectZeroTrust, reconcileProjectZeroTrustDomains, @@ -106,6 +108,23 @@ function request(body: unknown, token = 'installer-proof') { body: JSON.stringify(body), }); } +function turnOffRequest(options: { prefix?: string; token?: string; body?: string } = {}): Request { + const body = options.body ?? JSON.stringify({ version: 1 }); + const signature = createHmac('sha256', setupKey) + .update( + `${options.prefix ?? PLATFORM_APP_ACCESS_TURN_OFF_PROOF_PREFIX}\n${installationId}\n${body}`, + ) + .digest('base64url'); + return new Request('https://platform.example.test/internal/platform/app-access/turn-off', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Void-Platform-Probe': options.token ?? 'installer-proof', + 'X-Void-App-Access-Setup': signature, + }, + body, + }); +} function accessApi(identity?: { issuer: string; jwk: Record }) { const applications = new Map>(); let next = 0; @@ -204,6 +223,108 @@ function accessApi(identity?: { issuer: string; jwk: Record }) return { applications, fetcher, controls }; } +describe('uninstall before any platform user exists', () => { + // A failed install closes traffic; the installer's probe still reaches the route. + const closed = () => ({ ...bindings(), PLATFORM_TRAFFIC_STATE: 'enabled' }); + + it('turns off installer-managed Zero Trust and deletes its Access applications', async () => { + const cf = accessApi(); + await claim(); + expect((await app.fetch(request(setup()), bindings())).status).toBe(200); + const enabled = (await readZeroTrustConfigurationStrict(env.db))!; + expect(cf.applications.has(enabled.wildcardApplication!.id)).toBe(true); + const response = await app.fetch(turnOffRequest(), closed()); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ installationId, turnedOff: true }); + expect(cf.applications.has(enabled.wildcardApplication!.id)).toBe(false); + expect(await readZeroTrustConfigurationStrict(env.db)).toMatchObject({ + enabled: false, + operation: null, + }); + }); + + it('accepts the installer service identity behind platform Access', async () => { + const issuer = 'https://uninstall-company.cloudflareaccess.com'; + const pair = await generateKeyPair('RS256', { extractable: true }); + accessApi({ + issuer, + jwk: { ...(await exportJWK(pair.publicKey)), alg: 'RS256', kid: 'turn-off-key', use: 'sig' }, + }); + await claim(); + expect((await app.fetch(request(setup()), bindings())).status).toBe(200); + await importLegacyAuthentication(env.db, env); + await env.db.update(platformAuthSettings).set({ + protection: { + teamDomain: issuer, + applications: [{ origin: 'https://platform.example.test', audience: 'installer-api' }], + }, + }); + expect((await app.fetch(turnOffRequest(), closed())).status).toBe(403); + const authorized = turnOffRequest(); + authorized.headers.set( + 'Cf-Access-Jwt-Assertion', + await new SignJWT({ type: 'app', common_name: 'installer.access' }) + .setIssuer(issuer) + .setAudience('installer-api') + .setSubject('') + .setIssuedAt() + .setExpirationTime('5m') + .setProtectedHeader({ alg: 'RS256', kid: 'turn-off-key' }) + .sign(pair.privateKey), + ); + const response = await app.fetch(authorized, closed()); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ installationId, turnedOff: true }); + }); + + it('reports a user who signed in while Zero Trust was turning off', async () => { + accessApi(); + await claim(); + expect((await app.fetch(request(setup()), bindings())).status).toBe(200); + // First administrator setup finishes after the route found no user. + const disable = zeroTrustInstallation.disablePlatformZeroTrust; + vi.spyOn(zeroTrustInstallation, 'disablePlatformZeroTrust').mockImplementationOnce( + async (...args) => { + await createTestUser(env.db, 'first-admin'); + return disable(...args); + }, + ); + const response = await app.fetch(turnOffRequest(), closed()); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ installationId, turnedOff: true, usersExist: true }); + }); + + it('refuses once a platform user exists', async () => { + accessApi(); + await claim(); + expect((await app.fetch(request(setup()), bindings())).status).toBe(200); + await createTestUser(env.db, 'first-user'); + const response = await app.fetch(turnOffRequest(), closed()); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ code: 'users_exist' }); + expect((await readZeroTrustConfigurationStrict(env.db))?.enabled).toBe(true); + }); + + it('accepts only the turn-off proof with the installer probe', async () => { + const cf = accessApi(); + expect( + ( + await app.fetch( + turnOffRequest({ prefix: PLATFORM_APP_ACCESS_SETUP_PROOF_PREFIX }), + closed(), + ) + ).status, + ).toBe(404); + // Without the installer probe the closed platform answers before the route. + expect((await app.fetch(turnOffRequest({ token: 'wrong' }), closed())).status).toBe(503); + expect( + (await app.fetch(turnOffRequest(), { ...closed(), PLATFORM_APP_ACCESS: undefined })).status, + ).toBe(404); + expect((await app.fetch(turnOffRequest({ body: '{"version":2}' }), closed())).status).toBe(400); + expect(cf.fetcher).not.toHaveBeenCalled(); + }); +}); + describe('installer app access handoff', () => { it('initializes behind platform Access with both a scoped service identity and setup authority', async () => { const issuer = 'https://installer-company.cloudflareaccess.com'; diff --git a/platform/packages/api/test/integration/platform-bootstrap.test.ts b/platform/packages/api/test/integration/platform-bootstrap.test.ts index f73cf30ac..a0369f297 100644 --- a/platform/packages/api/test/integration/platform-bootstrap.test.ts +++ b/platform/packages/api/test/integration/platform-bootstrap.test.ts @@ -19,6 +19,8 @@ import { commitAuthenticationRecovery, recoverAuthenticationCredential, } from '../../../../../packages/void/src/cli/platform-auth-recovery-core.ts'; +import { authenticationSetupRequestHash } from '../../../../../packages/void/src/cli/platform-install-auth.ts'; +import { readPlatformAdministratorSetupConnection } from '../../../../../packages/void/src/cli/platform-installer-database.ts'; import { AUTH_SETUP_PROOF_PREFIX, initializeAuthentication } from '../../src/auth/bootstrap.ts'; import { hmacSignState } from '../../src/auth/hmac.ts'; import { signJwt } from '../../src/auth/jwt.ts'; @@ -873,6 +875,71 @@ describe('provider-independent first administrator setup', () => { expect(await (await initialize()).json()).toMatchObject({ setupPending: false }); }); + it('shows the code, callback URL, and repair command when first administrator sign-in fails', async () => { + expect((await initialize()).status).toBe(200); + const start = await form('/auth/bootstrap', { code }); + expect(start.status).toBe(302); + authorization = new URL(start.headers.get('Location')!); + const cookie = start.headers.get('Set-Cookie')!.split(';')[0]!; + const provider = globalThis.fetch; + vi.stubGlobal('fetch', async (input: string | URL | Request, options?: RequestInit) => + input.toString() === `${issuer}/token` + ? Response.json({ error: 'invalid_client' }, { status: 401 }) + : provider(input, options), + ); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + const page = await app.fetch( + new Request( + `${origin}/auth/callback?code=provider-code&state=${authorization.searchParams.get('state')}`, + { headers: { Cookie: cookie, Accept: 'text/html' } }, + ), + bindings(), + ); + expect(page.status).toBe(400); + const html = await page.text(); + expect(html).toContain('The provider refused the token request.'); + expect(html).toContain('Error code: oidc_token_failed'); + expect(html).toContain(`Callback URL: ${origin}/auth/callback`); + expect(html).toContain(`void platform repair ${installationId}`); + expect(html).toContain('href="/setup"'); + expect(errors).toHaveBeenCalledWith('[auth] login failed', { + code: 'oidc_token_failed', + purpose: 'bootstrap', + connectionId: 'company', + }); + expect(JSON.stringify(errors.mock.calls)).not.toContain('oidc-bootstrap-secret'); + errors.mockRestore(); + }); + + it('reads pending administrator setup for the installing CLI', async () => { + const connection = ( + env.db as unknown as { + $client: Parameters[0]; + } + ).$client; + expect(await readPlatformAdministratorSetupConnection(connection)).toEqual({ + pending: false, + usersExist: false, + requestHash: null, + }); + await initialize(); + // The CLI computes the same hash the platform records for these settings. + expect(await readPlatformAdministratorSetupConnection(connection)).toEqual({ + pending: true, + usersExist: false, + requestHash: authenticationSetupRequestHash(JSON.stringify(await input())), + }); + await env.db + .update(platformAuthBootstrap) + .set({ consumed_at: new Date().toISOString() }) + .where(eq(platformAuthBootstrap.singleton, true)); + await createTestUser(env.db, 'first-user'); + expect(await readPlatformAdministratorSetupConnection(connection)).toMatchObject({ + pending: false, + usersExist: true, + }); + }); + it('allows an explicit installer-authorized reset only before an administrator exists', async () => { await initialize(); await env.db diff --git a/platform/packages/api/test/integration/platform-login.test.ts b/platform/packages/api/test/integration/platform-login.test.ts index 6064c4d8f..69a634a06 100644 --- a/platform/packages/api/test/integration/platform-login.test.ts +++ b/platform/packages/api/test/integration/platform-login.test.ts @@ -694,6 +694,59 @@ describe('configured platform login routes', () => { expect((await callback(await start('github'))).status).toBe(302); expect((await env.db.select().from(githubInstallations))[0]?.user_id).toBe(user.id); }); + it('names the cause of a failed GitHub sign-in without logging provider details', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async (request: string | URL | Request) => { + if (request.toString() === 'https://github.com/login/oauth/access_token') { + return Response.json({ + error: 'incorrect_client_credentials', + error_description: 'The client_id and/or client_secret passed are incorrect.', + }); + } + throw new Error(`Unexpected GitHub request: ${request.toString()}`); + }), + ); + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + const flow = await start('github'); + const page = await app.fetch( + new Request(`${origin}/auth/callback?code=provider-code&state=${flow.state}`, { + headers: { Cookie: flow.cookie, Accept: 'text/html' }, + }), + env, + ); + expect(page.status).toBe(400); + const html = await page.text(); + expect(html).toContain('GitHub rejected this platform’s client ID or client secret.'); + expect(html).toContain('Error code: github_incorrect_client_credentials'); + expect(html).toContain(`Callback URL: ${origin}/auth/callback`); + expect(html).not.toContain('void platform repair'); + expect(errors).toHaveBeenCalledWith('[auth] login failed', { + code: 'github_incorrect_client_credentials', + purpose: 'login', + connectionId: 'github', + }); + const logged = JSON.stringify(errors.mock.calls); + expect(logged).not.toContain('test-github-client-secret'); + expect(logged).not.toContain('passed are incorrect'); + + const cli = await callback(await start('github')); + expect(cli.status).toBe(400); + expect(await cli.json()).toMatchObject({ + error: 'login_failed', + code: 'github_incorrect_client_credentials', + }); + + const denied = await start('github'); + const cancelled = await app.fetch( + new Request(`${origin}/auth/callback?error=access_denied&state=${denied.state}`, { + headers: { Cookie: denied.cookie }, + }), + env, + ); + expect(await cancelled.json()).toMatchObject({ code: 'login_denied' }); + errors.mockRestore(); + }); it('backfills a GitHub App installation after explicit managed identity linking', async () => { const user = await createTestUser(env.db, 'oidc-user', 'oidc:company', 'oidc-user-subject'); await env.db.insert(platformLoginIdentities).values({ diff --git a/platform/packages/api/test/integration/zero-trust-uninstall.test.ts b/platform/packages/api/test/integration/zero-trust-uninstall.test.ts index 5cef75127..61c2cddca 100644 --- a/platform/packages/api/test/integration/zero-trust-uninstall.test.ts +++ b/platform/packages/api/test/integration/zero-trust-uninstall.test.ts @@ -205,7 +205,7 @@ describe('platform uninstall check against the API database', () => { assertProjectZeroTrustAllowsUninstall(() => readPlatformProjectZeroTrustStateConnection(connection), ); - const refusal = await check().catch((error: unknown) => error as Error); + const refusal = (await check().catch((error: unknown) => error)) as Error; expect(refusal).toBeInstanceOf(Error); expect(refusal!.message).toContain('Deletion of project stopped-deletion stopped'); expect(refusal!.message).toContain('`void project delete `'); @@ -216,6 +216,23 @@ describe('platform uninstall check against the API database', () => { await expect(check()).resolves.toBeUndefined(); }); + it('reports whether a platform user exists while Zero Trust is enabled', async () => { + vi.stubGlobal('fetch', accessApi().fetcher); + const platform = bindings(); + await configurePlatformZeroTrust(platform, env.db, configureInput); + await settleOperation(platform); + const connection = (env.db as unknown as { $client: QueryConnection }).$client; + expect(await readPlatformProjectZeroTrustStateConnection(connection)).toMatchObject({ + active: true, + usersExist: false, + }); + await createTestUser(env.db, 'first-user'); + expect(await readPlatformProjectZeroTrustStateConnection(connection)).toMatchObject({ + active: true, + usersExist: true, + }); + }); + it('refuses every Zero Trust lock holder while the uninstall fence is in place', async () => { const owner = await createTestUser(env.db, 'fenced-owner'); const project = await createTestProject(env.db, owner.id, 'fenced'); diff --git a/platform/packages/api/test/unit/platform-oidc.test.ts b/platform/packages/api/test/unit/platform-oidc.test.ts index 0a04396af..ad7efee7d 100644 --- a/platform/packages/api/test/unit/platform-oidc.test.ts +++ b/platform/packages/api/test/unit/platform-oidc.test.ts @@ -218,6 +218,49 @@ describe('platform OIDC', () => { expect((await accepted.exchange()).subject).toBe('person-123'); }); + it('names each login failure with a short code', async () => { + await expect( + fixture({ metadata: { issuer: 'https://other.example.com' } }).start(), + ).rejects.toMatchObject({ code: 'oidc_discovery_failed' }); + + const denied = fixture(); + await denied.start(); + await expect( + denied.provider.exchange({ + callback: new URL( + 'https://api.example.com/auth/callback?error=access_denied&state=browser-bound-state', + ), + redirectUri: 'https://api.example.com/auth/callback', + state: 'browser-bound-state', + nonce: 'unused', + verifier: 'unused', + clientSecret: 'client-secret', + }), + ).rejects.toMatchObject({ code: 'login_denied' }); + + const token = fixture({ metadata: { token_endpoint_auth_methods_supported: ['none'] } }); + await token.start(); + await expect(token.exchange()).rejects.toMatchObject({ code: 'oidc_token_failed' }); + + const groups = fixture({ + configuration: { requiredGroups: ['employees'] }, + claims: { groups: ['contractors'] }, + }); + await groups.start(); + await expect(groups.exchange()).rejects.toMatchObject({ code: 'oidc_group_not_allowed' }); + + const domain = fixture({ + configuration: { + id: 'google', + kind: 'google', + issuer: 'https://accounts.google.com', + allowedDomains: ['example.com'], + }, + }); + await domain.start(); + await expect(domain.exchange()).rejects.toMatchObject({ code: 'google_domain_not_allowed' }); + }); + it('rejects issuer confusion and cross-origin metadata endpoints', async () => { for (const metadata of [ { issuer: 'https://other.example.com' },