diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fed3349..72b6b87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,45 @@ jobs: - uses: actions/checkout@v7 - uses: docker/setup-qemu-action@v4 - uses: docker/setup-buildx-action@v4 + - name: Build native container for runtime smoke tests + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64 + load: true + push: false + tags: workbench:smoke + cache-from: type=gha + - name: Verify non-root Azure runtime + run: >- + docker run --rm --entrypoint sh workbench:smoke -c + 'test "$(id -u)" = 1001 && + test "$AZURE_CONFIG_DIR" = /home/nextjs/.azure && + test "$(stat -c %a "$AZURE_CONFIG_DIR")" = 700 && + test -w "$AZURE_CONFIG_DIR" && + az version --output json --only-show-errors >/dev/null && + node -e "require(\"sharp\")" && + wget --version >/dev/null' + - name: Build arm64 container for runtime smoke tests + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/arm64 + load: true + push: false + tags: workbench:smoke-arm64 + cache-from: type=gha + - name: Verify non-root Azure runtime on arm64 + run: >- + docker run --rm --platform linux/arm64 --entrypoint sh + workbench:smoke-arm64 -c + 'test "$(id -u)" = 1001 && + test "$AZURE_CONFIG_DIR" = /home/nextjs/.azure && + test "$(stat -c %a "$AZURE_CONFIG_DIR")" = 700 && + test -w "$AZURE_CONFIG_DIR" && + az version --output json --only-show-errors >/dev/null && + node -e "require(\"sharp\")" && + wget --version >/dev/null' - uses: docker/build-push-action@v7 with: context: . diff --git a/CHANGELOG.md b/CHANGELOG.md index d4ee9df..a3e6f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,36 @@ All notable changes to Workbench are documented here. Versions follow semantic versioning. -## 0.1.0 - 2026-07-18 +## 1.1.0 - 2026-07-19 + +### Added + +- UI-managed Microsoft device-code sign-in for a personal Azure account, with + no user-created Entra application registration or terminal step. +- Azure Key Vault as an optional source for bearer tokens, Basic passwords, API + keys, OAuth client secrets, OAuth passwords, and OAuth refresh tokens. +- Exact or latest Key Vault secret references, sanitized connection and + failure states, reference testing, and server-side just-in-time resolution. +- Persistent, isolated Azure CLI authentication state in the standard Docker + Compose installation. + +### Changed + +- The standard production and development containers now include Azure CLI + 2.88.0 and use Debian Bookworm while remaining non-root and multi-platform. +- Export manifests now report the package version instead of a stale hard-coded + value. + +### Security + +- Key Vault tokens and resolved values remain outside browser responses, + PostgreSQL, execution history, exports, backups, and application logs. +- Vault references accept only HTTPS Azure Key Vault hosts, preventing access + tokens from being forwarded to arbitrary servers. +- Azure CLI processes use fixed argument arrays without a shell, bounded output, + one active login, cancellation, expiry, and sanitized errors. + +## 1.0.0 - 2026-07-18 Initial public release. diff --git a/Dockerfile b/Dockerfile index 0f430d0..29b7c44 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,43 @@ # syntax=docker/dockerfile:1 -FROM --platform=$BUILDPLATFORM node:24-alpine AS build-base -RUN apk add --no-cache libc6-compat +FROM --platform=$BUILDPLATFORM node:24-bookworm-slim AS build-base WORKDIR /app ENV NEXT_TELEMETRY_DISABLED=1 +FROM node:24-bookworm-slim AS azure-cli-base +ARG AZURE_CLI_VERSION=2.88.0 +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +ENV AZURE_CONFIG_DIR=/home/nextjs/.azure + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl gnupg wget \ + && mkdir -p /etc/apt/keyrings \ + && curl -sLS https://packages.microsoft.com/keys/microsoft.asc \ + | gpg --dearmor -o /etc/apt/keyrings/microsoft.gpg \ + && chmod go+r /etc/apt/keyrings/microsoft.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/microsoft.gpg] https://packages.microsoft.com/repos/azure-cli/ bookworm main" \ + > /etc/apt/sources.list.d/azure-cli.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends "azure-cli=${AZURE_CLI_VERSION}-1~bookworm" \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 1001 nodejs \ + && useradd --system --uid 1001 --gid nodejs --home-dir /home/nextjs --create-home --shell /usr/sbin/nologin nextjs \ + && mkdir -p "$AZURE_CONFIG_DIR" \ + && chown -R nextjs:nodejs /home/nextjs \ + && chmod 700 "$AZURE_CONFIG_DIR" + FROM build-base AS dependencies COPY package.json package-lock.json ./ RUN npm ci -FROM dependencies AS development +FROM azure-cli-base AS development ENV NODE_ENV=development -COPY . . +COPY --from=dependencies --chown=nextjs:nodejs /app/node_modules ./node_modules +COPY --chown=nextjs:nodejs . . +RUN mkdir -p /app/.next \ + && chown nextjs:nodejs /app/.next +USER nextjs EXPOSE 3000 CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0"] @@ -28,21 +54,18 @@ RUN case "$TARGETARCH" in \ amd64) target_npm_cpu=x64 ;; \ *) target_npm_cpu="$TARGETARCH" ;; \ esac \ - && npm ci --omit=dev --ignore-scripts --os=linux --cpu="$target_npm_cpu" --libc=musl \ - && test -d "node_modules/@img/sharp-linuxmusl-$target_npm_cpu" \ - && test -d "node_modules/@img/sharp-libvips-linuxmusl-$target_npm_cpu" \ + && npm ci --omit=dev --ignore-scripts --os=linux --cpu="$target_npm_cpu" --libc=glibc \ + && test -d "node_modules/@img/sharp-linux-$target_npm_cpu" \ + && test -d "node_modules/@img/sharp-libvips-linux-$target_npm_cpu" \ && npm cache clean --force -FROM node:24-alpine AS runner +FROM azure-cli-base AS runner ENV NODE_ENV=production ENV HOSTNAME=0.0.0.0 ENV PORT=3000 ENV WORKBENCH_BACKUP_DIR=/backups -RUN apk add --no-cache libc6-compat \ - && addgroup --system --gid 1001 nodejs \ - && adduser --system --uid 1001 nextjs \ - && mkdir -p /backups \ +RUN mkdir -p /backups \ && chown nextjs:nodejs /backups \ && chmod 700 /backups diff --git a/README.md b/README.md index eeb3c95..2926c1f 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,34 @@ authentication, connected request flows, and durable project organisation. It runs as a self-hosted Next.js application with PostgreSQL, so collections, credentials, execution history, imports, and backups remain under your control. +> [!IMPORTANT] +> **Azure Key Vault is built directly into Workbench authentication in v1.1.** +> Connect your personal Microsoft account through a guided, `az login`-style +> flow in the UI, then use Key Vault secrets in authentication profiles. There +> is no terminal login, no user-created Entra application registration, and no +> resolved vault value stored in Workbench. + +## Use Azure Key Vault without leaving the app + +Workbench can source bearer tokens, Basic-auth passwords, API keys, OAuth client +secrets, OAuth passwords, and OAuth refresh tokens from Azure Key Vault. Pick +**Azure Key Vault** on the credential field, enter the vault URL and secret name, +and optionally pin an exact version. Leaving the version blank follows the +latest secret so normal rotation does not require editing the profile. + +The Microsoft device-code flow, connection status, reference test, and +disconnect controls all live on the Authentication screen. Azure CLI is already +included in the standard amd64 and arm64 Docker images, and its isolated session +survives normal Compose container recreation. + +![Azure Key Vault authentication in Workbench](docs/images/phase-12-azure-key-vault.png) + +[Read the Azure Key Vault setup and security details](docs/authentication.md#connect-azure). + It is designed for developers who need more than isolated HTTP requests: +- resolve authentication secrets from Azure Key Vault only when a request needs + them; - obtain a token from one saved request and inject it into another; - publish response values for later requests and workflows; - explain exactly which environment or variable supplied a value; @@ -47,6 +73,26 @@ tokens are cached server-side and refreshed before expiry. ![OAuth client credentials profile](docs/images/phase-10-oauth-client-credentials.png) +Credential fields can be stored in Workbench or resolved from Azure Key Vault. +The Authentication screen connects a personal Microsoft account using a guided +device-code flow entirely in the UI—there is no terminal command and no +user-created Microsoft Entra application registration. Workbench supports Key +Vault references for: + +| Authentication profile | Key Vault-backed fields | +| ------------------------ | ------------------------------- | +| Bearer token | Token | +| Basic authentication | Password | +| API key | Key value | +| OAuth client credentials | Client secret | +| OAuth password | Client secret and password | +| OAuth refresh token | Client secret and refresh token | + +Each reference identifies a vault URL, secret name, and optional exact version. +Omitting the version follows the latest secret, enabling rotation without +editing the profile. Values are resolved only on the server immediately before +use and never enter Workbench records, history, exports, or backups. + A request-derived profile connects normal saved requests into an authentication flow: @@ -186,8 +232,9 @@ Stop the stack without deleting data: docker compose down ``` -The `workbench_postgres_data` and `workbench_backups` volumes preserve the -database and logical backups across restarts. Compose defaults use development +The `workbench_postgres_data`, `workbench_backups`, and +`workbench_azure_cli` volumes preserve the database, logical backups, and +optional Azure sign-in respectively. Compose defaults use development credentials; set unique `POSTGRES_*` values before exposing the database beyond the local Docker network. @@ -200,7 +247,7 @@ Every verified merge to `master` publishes a non-root, multi-platform image for ghcr.io/josh-uk/workbench ``` -Images receive `latest` and full-commit-SHA tags. Version tags such as `v0.1.0` +Images receive `latest` and full-commit-SHA tags. Version tags such as `v1.1.0` also publish semantic-version tags, an SBOM, build provenance, and a GitHub release. GitHub Container Registry visibility is managed separately from the public repository. @@ -235,6 +282,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build | `APP_PORT` | Host port mapped to the application | `3000` | | `WORKBENCH_BACKUP_DIR` | Server-only logical backup directory | `/backups` | | `WORKBENCH_BACKUP_PASSWORD` | Password for encrypted automatic backups (12+) | Empty | +| `AZURE_CONFIG_DIR` | Isolated Azure CLI authentication state | `/home/nextjs/.azure` | Never use a `NEXT_PUBLIC_` variable for a secret. diff --git a/SECURITY.md b/SECURITY.md index ef11e67..259a8c1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,10 +12,9 @@ triage the impact, and coordinate remediation and disclosure. ## Supported versions -Before `v1.0.0`, the latest `0.1.x` release and the latest commit on `master` are -supported. Security fixes are released from current development; older pre-1.0 -minor lines do not receive backports. The stable support window will be -documented with the `v1.0.0` release. +The latest stable minor release and the latest commit on `master` are supported. +Security fixes are released from current development; older minor lines do not +normally receive backports. ## Scope diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fd254ec..ae594fb 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -10,7 +10,10 @@ services: - .:/app - workbench_node_modules:/app/node_modules - workbench_next_cache:/app/.next + - workbench_azure_cli:/home/nextjs/.azure volumes: workbench_node_modules: workbench_next_cache: + workbench_azure_cli: + name: workbench_azure_cli diff --git a/docker-compose.yml b/docker-compose.yml index 406c3a7..a29c709 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: restart: unless-stopped environment: DATABASE_URL: postgresql://${POSTGRES_USER:-workbench}:${POSTGRES_PASSWORD:-workbench}@database:5432/${POSTGRES_DB:-workbench} + AZURE_CONFIG_DIR: /home/nextjs/.azure WORKBENCH_BACKUP_DIR: /backups WORKBENCH_BACKUP_PASSWORD: ${WORKBENCH_BACKUP_PASSWORD:-} ports: @@ -18,6 +19,7 @@ services: condition: service_healthy volumes: - workbench_backups:/backups + - workbench_azure_cli:/home/nextjs/.azure healthcheck: test: [ @@ -55,3 +57,5 @@ volumes: name: workbench_postgres_data workbench_backups: name: workbench_backups + workbench_azure_cli: + name: workbench_azure_cli diff --git a/docs/architecture.md b/docs/architecture.md index 7c7ea84..cb51dba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,7 +24,10 @@ results to HTTP responses. Database and secret-bearing modules import ## Runtime The production container starts by applying committed database migrations, then -launches the Next.js standalone server as a non-root user. Compose waits for +launches the Next.js standalone server as a non-root user. The standard image +also contains a pinned Azure CLI. Its optional user-session cache is isolated in +`workbench_azure_cli`; Workbench launches only fixed Azure CLI argument arrays +and resolves Key Vault values through server-only modules. Compose waits for PostgreSQL health before starting the app. `/api/health` verifies both the server and database connection. @@ -34,6 +37,7 @@ and database connection. - Saved-request editor and persistence - Variable resolver - Authentication and request-output engine +- UI-managed Azure login and Azure Key Vault secret resolution - SSRF-aware HTTP execution engine - Modular collection importers - Workflow and assertion runner diff --git a/docs/authentication.md b/docs/authentication.md index cf88663..6d8d28c 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -26,6 +26,91 @@ timeout, TLS, and response-size controls. ![Authentication profiles](images/phase-10-authentication.png) +## Azure Key Vault credential sources + +The following secret-bearing fields can use either **Stored in Workbench** or +**Azure Key Vault**: + +| Profile | Supported Key Vault fields | +| -------------------------- | ------------------------------- | +| Bearer token | Token | +| Basic authentication | Password | +| API key in header or query | Key value | +| OAuth client credentials | Client secret | +| OAuth password | Client secret and password | +| OAuth refresh token | Client secret and refresh token | + +Request-derived authentication does not need a Key Vault source because it +receives its value from a saved request output. + +### Connect Azure + +Open **Authentication profiles**, find **Azure connection**, and select +**Connect Azure**. An optional tenant ID or verified tenant domain narrows the +sign-in. Workbench starts Azure CLI's device-code flow inside its container and +shows the temporary Microsoft code and verification link in a modal. Complete +Microsoft sign-in and MFA in the new browser tab; no terminal command or custom +Entra application registration is required. + +The connected user must already have Key Vault data-plane access. The +least-privilege built-in role is normally **Key Vault Secrets User** at the +individual vault scope. Workbench does not grant roles, change IAM, enumerate +vaults, or manage secrets. + +The first version supports one connected Azure user per Workbench installation. +Select **Disconnect** before changing accounts. Azure CLI state is kept in the +dedicated `workbench_azure_cli` Docker volume and survives ordinary container +recreation. It is not stored in PostgreSQL or included in Workbench backups. + +### Configure a reference + +Choose **Azure Key Vault** from a supported field's Source menu, then enter: + +- the exact vault URL, such as `https://my-vault.vault.azure.net/`; +- the secret name; and +- optionally, the 32-character secret version. + +Select **Test reference** to verify access without displaying the value. If the +version is blank, every execution resolves the latest version. This is the +recommended setting for automatic secret rotation. If the latest version is +disabled, the request fails rather than requiring permission to enumerate older +versions. Supplying a version keeps the profile pinned until the reference is +edited. + +Only public Azure Key Vault hostnames ending in `.vault.azure.net` are accepted +in v1.1.0. The hostname may resolve through a private endpoint, but the +Workbench container must have the required DNS and network route. Sovereign +Azure clouds are not yet supported. + +Key Vault values are requested only when needed. In particular, a fresh cached +OAuth access token is reused without contacting Key Vault; the referenced +credential is resolved immediately before the next OAuth token request. + +### Troubleshooting and decommissioning + +- **Azure CLI unavailable:** use the supplied Docker Compose image; local + `npm run dev` does not bundle Azure CLI. +- **Permission denied:** confirm the signed-in user has **Key Vault Secrets + User** (or equivalent `secrets/get` data-plane access) on that vault. +- **Vault unreachable:** confirm the container can resolve and route to the + vault hostname, including private-endpoint DNS when used. +- **Latest secret disabled or expired:** enable a usable latest version or pin + the exact active 32-character version in the profile. + +Disconnecting in the UI removes the active account but retains the empty Azure +CLI configuration volume. To fully remove all persisted Azure session state, +stop Workbench and delete only that dedicated volume: + +```sh +docker compose down +docker volume rm workbench_azure_cli +``` + +The second command is intentionally destructive for Azure CLI session state; +it does not remove the PostgreSQL or backup volumes. + +![Azure Key Vault credential source](images/phase-12-azure-key-vault.png) + ## Token lifecycle Workbench keeps one server-side cache entry per OAuth profile. It reuses a token @@ -72,3 +157,10 @@ used by the server. Database access is therefore inside the trusted local boundary. Workbench does not claim operating-system keychain or at-rest database encryption; protect the host and database and do not expose them to untrusted networks. + +Azure changes the storage boundary for referenced profile fields: the secret +value remains in Key Vault and exists in Workbench memory only for the active +request or OAuth exchange. The browser receives the temporary device code and +sanitized account/status metadata, never Azure access tokens, refresh tokens, +CLI output, or resolved Key Vault values. Removing the `workbench_azure_cli` +volume after stopping Compose fully removes the persisted Azure CLI session. diff --git a/docs/backup-and-restore.md b/docs/backup-and-restore.md index 9e47c40..97eed2c 100644 --- a/docs/backup-and-restore.md +++ b/docs/backup-and-restore.md @@ -48,6 +48,14 @@ Losing an archive password makes its secrets unrecoverable. Plain-text mode requires a prominent confirmation and adds a manifest warning. Anyone who can read that archive can read its credentials. +Azure Key Vault references are configuration rather than secret material, so +their vault URL, secret name, and optional version remain in every archive mode. +The resolved value and Azure CLI tokens are never added to an archive. Azure CLI +state lives in the separate `workbench_azure_cli` Docker volume; logical backup +and restore deliberately do not copy it. After restoring on another machine, +use **Authentication profiles → Connect Azure** before executing a referenced +profile. + ## Automatic backups The production server checks the persisted schedule once a minute and creates a diff --git a/docs/data-model.md b/docs/data-model.md index 8def5b2..24abb8c 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -63,7 +63,11 @@ response body previews are bounded separately from the network response-size limit. Authentication profile configuration uses validated JSON so profile types can -evolve without a sparse table for every credential field. `auth_token_cache` +evolve without a sparse table for every credential field. Azure Key Vault +references are a nested map in this JSON containing only the vault URL, secret +name, and optional version; the resolved value is never persisted. The Azure +CLI account and token cache live outside PostgreSQL in their dedicated Docker +volume. `auth_token_cache` stores the latest access token, optional refresh token, token type, and expiry for a profile. Request output definitions are normalized children of saved requests; extracted runtime values reference both their definition and source diff --git a/docs/development.md b/docs/development.md index 7790a90..e3a0129 100644 --- a/docs/development.md +++ b/docs/development.md @@ -20,6 +20,17 @@ npm run db:migrate npm run dev ``` +Local host development shows Azure as unavailable because the security-sensitive +CLI integration uses the fixed container path `/usr/bin/az`. For the supported +and production-equivalent Azure flow, run the containerised development stack: + +```bash +docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build +``` + +Do not point development at a personal host `~/.azure` directory. The Compose +stack uses the isolated `workbench_azure_cli` volume. + PostgreSQL integration tests that mutate hierarchy records require an isolated database URL. Never point `TEST_DATABASE_URL` at a database containing data you want to preserve. diff --git a/docs/images/phase-10-authentication.png b/docs/images/phase-10-authentication.png index 58b5bd5..46b0075 100644 Binary files a/docs/images/phase-10-authentication.png and b/docs/images/phase-10-authentication.png differ diff --git a/docs/images/phase-10-oauth-client-credentials.png b/docs/images/phase-10-oauth-client-credentials.png index db72fe5..712d22f 100644 Binary files a/docs/images/phase-10-oauth-client-credentials.png and b/docs/images/phase-10-oauth-client-credentials.png differ diff --git a/docs/images/phase-12-azure-key-vault.png b/docs/images/phase-12-azure-key-vault.png new file mode 100644 index 0000000..ac322a5 Binary files /dev/null and b/docs/images/phase-12-azure-key-vault.png differ diff --git a/docs/release-readiness.md b/docs/release-readiness.md index acb4ddb..49c5e37 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -1,7 +1,7 @@ # Release readiness -This review records the evidence used for the initial `v0.1.0` release. It is a -living checklist rather than a claim that a local-only application has no risk. +This living review records the evidence required for stable Workbench releases; +it is not a claim that a local-only application has no risk. ## Accessibility and keyboard review @@ -49,6 +49,9 @@ measured collections make it necessary. - Import parsers validate untrusted source documents before transactional writes. - The production container runs as a non-root user. Backup files use owner-only permissions, and GHCR releases include an SBOM and build provenance. +- Azure login uses one bounded, cancellable, non-shell CLI process. Key Vault + access tokens and resolved values remain server-only, vault hosts are strictly + allow-listed, and CLI state uses a dedicated owner-only Docker volume. - CI runs the npm high-severity audit. The release dependency graph has no known vulnerabilities at that threshold. diff --git a/docs/security.md b/docs/security.md index f971e2e..7993716 100644 --- a/docs/security.md +++ b/docs/security.md @@ -78,6 +78,22 @@ metadata before persistence. Authentication traces contain profile identity, credential source, and injection target, but never the credential. Editing a profile or override invalidates its direct OAuth cache. +Azure Key Vault references keep resolved values outside PostgreSQL, archives, +backups, execution history, and browser responses. The standard container runs a +pinned Azure CLI as the same non-root application user and stores its token +cache only in the mode-`0700` `workbench_azure_cli` volume. UI login launches a +fixed argument array with `shell: false`, permits one process at a time, bounds +captured output and lifetime, and maps raw CLI failures to sanitized messages. +The temporary device code is returned only during its active sign-in and is not +logged or persisted. + +Key Vault access tokens are requested server-side for +`https://vault.azure.net`. To prevent bearer-token disclosure and SSRF, vault +URLs must use HTTPS, contain no credentials, custom port, path, query, or +fragment, and end in the exact public Azure suffix `.vault.azure.net`. Redirects +are rejected. The REST response is schema-validated and the resolved value is +added to the same in-memory secret-redaction set as a locally stored credential. + Assertion evaluation runs on the server after a bounded response has completed. Definitions and counts are schema-bounded. Regular expressions reject backreferences, lookarounds, nested quantifiers, repeated unbounded wildcards, @@ -102,5 +118,6 @@ only names matching the generated backup pattern. CI fails for high-severity npm audit findings. Dependabot monitors npm, Docker, and GitHub Actions dependencies. Production containers run as a non-root user -and contain only the standalone application, runtime dependencies, migrations, -and public assets. +and contain the standalone application, runtime dependencies, migrations, +Azure CLI, and public assets. Azure CLI is pinned and the image continues to be +built and tested for `linux/amd64` and `linux/arm64`. diff --git a/package-lock.json b/package-lock.json index b59944d..5cc6395 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "workbench", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "workbench", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "ajv": "8.20.0", "class-variance-authority": "0.7.1", diff --git a/package.json b/package.json index 2bd13e8..e8251ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "workbench", - "version": "1.0.0", + "version": "1.1.0", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/api/configuration/azure/key-vault/route.ts b/src/app/api/configuration/azure/key-vault/route.ts new file mode 100644 index 0000000..26e9b64 --- /dev/null +++ b/src/app/api/configuration/azure/key-vault/route.ts @@ -0,0 +1,20 @@ +import { testKeyVaultReferenceSchema } from "@/features/authentication/azure/domain"; +import { + assertTrustedMutation, + azureErrorResponse, + azureJson, +} from "@/features/authentication/azure/http"; +import { testKeyVaultSecretReference } from "@/features/authentication/azure/key-vault"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + try { + assertTrustedMutation(request); + const input = testKeyVaultReferenceSchema.parse(await request.json()); + return azureJson(await testKeyVaultSecretReference(input.reference)); + } catch (error) { + return azureErrorResponse(error); + } +} diff --git a/src/app/api/configuration/azure/login/route.ts b/src/app/api/configuration/azure/login/route.ts new file mode 100644 index 0000000..82b60a6 --- /dev/null +++ b/src/app/api/configuration/azure/login/route.ts @@ -0,0 +1,33 @@ +import { + cancelAzureLogin, + startAzureLogin, +} from "@/features/authentication/azure/azure-cli"; +import { + assertTrustedMutation, + azureErrorResponse, + azureJson, +} from "@/features/authentication/azure/http"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(request: Request) { + try { + assertTrustedMutation(request); + return azureJson(await startAzureLogin(await request.json()), { + status: 202, + }); + } catch (error) { + return azureErrorResponse(error); + } +} + +export async function DELETE(request: Request) { + try { + assertTrustedMutation(request); + await cancelAzureLogin(); + return azureJson({ ok: true }); + } catch (error) { + return azureErrorResponse(error); + } +} diff --git a/src/app/api/configuration/azure/route.ts b/src/app/api/configuration/azure/route.ts new file mode 100644 index 0000000..2e943cf --- /dev/null +++ b/src/app/api/configuration/azure/route.ts @@ -0,0 +1,26 @@ +import { + disconnectAzure, + getAzureConnectionState, +} from "@/features/authentication/azure/azure-cli"; +import { + assertTrustedMutation, + azureErrorResponse, + azureJson, +} from "@/features/authentication/azure/http"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + return azureJson(await getAzureConnectionState()); +} + +export async function DELETE(request: Request) { + try { + assertTrustedMutation(request); + await disconnectAzure(); + return azureJson({ ok: true }); + } catch (error) { + return azureErrorResponse(error); + } +} diff --git a/src/core/assertions/evaluator.test.ts b/src/core/assertions/evaluator.test.ts index ee944ca..722c067 100644 --- a/src/core/assertions/evaluator.test.ts +++ b/src/core/assertions/evaluator.test.ts @@ -263,4 +263,22 @@ describe("response assertion evaluation", () => { expect(unsafeRegexReason("a".repeat(257))).toContain("256"); expect(unsafeRegexReason("^abc-[0-9]+$")).toBeNull(); }); + + it("does not include invalid response content in assertion errors", () => { + const secret = "azure-kv-supersecret"; + const results = evaluateAssertions( + { ...response, rawBody: secret, bodyPreview: "••••••••" }, + owned([ + { + name: "Secret must be JSON", + enabled: true, + type: "jsonpath_exists", + configuration: { path: "$.value" }, + }, + ]), + ); + + expect(results[0]).toMatchObject({ passed: false }); + expect(results[0]?.message).not.toContain(secret); + }); }); diff --git a/src/core/assertions/evaluator.ts b/src/core/assertions/evaluator.ts index 4dccb65..5e68121 100644 --- a/src/core/assertions/evaluator.ts +++ b/src/core/assertions/evaluator.ts @@ -2,6 +2,7 @@ import Ajv from "ajv"; import { evaluateJsonPath, + JsonPathError, outputValueToString, } from "@/core/request-outputs/json-path"; @@ -238,9 +239,9 @@ export function evaluateAssertions( return result( assertion, false, - error instanceof Error + error instanceof JsonPathError ? error.message - : "Assertion evaluation failed.", + : "Assertion evaluation failed because the response or assertion configuration was invalid.", ); } }); diff --git a/src/features/authentication/azure/azure-cli-concurrency.test.ts b/src/features/authentication/azure/azure-cli-concurrency.test.ts new file mode 100644 index 0000000..49b4ee7 --- /dev/null +++ b/src/features/authentication/azure/azure-cli-concurrency.test.ts @@ -0,0 +1,155 @@ +import { EventEmitter } from "node:events"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const mocks = vi.hoisted(() => ({ + spawn: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + spawn: mocks.spawn, +})); + +import { + cancelAzureLogin, + disconnectAzure, + getAzureConnectionState, + getKeyVaultAccessToken, + startAzureLogin, +} from "./azure-cli"; + +function childProcess() { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: ReturnType; + }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.kill = vi.fn(); + return child; +} + +describe("Azure CLI login lifecycle", () => { + beforeEach(async () => { + vi.clearAllMocks(); + await cancelAzureLogin(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("reserves one login slot before account inspection and awaits cancellation", async () => { + const accountCheck = childProcess(); + const login = childProcess(); + mocks.spawn.mockReturnValueOnce(accountCheck).mockReturnValueOnce(login); + + const firstLogin = startAzureLogin({}); + await expect(startAzureLogin({})).rejects.toMatchObject({ + code: "AZURE_LOGIN_IN_PROGRESS", + }); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + + accountCheck.emit("close", 1); + await expect(firstLogin).resolves.toMatchObject({ status: "starting" }); + expect(mocks.spawn).toHaveBeenCalledTimes(2); + expect(mocks.spawn.mock.calls[0]?.[2]?.env).toMatchObject({ + AZURE_CORE_COLLECT_TELEMETRY: "no", + }); + + let cancelled = false; + const cancellation = cancelAzureLogin().then(() => { + cancelled = true; + }); + await Promise.resolve(); + expect(login.kill).toHaveBeenCalledWith("SIGTERM"); + expect(cancelled).toBe(false); + + login.emit("close", 1); + await cancellation; + expect(cancelled).toBe(true); + + const accountAfterCancel = childProcess(); + mocks.spawn.mockReturnValueOnce(accountAfterCancel); + const state = getAzureConnectionState(); + accountAfterCancel.emit("close", 1); + await expect(state).resolves.toEqual({ + status: "disconnected", + cliAvailable: true, + }); + }); + + it("parses Key Vault tokens without returning raw CLI output", async () => { + const tokenCommand = childProcess(); + mocks.spawn.mockReturnValueOnce(tokenCommand); + const token = getKeyVaultAccessToken(); + tokenCommand.stdout.emit( + "data", + Buffer.from( + JSON.stringify({ + accessToken: "access-token", + expires_on: 2_000_000_000, + tenant: "tenant-id", + tokenType: "Bearer", + }), + ), + ); + tokenCommand.emit("close", 0); + + await expect(token).resolves.toEqual({ + accessToken: "access-token", + expiresOn: 2_000_000_000, + tenantId: "tenant-id", + tokenType: "Bearer", + }); + }); + + it("sanitizes CLI failures and clears the account during disconnect", async () => { + const failedToken = childProcess(); + mocks.spawn.mockReturnValueOnce(failedToken); + const token = getKeyVaultAccessToken(); + failedToken.stderr.emit( + "data", + Buffer.from("AADSTS50076 internal-secret-value"), + ); + failedToken.emit("close", 1); + await expect(token).rejects.toThrow("Microsoft Entra rejected"); + await expect(token).rejects.not.toThrow("internal-secret-value"); + + mocks.spawn.mockImplementation(() => { + const child = childProcess(); + queueMicrotask(() => child.emit("close", 0)); + return child; + }); + await disconnectAzure(); + + expect(mocks.spawn.mock.calls.slice(-2).map((call) => call[1])).toEqual([ + ["logout", "--only-show-errors"], + ["account", "clear", "--only-show-errors"], + ]); + }); + + it("does not report a successful disconnect when session removal fails", async () => { + const logout = childProcess(); + const clear = childProcess(); + mocks.spawn.mockReturnValueOnce(logout).mockReturnValueOnce(clear); + + const disconnected = disconnectAzure(); + await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledTimes(1)); + logout.stderr.emit( + "data", + Buffer.from("permission denied internal-secret"), + ); + logout.emit("close", 1); + await vi.waitFor(() => expect(mocks.spawn).toHaveBeenCalledTimes(2)); + clear.emit("close", 0); + + await expect(disconnected).rejects.toThrow( + "Azure session data could not be cleared", + ); + await expect(disconnected).rejects.not.toThrow("internal-secret"); + }); +}); diff --git a/src/features/authentication/azure/azure-cli.test.ts b/src/features/authentication/azure/azure-cli.test.ts new file mode 100644 index 0000000..27b650f --- /dev/null +++ b/src/features/authentication/azure/azure-cli.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +import { parseAzureDeviceCodeOutput } from "./azure-cli"; + +describe("Azure CLI device-code parsing", () => { + it("extracts only the bounded device code from CLI output", () => { + expect( + parseAzureDeviceCodeOutput( + "To sign in, open https://microsoft.com/devicelogin and enter the code AB12-CD34 to authenticate.", + ), + ).toBe("AB12-CD34"); + expect( + parseAzureDeviceCodeOutput("Use the browser. Device code: ZXCV1234"), + ).toBe("ZXCV1234"); + }); + + it("does not accept arbitrary text as a device code", () => { + expect( + parseAzureDeviceCodeOutput("code: "), + ).toBeNull(); + expect(parseAzureDeviceCodeOutput("enter code short")).toBeNull(); + }); +}); diff --git a/src/features/authentication/azure/azure-cli.ts b/src/features/authentication/azure/azure-cli.ts new file mode 100644 index 0000000..99cf059 --- /dev/null +++ b/src/features/authentication/azure/azure-cli.ts @@ -0,0 +1,427 @@ +import "server-only"; + +import { spawn, type ChildProcess } from "node:child_process"; + +import { z } from "zod"; + +import { + type AzureAccountSummary, + AzureAuthenticationError, + type AzureConnectionState, + azureLoginRequestSchema, +} from "./domain"; + +const AZURE_CLI = "/usr/bin/az"; +const LOGIN_TIMEOUT_MS = 15 * 60 * 1_000; +const COMMAND_TIMEOUT_MS = 20_000; +const MAX_CAPTURE_BYTES = 64 * 1_024; +const DEVICE_LOGIN_URL = "https://microsoft.com/devicelogin"; + +const accountSchema = z.object({ + id: z.string().min(1).max(200), + name: z.string().min(1).max(200), + tenantId: z.string().min(1).max(200), + user: z.object({ name: z.string().min(1).max(320) }), +}); + +const accessTokenSchema = z.object({ + accessToken: z.string().min(1).max(32_768), + expires_on: z.union([z.number(), z.string()]).optional(), + tenant: z.string().min(1).max(200), + tokenType: z.literal("Bearer").default("Bearer"), +}); + +interface LoginAttempt { + process: Pick | null; + state: AzureConnectionState; + output: string; + timeout: NodeJS.Timeout | null; + cancelled: boolean; + completed: Promise; + complete: () => void; +} + +interface AzureCliGlobal { + login: LoginAttempt | null; + lastFailure: { message: string; at: number } | null; +} + +const globalKey = Symbol.for("workbench.azure-cli-state"); +const globalStore = globalThis as typeof globalThis & { + [globalKey]?: AzureCliGlobal; +}; +const store = (globalStore[globalKey] ??= { + login: null, + lastFailure: null, +}); + +function cliEnvironment() { + return { + ...process.env, + AZURE_CORE_COLLECT_TELEMETRY: "no", + AZURE_CORE_LOGIN_EXPERIENCE_V2: "off", + AZURE_CORE_NO_COLOR: "true", + AZURE_CORE_ONLY_SHOW_ERRORS: "true", + PYTHONUNBUFFERED: "1", + }; +} + +function friendlyFailure(message: string) { + const lower = message.toLowerCase(); + if (lower.includes("not found") || lower.includes("enoent")) { + return "Azure CLI is not available in this Workbench installation."; + } + if ( + lower.includes("az login") || + lower.includes("not logged in") || + lower.includes("no subscriptions found") + ) { + return "Azure is disconnected. Reconnect Azure and try again."; + } + if (lower.includes("expired") || lower.includes("code_expired")) { + return "Microsoft sign-in expired. Start a new Azure connection."; + } + if (lower.includes("cancel") || lower.includes("authorization_pending")) { + return "Microsoft sign-in was cancelled or did not complete."; + } + if (lower.includes("conditional access") || lower.includes("aadsts")) { + return "Microsoft Entra rejected the sign-in. Check MFA, tenant, and Conditional Access requirements."; + } + return "Azure sign-in failed. Check the tenant and try again."; +} + +function boundedAppend(current: string, chunk: Buffer | string) { + const combined = current + chunk.toString(); + return combined.length > MAX_CAPTURE_BYTES + ? combined.slice(-MAX_CAPTURE_BYTES) + : combined; +} + +export function parseAzureDeviceCodeOutput(output: string) { + const code = + output.match(/enter\s+(?:the\s+)?code\s+([A-Z0-9-]{6,20})/i)?.[1] ?? + output.match(/code\s*:\s*([A-Z0-9-]{6,20})/i)?.[1] ?? + null; + return code?.toUpperCase() ?? null; +} + +function clearAttempt(attempt: LoginAttempt) { + if (attempt.timeout) clearTimeout(attempt.timeout); + if (store.login === attempt) store.login = null; + attempt.complete(); +} + +function terminateProcess(process: Pick) { + process.kill("SIGTERM"); + const force = setTimeout(() => process.kill("SIGKILL"), 2_000); + force.unref(); +} + +async function runCommand( + args: string[], + options: { timeoutMs?: number; allowFailure?: boolean } = {}, +) { + return new Promise<{ stdout: string; stderr: string; code: number }>( + (resolve, reject) => { + const child = spawn(/* turbopackIgnore: true */ AZURE_CLI, args, { + env: cliEnvironment(), + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + const timeout = setTimeout(() => { + terminateProcess(child); + reject( + new AzureAuthenticationError( + "Azure CLI did not respond in time.", + "AZURE_CLI_TIMEOUT", + ), + ); + }, options.timeoutMs ?? COMMAND_TIMEOUT_MS); + child.stdout.on("data", (chunk: Buffer) => { + stdout = boundedAppend(stdout, chunk); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr = boundedAppend(stderr, chunk); + }); + child.once("error", (error) => { + clearTimeout(timeout); + reject( + new AzureAuthenticationError( + friendlyFailure(error.message), + error.message.includes("ENOENT") + ? "AZURE_CLI_UNAVAILABLE" + : "AZURE_CLI_FAILED", + ), + ); + }); + child.once("close", (code) => { + clearTimeout(timeout); + const exitCode = code ?? 1; + if (exitCode !== 0 && !options.allowFailure) { + reject( + new AzureAuthenticationError( + friendlyFailure(stderr), + "AZURE_CLI_FAILED", + ), + ); + return; + } + resolve({ stdout, stderr, code: exitCode }); + }); + }, + ); +} + +async function currentAccount(): Promise { + const result = await runCommand( + ["account", "show", "--output", "json", "--only-show-errors"], + { allowFailure: true }, + ); + if (result.code !== 0 || !result.stdout.trim()) return null; + try { + const account = accountSchema.parse(JSON.parse(result.stdout)); + return { + name: account.name, + username: account.user.name, + tenantId: account.tenantId, + subscriptionId: account.id, + }; + } catch { + throw new AzureAuthenticationError( + "Azure CLI returned invalid account information.", + "AZURE_CLI_INVALID_RESPONSE", + ); + } +} + +async function verifyKeyVaultSession() { + await runCommand([ + "account", + "get-access-token", + "--resource", + "https://vault.azure.net", + "--query", + "expires_on", + "--output", + "tsv", + "--only-show-errors", + ]); +} + +export async function getAzureConnectionState(): Promise { + if (store.login) return store.login.state; + try { + const account = await currentAccount(); + if (account) { + await verifyKeyVaultSession(); + return { status: "connected", cliAvailable: true, account }; + } + const recentFailure = + store.lastFailure && Date.now() - store.lastFailure.at < 60_000 + ? store.lastFailure + : null; + if (recentFailure) { + return { + status: "failed", + cliAvailable: true, + error: recentFailure.message, + }; + } + return { status: "disconnected", cliAvailable: true }; + } catch (error) { + if ( + error instanceof AzureAuthenticationError && + error.code === "AZURE_CLI_UNAVAILABLE" + ) { + return { status: "disconnected", cliAvailable: false }; + } + return { + status: "failed", + cliAvailable: + !(error instanceof AzureAuthenticationError) || + error.code !== "AZURE_CLI_UNAVAILABLE", + error: + error instanceof AzureAuthenticationError + ? error.message + : "Azure connection status could not be checked.", + }; + } +} + +export async function startAzureLogin(input: unknown) { + const parsed = azureLoginRequestSchema.parse(input); + if (store.login) { + throw new AzureAuthenticationError( + "An Azure sign-in is already in progress.", + "AZURE_LOGIN_IN_PROGRESS", + ); + } + const expiresAt = new Date(Date.now() + LOGIN_TIMEOUT_MS).toISOString(); + let complete = () => {}; + const completed = new Promise((resolve) => { + complete = resolve; + }); + const attempt: LoginAttempt = { + process: null, + output: "", + state: { + status: "starting", + cliAvailable: true, + verificationUrl: null, + userCode: null, + expiresAt, + } satisfies AzureConnectionState, + timeout: null, + cancelled: false, + completed, + complete, + }; + + // Reserve the process slot before the first await so simultaneous requests + // cannot both pass the login-in-progress check. + store.login = attempt; + store.lastFailure = null; + + try { + if (await currentAccount()) { + throw new AzureAuthenticationError( + "Disconnect the current Azure account before signing in again.", + "AZURE_ALREADY_CONNECTED", + ); + } + if (attempt.cancelled || store.login !== attempt) { + throw new AzureAuthenticationError( + "Microsoft sign-in was cancelled.", + "AZURE_LOGIN_CANCELLED", + ); + } + } catch (error) { + clearAttempt(attempt); + throw error; + } + + const args = [ + "login", + "--use-device-code", + "--allow-no-subscriptions", + "--output", + "json", + "--only-show-errors", + ]; + if (parsed.tenant) args.push("--tenant", parsed.tenant); + + const child = spawn(/* turbopackIgnore: true */ AZURE_CLI, args, { + env: cliEnvironment(), + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + attempt.process = child; + attempt.timeout = setTimeout(() => { + terminateProcess(child); + store.lastFailure = { + message: "Microsoft sign-in expired. Start a new Azure connection.", + at: Date.now(), + }; + }, LOGIN_TIMEOUT_MS); + + const receive = (chunk: Buffer) => { + attempt.output = boundedAppend(attempt.output, chunk); + const userCode = parseAzureDeviceCodeOutput(attempt.output); + if (userCode) { + attempt.state = { + status: "waiting", + cliAvailable: true, + verificationUrl: DEVICE_LOGIN_URL, + userCode, + expiresAt, + }; + } + }; + child.stdout.on("data", receive); + child.stderr.on("data", receive); + child.once("error", (error) => { + if (store.login !== attempt) return; + if (!attempt.cancelled) { + store.lastFailure = { + message: friendlyFailure(error.message), + at: Date.now(), + }; + } + clearAttempt(attempt); + }); + child.once("close", (code) => { + if (store.login !== attempt) return; + if (code !== 0 && !attempt.cancelled && !store.lastFailure) { + store.lastFailure = { + message: friendlyFailure(attempt.output), + at: Date.now(), + }; + } + clearAttempt(attempt); + }); + return attempt.state; +} + +export async function cancelAzureLogin() { + const attempt = store.login; + if (!attempt) return; + store.lastFailure = null; + attempt.cancelled = true; + if (attempt.timeout) { + clearTimeout(attempt.timeout); + attempt.timeout = null; + } + if (attempt.process) terminateProcess(attempt.process); + await attempt.completed; +} + +export async function disconnectAzure() { + await cancelAzureLogin(); + const logout = await runCommand(["logout", "--only-show-errors"], { + allowFailure: true, + }); + const clear = await runCommand(["account", "clear", "--only-show-errors"], { + allowFailure: true, + }); + const logoutMessage = + `${logout.stdout}\n${logout.stderr}`.toLocaleLowerCase(); + const wasAlreadyDisconnected = + logoutMessage.includes("please run 'az login'") || + logoutMessage.includes('please run "az login"') || + logoutMessage.includes("not logged in"); + if ((logout.code !== 0 && !wasAlreadyDisconnected) || clear.code !== 0) { + throw new AzureAuthenticationError( + "Azure session data could not be cleared. Retry disconnect before removing the Azure CLI volume.", + "AZURE_DISCONNECT_FAILED", + ); + } + store.lastFailure = null; +} + +export async function getKeyVaultAccessToken() { + const result = await runCommand([ + "account", + "get-access-token", + "--resource", + "https://vault.azure.net", + "--output", + "json", + "--only-show-errors", + ]); + try { + const token = accessTokenSchema.parse(JSON.parse(result.stdout)); + return { + accessToken: token.accessToken, + expiresOn: token.expires_on ? Number(token.expires_on) : null, + tenantId: token.tenant, + tokenType: token.tokenType, + }; + } catch { + throw new AzureAuthenticationError( + "Azure CLI returned an invalid access token response.", + "AZURE_CLI_INVALID_RESPONSE", + ); + } +} diff --git a/src/features/authentication/azure/domain.test.ts b/src/features/authentication/azure/domain.test.ts new file mode 100644 index 0000000..1d5cf98 --- /dev/null +++ b/src/features/authentication/azure/domain.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { + azureLoginRequestSchema, + keyVaultSecretReferenceSchema, +} from "./domain"; + +describe("Azure authentication domain", () => { + it("accepts public Azure Key Vault references", () => { + expect( + keyVaultSecretReferenceSchema.parse({ + provider: "azure_key_vault", + vaultUrl: "https://workbench-secrets.vault.azure.net/", + secretName: "client-secret", + version: "0123456789abcdef0123456789abcdef", + }), + ).toEqual({ + provider: "azure_key_vault", + vaultUrl: "https://workbench-secrets.vault.azure.net/", + secretName: "client-secret", + version: "0123456789abcdef0123456789abcdef", + }); + }); + + it.each([ + "http://workbench-secrets.vault.azure.net/", + "https://workbench-secrets.vault.azure.net:444/", + "https://workbench-secrets.vault.azure.net/secrets", + "https://workbench-secrets.vault.azure.net/?redirect=evil", + "https://vault.example.test/", + "https://workbench-secrets.vault.azure.net.evil.test/", + ])("rejects unsafe vault URL %s", (vaultUrl) => { + expect(() => + keyVaultSecretReferenceSchema.parse({ + provider: "azure_key_vault", + vaultUrl, + secretName: "client-secret", + }), + ).toThrow("public Azure Key Vault URL"); + }); + + it("validates optional tenant identifiers", () => { + expect(azureLoginRequestSchema.parse({})).toEqual({ tenant: "" }); + expect( + azureLoginRequestSchema.parse({ tenant: "contoso.onmicrosoft.com" }), + ).toEqual({ tenant: "contoso.onmicrosoft.com" }); + expect(() => + azureLoginRequestSchema.parse({ tenant: "not a tenant" }), + ).toThrow("tenant ID or verified domain"); + }); +}); diff --git a/src/features/authentication/azure/domain.ts b/src/features/authentication/azure/domain.ts new file mode 100644 index 0000000..e497b04 --- /dev/null +++ b/src/features/authentication/azure/domain.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; + +export const azureTenantIdSchema = z + .string() + .trim() + .max(255) + .refine( + (value) => + !value || + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + value, + ) || + /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(value), + "Tenant must be a tenant ID or verified domain.", + ); + +export const azureLoginRequestSchema = z.object({ + tenant: azureTenantIdSchema.default(""), +}); + +export const keyVaultSecretReferenceSchema = z + .object({ + provider: z.literal("azure_key_vault"), + vaultUrl: z.string().trim().max(300), + secretName: z + .string() + .trim() + .min(1, "Secret name is required.") + .max(127) + .regex( + /^[0-9A-Za-z-]+$/, + "Secret name may contain letters, numbers, and hyphens only.", + ), + version: z + .string() + .trim() + .max(64) + .refine( + (value) => !value || /^[0-9a-f]{32}$/i.test(value), + "Secret version must be a 32-character hexadecimal version.", + ) + .default(""), + }) + .superRefine((value, context) => { + let url: URL; + try { + url = new URL(value.vaultUrl); + } catch { + context.addIssue({ + code: "custom", + path: ["vaultUrl"], + message: "Vault URL must be a valid URL.", + }); + return; + } + if ( + url.protocol !== "https:" || + url.port || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash || + !/^[a-z0-9](?:[a-z0-9-]{1,22}[a-z0-9])?\.vault\.azure\.net$/i.test( + url.hostname, + ) + ) { + context.addIssue({ + code: "custom", + path: ["vaultUrl"], + message: + "Vault URL must be a public Azure Key Vault URL such as https://my-vault.vault.azure.net/.", + }); + } + }); + +export type KeyVaultSecretReference = z.infer< + typeof keyVaultSecretReferenceSchema +>; + +export const testKeyVaultReferenceSchema = z.object({ + reference: keyVaultSecretReferenceSchema, +}); + +export interface AzureAccountSummary { + name: string; + username: string; + tenantId: string; + subscriptionId: string; +} + +export type AzureConnectionState = + | { status: "disconnected"; cliAvailable: boolean } + | { + status: "starting" | "waiting"; + cliAvailable: true; + verificationUrl: string | null; + userCode: string | null; + expiresAt: string; + } + | { + status: "connected"; + cliAvailable: true; + account: AzureAccountSummary; + } + | { + status: "failed"; + cliAvailable: boolean; + error: string; + }; + +export class AzureAuthenticationError extends Error { + constructor( + message: string, + public readonly code = "AZURE_AUTHENTICATION_FAILED", + ) { + super(message); + this.name = "AzureAuthenticationError"; + } +} diff --git a/src/features/authentication/azure/http.ts b/src/features/authentication/azure/http.ts new file mode 100644 index 0000000..ecc92cc --- /dev/null +++ b/src/features/authentication/azure/http.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { ZodError } from "zod"; + +import { AzureAuthenticationError } from "./domain"; + +export function assertTrustedMutation(request: Request) { + const contentType = request.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().startsWith("application/json")) { + throw new AzureAuthenticationError( + "Azure requests must use JSON.", + "AZURE_REQUEST_INVALID", + ); + } + const origin = request.headers.get("origin"); + if (origin && origin !== new URL(request.url).origin) { + throw new AzureAuthenticationError( + "Cross-origin Azure requests are not allowed.", + "AZURE_REQUEST_FORBIDDEN", + ); + } +} + +export function azureErrorResponse(error: unknown) { + const message = + error instanceof ZodError + ? (error.issues[0]?.message ?? "Azure configuration is invalid.") + : error instanceof AzureAuthenticationError + ? error.message + : "Azure operation failed."; + const status = + error instanceof AzureAuthenticationError && + error.code === "AZURE_LOGIN_IN_PROGRESS" + ? 409 + : error instanceof AzureAuthenticationError && + error.code === "AZURE_REQUEST_FORBIDDEN" + ? 403 + : error instanceof AzureAuthenticationError && + error.code === "AZURE_CLI_UNAVAILABLE" + ? 503 + : 400; + return NextResponse.json( + { error: message }, + { status, headers: { "Cache-Control": "no-store" } }, + ); +} + +export function azureJson(data: unknown, init?: ResponseInit) { + const headers = new Headers(init?.headers); + headers.set("Cache-Control", "no-store"); + return NextResponse.json(data, { ...init, headers }); +} diff --git a/src/features/authentication/azure/key-vault.test.ts b/src/features/authentication/azure/key-vault.test.ts new file mode 100644 index 0000000..6bc6347 --- /dev/null +++ b/src/features/authentication/azure/key-vault.test.ts @@ -0,0 +1,110 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); +vi.mock("./azure-cli", () => ({ + getKeyVaultAccessToken: vi.fn().mockResolvedValue({ + accessToken: "azure-access-token", + expiresOn: 2_000_000_000, + tenantId: "tenant-id", + tokenType: "Bearer", + }), +})); + +import { resolveKeyVaultSecret } from "./key-vault"; + +const reference = { + provider: "azure_key_vault" as const, + vaultUrl: "https://workbench-secrets.vault.azure.net/", + secretName: "api-secret", + version: "", +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("Azure Key Vault resolution", () => { + it("gets the latest secret without exposing the access token", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ value: "resolved-value" }), + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(resolveKeyVaultSecret(reference)).resolves.toBe( + "resolved-value", + ); + expect(fetchMock).toHaveBeenCalledWith( + new URL( + "https://workbench-secrets.vault.azure.net/secrets/api-secret?api-version=2025-07-01", + ), + expect.objectContaining({ + cache: "no-store", + redirect: "error", + headers: expect.objectContaining({ + Authorization: "Bearer azure-access-token", + }), + }), + ); + }); + + it("pins an exact secret version when configured", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ value: "versioned-value" }), + }); + vi.stubGlobal("fetch", fetchMock); + const version = "0123456789abcdef0123456789abcdef"; + + await resolveKeyVaultSecret({ ...reference, version }); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain( + `/secrets/api-secret/${version}?api-version=2025-07-01`, + ); + }); + + it.each([ + [401, "Reconnect Azure"], + [403, "does not have permission"], + [404, "was not found"], + [429, "throttling"], + [500, "could not resolve"], + ])("maps status %s to a sanitized error", async (status, message) => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status })); + await expect(resolveKeyVaultSecret(reference)).rejects.toThrow(message); + await expect(resolveKeyVaultSecret(reference)).rejects.not.toThrow( + "azure-access-token", + ); + }); + + it.each([ + ["SecretDisabled", "disabled"], + ["SecretExpired", "expired"], + ])( + "returns actionable, sanitized diagnostics for %s", + async (code, message) => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation( + () => + new Response( + JSON.stringify({ + error: { + code: "Forbidden", + message: "internal vault detail azure-access-token", + innererror: { code }, + }, + }), + { status: 403 }, + ), + ), + ); + + await expect(resolveKeyVaultSecret(reference)).rejects.toThrow(message); + await expect(resolveKeyVaultSecret(reference)).rejects.not.toThrow( + "azure-access-token", + ); + }, + ); +}); diff --git a/src/features/authentication/azure/key-vault.ts b/src/features/authentication/azure/key-vault.ts new file mode 100644 index 0000000..dca04c6 --- /dev/null +++ b/src/features/authentication/azure/key-vault.ts @@ -0,0 +1,161 @@ +import "server-only"; + +import { z } from "zod"; + +import { getKeyVaultAccessToken } from "./azure-cli"; +import { + AzureAuthenticationError, + type KeyVaultSecretReference, + keyVaultSecretReferenceSchema, +} from "./domain"; + +const KEY_VAULT_API_VERSION = "2025-07-01"; +const MAX_ERROR_BYTES = 4_096; +const keyVaultResponseSchema = z.object({ value: z.string().max(25_600) }); +const keyVaultErrorSchema = z.object({ + error: z.object({ + code: z.string().max(100).optional(), + innererror: z.object({ code: z.string().max(100).optional() }).optional(), + }), +}); + +function secretUrl(reference: KeyVaultSecretReference) { + const vault = reference.vaultUrl.endsWith("/") + ? reference.vaultUrl + : `${reference.vaultUrl}/`; + const path = [ + "secrets", + encodeURIComponent(reference.secretName), + reference.version ? encodeURIComponent(reference.version) : null, + ] + .filter(Boolean) + .join("/"); + return new URL(`${path}?api-version=${KEY_VAULT_API_VERSION}`, vault); +} + +async function boundedErrorDetails(response: Response) { + if (!response.body) return ""; + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let text = ""; + try { + while (text.length < MAX_ERROR_BYTES) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + } finally { + await reader.cancel().catch(() => undefined); + } + try { + const payload = keyVaultErrorSchema.parse( + JSON.parse(text.slice(0, MAX_ERROR_BYTES)), + ); + return [payload.error.code, payload.error.innererror?.code] + .filter(Boolean) + .join(" ") + .toLocaleLowerCase(); + } catch { + return ""; + } +} + +async function failureForResponse(response: Response) { + const details = await boundedErrorDetails(response); + if (details === "secretdisabled" || details.includes(" secretdisabled")) { + return new AzureAuthenticationError( + "The requested Key Vault secret version is disabled.", + "KEY_VAULT_SECRET_DISABLED", + ); + } + if (details === "secretexpired" || details.includes(" secretexpired")) { + return new AzureAuthenticationError( + "The requested Key Vault secret version has expired.", + "KEY_VAULT_SECRET_EXPIRED", + ); + } + const status = response.status; + if (status === 401) { + return new AzureAuthenticationError( + "Azure sign-in has expired. Reconnect Azure and try again.", + "AZURE_RECONNECT_REQUIRED", + ); + } + if (status === 403) { + return new AzureAuthenticationError( + "The connected Azure user does not have permission to read this secret.", + "KEY_VAULT_FORBIDDEN", + ); + } + if (status === 404) { + return new AzureAuthenticationError( + "The Key Vault secret or version was not found.", + "KEY_VAULT_NOT_FOUND", + ); + } + if (status === 429) { + return new AzureAuthenticationError( + "Azure Key Vault is throttling requests. Try again shortly.", + "KEY_VAULT_THROTTLED", + ); + } + return new AzureAuthenticationError( + "Azure Key Vault could not resolve this secret.", + "KEY_VAULT_FAILED", + ); +} + +export async function resolveKeyVaultSecret( + input: KeyVaultSecretReference, + options: { signal?: AbortSignal } = {}, +) { + const reference = keyVaultSecretReferenceSchema.parse(input); + const token = await getKeyVaultAccessToken(); + const timeout = AbortSignal.timeout(15_000); + const signal = options.signal + ? AbortSignal.any([options.signal, timeout]) + : timeout; + let response: Response; + try { + response = await fetch(secretUrl(reference), { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `${token.tokenType} ${token.accessToken}`, + }, + cache: "no-store", + redirect: "error", + signal, + }); + } catch (error) { + if (error instanceof AzureAuthenticationError) throw error; + throw new AzureAuthenticationError( + "Azure Key Vault could not be reached.", + "KEY_VAULT_UNREACHABLE", + ); + } + if (!response.ok) throw await failureForResponse(response); + try { + const payload = keyVaultResponseSchema.parse(await response.json()); + if (!payload.value) { + throw new AzureAuthenticationError( + "Azure Key Vault returned an empty secret value.", + "KEY_VAULT_EMPTY", + ); + } + return payload.value; + } catch (error) { + if (error instanceof AzureAuthenticationError) throw error; + throw new AzureAuthenticationError( + "Azure Key Vault returned an invalid secret response.", + "KEY_VAULT_INVALID_RESPONSE", + ); + } +} + +export async function testKeyVaultSecretReference( + reference: KeyVaultSecretReference, +) { + await resolveKeyVaultSecret(reference); + return { ok: true as const }; +} diff --git a/src/features/authentication/data/auth-repository.ts b/src/features/authentication/data/auth-repository.ts index 8790432..2abe50e 100644 --- a/src/features/authentication/data/auth-repository.ts +++ b/src/features/authentication/data/auth-repository.ts @@ -20,6 +20,7 @@ import { AuthDomainError, authSecretFields, type EffectiveAuthProfile, + normaliseReferencedSecrets, parseAuthConfiguration, } from "../domain"; @@ -77,6 +78,13 @@ function mergeConfiguration( >) { if (authSecretFields.has(key) && value === AUTH_SECRET_PLACEHOLDER) continue; + if (key === "secretReferences" && value && typeof value === "object") { + result.secretReferences = { + ...result.secretReferences, + ...value, + }; + continue; + } Object.assign(result, { [key]: value }); } return parseAuthConfiguration(result); @@ -309,7 +317,7 @@ export async function saveAuthProfile(input: { tokenRequestId: input.tokenRequestId, name: input.name, type: input.type, - configuration: input.configuration, + configuration: normaliseReferencedSecrets(input.configuration), }) .returning({ id: authProfiles.id }); if (!profile) diff --git a/src/features/authentication/domain.test.ts b/src/features/authentication/domain.test.ts new file mode 100644 index 0000000..ea5c394 --- /dev/null +++ b/src/features/authentication/domain.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { + defaultAuthConfiguration, + parseAuthConfiguration, + secretFieldsForAuthType, +} from "./domain"; + +describe("authentication secret references", () => { + it("keeps existing profiles backward compatible", () => { + const configuration = parseAuthConfiguration({ token: "local-token" }); + expect(configuration.token).toBe("local-token"); + expect(configuration.secretReferences).toEqual({ + token: null, + password: null, + key: null, + clientSecret: null, + refreshToken: null, + }); + }); + + it("clears stored values when an Azure reference owns the field", () => { + const configuration = parseAuthConfiguration({ + ...defaultAuthConfiguration(), + clientSecret: "must-not-remain", + secretReferences: { + clientSecret: { + provider: "azure_key_vault", + vaultUrl: "https://workbench-secrets.vault.azure.net/", + secretName: "oauth-secret", + version: "", + }, + }, + }); + expect(configuration.clientSecret).toBe(""); + expect(configuration.secretReferences.clientSecret?.secretName).toBe( + "oauth-secret", + ); + }); + + it("resolves only fields used by each authentication type", () => { + expect(secretFieldsForAuthType("bearer")).toEqual(["token"]); + expect(secretFieldsForAuthType("oauth2_password")).toEqual([ + "clientSecret", + "password", + ]); + expect(secretFieldsForAuthType("request_derived")).toEqual([]); + }); +}); diff --git a/src/features/authentication/domain.ts b/src/features/authentication/domain.ts index 5193cd6..bd7c9ac 100644 --- a/src/features/authentication/domain.ts +++ b/src/features/authentication/domain.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { maskSecret } from "@/core/secrets/redaction"; +import { keyVaultSecretReferenceSchema } from "@/features/authentication/azure/domain"; import { entityIdSchema, entityNameSchema } from "@/features/workspaces/domain"; export const authTypes = [ @@ -20,6 +21,32 @@ export type AuthType = (typeof authTypes)[number]; const configurationValue = z.string().max(8_192).default(""); +export const authSecretFieldNames = [ + "token", + "password", + "key", + "clientSecret", + "refreshToken", +] as const; + +export type AuthSecretField = (typeof authSecretFieldNames)[number]; + +export const authSecretReferencesSchema = z + .object({ + token: keyVaultSecretReferenceSchema.nullable().default(null), + password: keyVaultSecretReferenceSchema.nullable().default(null), + key: keyVaultSecretReferenceSchema.nullable().default(null), + clientSecret: keyVaultSecretReferenceSchema.nullable().default(null), + refreshToken: keyVaultSecretReferenceSchema.nullable().default(null), + }) + .default({ + token: null, + password: null, + key: null, + clientSecret: null, + refreshToken: null, + }); + export const authProfileConfigurationSchema = z.object({ token: configurationValue, username: configurationValue, @@ -42,6 +69,7 @@ export const authProfileConfigurationSchema = z.object({ injectionTarget: z.enum(["header", "query"]).default("header"), injectionName: configurationValue, failureBehavior: z.enum(["stop", "continue_without_auth"]).default("stop"), + secretReferences: authSecretReferencesSchema, }); export type AuthProfileConfiguration = z.infer< @@ -116,13 +144,39 @@ export interface AuthenticationTrace { } export const AUTH_SECRET_PLACEHOLDER = maskSecret("secret"); -export const authSecretFields = new Set([ - "token", - "password", - "key", - "clientSecret", - "refreshToken", -]); +export const authSecretFields = new Set( + authSecretFieldNames, +); + +export function secretFieldsForAuthType(type: AuthType): AuthSecretField[] { + switch (type) { + case "bearer": + return ["token"]; + case "basic": + return ["password"]; + case "api_key_header": + case "api_key_query": + return ["key"]; + case "oauth2_client_credentials": + return ["clientSecret"]; + case "oauth2_password": + return ["clientSecret", "password"]; + case "oauth2_refresh_token": + return ["clientSecret", "refreshToken"]; + default: + return []; + } +} + +export function normaliseReferencedSecrets( + configuration: AuthProfileConfiguration, +) { + const result = structuredClone(configuration); + for (const key of authSecretFieldNames) { + if (result.secretReferences[key]) result[key] = ""; + } + return result; +} export function defaultAuthConfiguration(): AuthProfileConfiguration { return authProfileConfigurationSchema.parse({ @@ -141,10 +195,11 @@ export function defaultAuthConfiguration(): AuthProfileConfiguration { } export function parseAuthConfiguration(value: unknown) { - return authProfileConfigurationSchema.parse({ + const parsed = authProfileConfigurationSchema.parse({ ...defaultAuthConfiguration(), ...(value && typeof value === "object" ? value : {}), }); + return normaliseReferencedSecrets(parsed); } export class AuthDomainError extends Error { diff --git a/src/features/authentication/injection.test.ts b/src/features/authentication/injection.test.ts index 3dd3832..c741490 100644 --- a/src/features/authentication/injection.test.ts +++ b/src/features/authentication/injection.test.ts @@ -47,23 +47,29 @@ function profile( describe("authentication injection", () => { it("injects bearer, basic, and API key credentials as secrets", () => { - expect( - injectAuthentication(plan, profile("bearer", { token: "token-1" })).plan - .headers, - ).toEqual([ + const bearer = injectAuthentication( + plan, + profile("bearer", { token: "token-1" }), + ).plan; + expect(bearer.headers).toEqual([ expect.objectContaining({ name: "Authorization", value: "Bearer token-1", secret: true, }), ]); + expect(bearer.secretValues).toEqual( + expect.arrayContaining(["Bearer token-1", "token-1"]), + ); - expect( - injectAuthentication( - plan, - profile("basic", { username: "worker", password: "secret" }), - ).plan.headers[0]?.value, - ).toBe(`Basic ${Buffer.from("worker:secret").toString("base64")}`); + const basic = injectAuthentication( + plan, + profile("basic", { username: "worker", password: "secret" }), + ).plan; + expect(basic.headers[0]?.value).toBe( + `Basic ${Buffer.from("worker:secret").toString("base64")}`, + ); + expect(basic.secretValues).toContain("secret"); expect( injectAuthentication( diff --git a/src/features/authentication/injection.ts b/src/features/authentication/injection.ts index 5bd727f..c93b80e 100644 --- a/src/features/authentication/injection.ts +++ b/src/features/authentication/injection.ts @@ -67,6 +67,7 @@ export function injectAuthentication( let value = ""; let target: "header" | "query" | null = null; let name = ""; + const rawSecrets: string[] = []; let source: AuthenticationTrace["source"] = credential?.source ?? "configured"; @@ -74,13 +75,15 @@ export function injectAuthentication( case "none": break; case "bearer": - value = `${config.tokenPrefix || "Bearer"} ${required(config.token, "Bearer token")}`; + rawSecrets.push(required(config.token, "Bearer token")); + value = `${config.tokenPrefix || "Bearer"} ${config.token}`; target = "header"; name = config.headerName || "Authorization"; break; case "basic": { const username = required(config.username, "Username"); const password = required(config.password, "Password"); + rawSecrets.push(password); value = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; target = "header"; name = config.headerName || "Authorization"; @@ -88,11 +91,13 @@ export function injectAuthentication( } case "api_key_header": value = required(config.key, "API key"); + rawSecrets.push(config.key); target = "header"; name = required(config.headerName, "Header name"); break; case "api_key_query": value = required(config.key, "API key"); + rawSecrets.push(config.key); target = "query"; name = required(config.queryName, "Query parameter name"); break; @@ -119,9 +124,12 @@ export function injectAuthentication( ? replaceQuery(plan, name, value) : plan.queryParameters, secretValues: value - ? [...(plan.secretValues ?? []), value, credential?.value ?? ""].filter( - Boolean, - ) + ? [ + ...(plan.secretValues ?? []), + value, + credential?.value ?? "", + ...rawSecrets, + ].filter(Boolean) : plan.secretValues, }; diff --git a/src/features/authentication/resolution.test.ts b/src/features/authentication/resolution.test.ts new file mode 100644 index 0000000..664efa3 --- /dev/null +++ b/src/features/authentication/resolution.test.ts @@ -0,0 +1,270 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const mocks = vi.hoisted(() => ({ + executeHttpRequest: vi.fn(), + getCachedToken: vi.fn(), + getEffectiveAuthProfile: vi.fn(), + getLatestRequestOutput: vi.fn(), + resolveKeyVaultSecret: vi.fn(), + saveCachedToken: vi.fn(), +})); + +vi.mock("@/features/authentication/data/auth-repository", () => ({ + getCachedToken: mocks.getCachedToken, + getEffectiveAuthProfile: mocks.getEffectiveAuthProfile, + saveCachedToken: mocks.saveCachedToken, +})); +vi.mock("@/features/authentication/azure/key-vault", () => ({ + resolveKeyVaultSecret: mocks.resolveKeyVaultSecret, +})); +vi.mock("@/features/request-outputs/data/request-output-repository", () => ({ + getLatestRequestOutput: mocks.getLatestRequestOutput, +})); +vi.mock("@/features/requests/execution/http-engine", () => ({ + executeHttpRequest: mocks.executeHttpRequest, +})); + +import { type EffectiveAuthProfile, defaultAuthConfiguration } from "./domain"; +import { resolveAuthentication } from "./resolution"; +import type { RequestPlan } from "../requests/execution/http-engine"; + +const reference = { + provider: "azure_key_vault" as const, + vaultUrl: "https://workbench-secrets.vault.azure.net/", + secretName: "credential", + version: "", +}; + +const plan: RequestPlan = { + id: "request-id", + projectId: "project-id", + method: "GET", + url: "https://api.example.test", + queryParameters: [], + headers: [], + body: { type: "none", content: null, contentType: null, metadata: {} }, + settings: { + timeoutMs: 1_000, + followRedirects: true, + maxRedirects: 5, + tlsVerify: true, + maxResponseBytes: 100_000, + allowPrivateNetwork: false, + cookies: [], + }, +}; + +function profile( + type: EffectiveAuthProfile["type"], + configuration: Partial, +): EffectiveAuthProfile { + return { + id: "profile-id", + workspaceId: "workspace-id", + projectId: null, + tokenRequestId: null, + name: "Azure profile", + type, + configuration: { + ...defaultAuthConfiguration(), + ...configuration, + }, + inherited: true, + overridden: false, + }; +} + +async function resolve() { + return resolveAuthentication({ + authProfileId: "profile-id", + projectId: "project-id", + plan, + variableDefinitions: [], + signal: new AbortController().signal, + executeTokenRequest: vi.fn(), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveKeyVaultSecret.mockResolvedValue("key-vault-secret"); + mocks.getCachedToken.mockResolvedValue(null); +}); + +describe("Azure-backed authentication resolution", () => { + it("resolves a direct secret only for the active authentication field", async () => { + mocks.getEffectiveAuthProfile.mockResolvedValue( + profile("bearer", { + secretReferences: { + ...defaultAuthConfiguration().secretReferences, + token: reference, + password: { ...reference, secretName: "unused-password" }, + }, + }), + ); + + const result = await resolve(); + expect(mocks.resolveKeyVaultSecret).toHaveBeenCalledTimes(1); + expect(mocks.resolveKeyVaultSecret).toHaveBeenCalledWith( + reference, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + expect(result.plan.headers[0]).toMatchObject({ + name: "Authorization", + value: "Bearer key-vault-secret", + secret: true, + }); + expect(result.trace?.credential).toBe("••••••••"); + }); + + it("does not contact Key Vault while a cached OAuth token is fresh", async () => { + mocks.getEffectiveAuthProfile.mockResolvedValue( + profile("oauth2_client_credentials", { + tokenUrl: "https://login.example.test/token", + clientId: "client-id", + secretReferences: { + ...defaultAuthConfiguration().secretReferences, + clientSecret: reference, + }, + }), + ); + mocks.getCachedToken.mockResolvedValue({ + accessToken: "cached-access-token", + refreshToken: null, + tokenType: "Bearer", + expiresAt: new Date(Date.now() + 120_000), + }); + + const result = await resolve(); + expect(mocks.resolveKeyVaultSecret).not.toHaveBeenCalled(); + expect(mocks.executeHttpRequest).not.toHaveBeenCalled(); + expect(result.plan.headers[0]?.value).toBe("Bearer cached-access-token"); + }); + + it("resolves the Key Vault client secret immediately before OAuth renewal", async () => { + mocks.getEffectiveAuthProfile.mockResolvedValue( + profile("oauth2_client_credentials", { + tokenUrl: "https://login.example.test/token", + clientId: "client-id", + secretReferences: { + ...defaultAuthConfiguration().secretReferences, + clientSecret: reference, + }, + }), + ); + mocks.executeHttpRequest.mockResolvedValue({ + statusCode: 200, + rawBody: JSON.stringify({ + access_token: "new-access-token", + expires_in: 3600, + token_type: "Bearer", + }), + }); + + const result = await resolve(); + expect(mocks.resolveKeyVaultSecret).toHaveBeenCalledOnce(); + expect(mocks.executeHttpRequest).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + content: expect.stringContaining("client_secret=key-vault-secret"), + }), + secretValues: expect.arrayContaining(["key-vault-secret"]), + }), + expect.any(AbortSignal), + ); + expect(mocks.saveCachedToken).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: "new-access-token" }), + ); + expect(result.plan.headers[0]?.value).toBe("Bearer new-access-token"); + }); + + it("does not read an unused password reference when renewing with a cached refresh token", async () => { + const passwordReference = { ...reference, secretName: "password" }; + mocks.getEffectiveAuthProfile.mockResolvedValue( + profile("oauth2_password", { + tokenUrl: "https://login.example.test/token", + clientId: "client-id", + username: "person@example.test", + secretReferences: { + ...defaultAuthConfiguration().secretReferences, + clientSecret: reference, + password: passwordReference, + }, + }), + ); + mocks.getCachedToken.mockResolvedValue({ + accessToken: "stale-access-token", + refreshToken: "cached-refresh-token", + tokenType: "Bearer", + expiresAt: new Date(Date.now() - 60_000), + }); + mocks.executeHttpRequest.mockResolvedValue({ + statusCode: 200, + rawBody: JSON.stringify({ + access_token: "renewed-access-token", + expires_in: 3600, + }), + }); + + await resolve(); + + expect(mocks.resolveKeyVaultSecret).toHaveBeenCalledOnce(); + expect(mocks.resolveKeyVaultSecret).toHaveBeenCalledWith( + reference, + expect.any(Object), + ); + expect(mocks.executeHttpRequest).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + content: expect.stringContaining( + "refresh_token=cached-refresh-token", + ), + }), + }), + expect.any(AbortSignal), + ); + }); + + it("never caches a Key Vault-backed configured refresh token", async () => { + const refreshReference = { ...reference, secretName: "refresh-token" }; + mocks.getEffectiveAuthProfile.mockResolvedValue( + profile("oauth2_refresh_token", { + tokenUrl: "https://login.example.test/token", + clientId: "client-id", + secretReferences: { + ...defaultAuthConfiguration().secretReferences, + clientSecret: reference, + refreshToken: refreshReference, + }, + }), + ); + mocks.resolveKeyVaultSecret.mockImplementation(async (secretReference) => + secretReference.secretName === "refresh-token" + ? "vault-refresh-token" + : "vault-client-secret", + ); + mocks.executeHttpRequest.mockResolvedValue({ + statusCode: 200, + rawBody: JSON.stringify({ + access_token: "new-access-token", + expires_in: 3600, + }), + }); + + await resolve(); + + expect(mocks.executeHttpRequest).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + content: expect.stringContaining("refresh_token=vault-refresh-token"), + }), + }), + expect.any(AbortSignal), + ); + expect(mocks.saveCachedToken).toHaveBeenCalledWith( + expect.objectContaining({ refreshToken: null }), + ); + }); +}); diff --git a/src/features/authentication/resolution.ts b/src/features/authentication/resolution.ts index d4f9186..fc9a1fb 100644 --- a/src/features/authentication/resolution.ts +++ b/src/features/authentication/resolution.ts @@ -15,7 +15,10 @@ import { AuthDomainError, type EffectiveAuthProfile, parseAuthConfiguration, + secretFieldsForAuthType, } from "@/features/authentication/domain"; +import { resolveKeyVaultSecret } from "@/features/authentication/azure/key-vault"; +import { AzureAuthenticationError } from "@/features/authentication/azure/domain"; import { calculateTokenExpiry, injectAuthentication, @@ -56,6 +59,21 @@ function interpolateConfiguration( return { ...profile, configuration: parseAuthConfiguration(configuration) }; } +async function resolveReferencedSecrets( + profile: EffectiveAuthProfile, + signal: AbortSignal, + fields = secretFieldsForAuthType(profile.type), +): Promise { + const configuration = structuredClone(profile.configuration); + for (const key of fields) { + const reference = configuration.secretReferences[key]; + if (reference) { + configuration[key] = await resolveKeyVaultSecret(reference, { signal }); + } + } + return { ...profile, configuration }; +} + function formContent(values: Record) { return Object.entries(values) .filter(([, value]) => Boolean(value)) @@ -90,11 +108,10 @@ async function requestOAuthToken( profile: EffectiveAuthProfile, sourcePlan: RequestPlan, signal: AbortSignal, + cached: Awaited>, ) { const config = profile.configuration; - const cached = await getCachedToken(profile.id, sourcePlan.projectId); - const canRefresh = Boolean(cached?.refreshToken || config.refreshToken); - const grantType = canRefresh + const grantType = cached?.refreshToken ? "refresh_token" : profile.type === "oauth2_password" ? "password" @@ -143,7 +160,7 @@ async function requestOAuthToken( settings: sourcePlan.settings, secretValues: [ config.clientSecret, - config.password, + fields.password ?? "", fields.refresh_token ?? "", ].filter(Boolean), }, @@ -178,7 +195,7 @@ async function requestOAuthToken( false, ) ?? cached?.refreshToken ?? - config.refreshToken ?? + (config.secretReferences.refreshToken ? null : config.refreshToken) ?? null; const expiresIn = jsonPathValue( document, @@ -211,7 +228,7 @@ export async function resolveAuthentication(input: { executeTokenRequest: (requestId: string) => Promise; }): Promise<{ plan: RequestPlan; trace: AuthenticationTrace | null }> { if (!input.authProfileId) return { plan: input.plan, trace: null }; - const profile = interpolateConfiguration( + let profile = interpolateConfiguration( await getEffectiveAuthProfile(input.authProfileId, input.projectId), input.variableDefinitions, ); @@ -223,6 +240,7 @@ export async function resolveAuthentication(input: { profile.type !== "oauth2_refresh_token" && profile.type !== "request_derived" ) { + profile = await resolveReferencedSecrets(profile, input.signal); return injectAuthentication(input.plan, profile); } @@ -267,7 +285,27 @@ export async function resolveAuthentication(input: { source: "cache", }); } - const token = await requestOAuthToken(profile, input.plan, input.signal); + const usesCachedRefreshToken = Boolean(cached?.refreshToken); + const neededSecretFields = [ + "clientSecret" as const, + ...(!usesCachedRefreshToken && profile.type === "oauth2_password" + ? (["password"] as const) + : []), + ...(!usesCachedRefreshToken && profile.type === "oauth2_refresh_token" + ? (["refreshToken"] as const) + : []), + ]; + profile = await resolveReferencedSecrets( + profile, + input.signal, + neededSecretFields, + ); + const token = await requestOAuthToken( + profile, + input.plan, + input.signal, + cached, + ); return injectAuthentication(input.plan, profile, { value: token.accessToken, prefix: token.tokenType, @@ -287,6 +325,9 @@ export async function resolveAuthentication(input: { }, }; } + if (error instanceof AzureAuthenticationError) { + throw new AuthDomainError(error.message, error.code); + } throw error; } } diff --git a/src/features/exports/archive.test.ts b/src/features/exports/archive.test.ts index 24c8491..28d9130 100644 --- a/src/features/exports/archive.test.ts +++ b/src/features/exports/archive.test.ts @@ -120,6 +120,52 @@ describe("versioned export archives", () => { ); }); + it("preserves Key Vault references without exporting resolved values", async () => { + const { result, workspaceId } = tables(); + result.authProfiles.push({ + id: randomUUID(), + workspaceId, + projectId: null, + tokenRequestId: null, + name: "Azure bearer", + type: "bearer", + configuration: { + token: "resolved-value-must-not-persist", + secretReferences: { + token: { + provider: "azure_key_vault", + vaultUrl: "https://workbench-secrets.vault.azure.net/", + secretName: "bearer-token", + version: "", + }, + }, + }, + }); + const { archive, manifest } = await createExportArchive({ + tables: result, + kind: "workspace", + scope: { id: workspaceId, name: "Portable" }, + secretMode: "exclude", + }); + + expect(manifest.appVersion).toBe("1.1.0"); + expect(Buffer.from(archive).includes(Buffer.from("resolved-value"))).toBe( + false, + ); + const parsed = await parseExportArchive(archive); + expect(parsed.data.tables.authProfiles[0]?.configuration).toEqual({ + token: "", + secretReferences: { + token: { + provider: "azure_key_vault", + vaultUrl: "https://workbench-secrets.vault.azure.net/", + secretName: "bearer-token", + version: "", + }, + }, + }); + }); + it("round-trips encrypted secrets only with the right password", async () => { const { result, workspaceId } = tables(); const { archive } = await createExportArchive({ diff --git a/src/features/exports/archive.ts b/src/features/exports/archive.ts index aaf350f..c23fab2 100644 --- a/src/features/exports/archive.ts +++ b/src/features/exports/archive.ts @@ -3,6 +3,8 @@ import { createHash } from "node:crypto"; import { strFromU8, strToU8, unzipSync, zipSync } from "fflate"; import { z } from "zod"; +import packageMetadata from "../../../package.json"; + import { authSecretFields } from "@/features/authentication/domain"; import { archiveDataSchema, @@ -350,7 +352,7 @@ export async function createExportArchive(input: { format: EXPORT_FORMAT, version: EXPORT_VERSION, schemaVersion: EXPORT_SCHEMA_VERSION, - appVersion: "0.1.0", + appVersion: packageMetadata.version, kind: input.kind, createdAt: (input.createdAt ?? new Date()).toISOString(), secretMode: input.secretMode, diff --git a/src/features/request-outputs/data/request-output-repository.ts b/src/features/request-outputs/data/request-output-repository.ts index 210e009..a393fc2 100644 --- a/src/features/request-outputs/data/request-output-repository.ts +++ b/src/features/request-outputs/data/request-output-repository.ts @@ -84,6 +84,7 @@ export async function persistRequestOutputs(input: { requestId: string; executionId: string; rawBody: string | null; + knownSecrets?: readonly string[]; }) { const definitions = await getDatabase() .select() @@ -92,7 +93,16 @@ export async function persistRequestOutputs(input: { .orderBy(asc(requestOutputDefinitions.position)); if (!definitions.length) return []; const document = parseJsonResponse(input.rawBody ?? ""); - const outputs = extractRequestOutputs(document, definitions); + const knownSecrets = (input.knownSecrets ?? []).filter(Boolean); + const outputs = extractRequestOutputs(document, definitions).filter( + (output) => + !knownSecrets.some( + (secret) => + output.value.includes(secret) || + output.value.includes(encodeURIComponent(secret)), + ), + ); + if (!outputs.length) return []; await getDatabase() .insert(runtimeOutputs) .values( diff --git a/src/features/requests/execution/request-executor.ts b/src/features/requests/execution/request-executor.ts index 30f3543..03205b7 100644 --- a/src/features/requests/execution/request-executor.ts +++ b/src/features/requests/execution/request-executor.ts @@ -239,6 +239,7 @@ export async function executeSavedRequest(input: { requestId: saved.id, executionId: input.executionId, rawBody: response.rawBody, + knownSecrets: plan.secretValues, }); const assertionResults = evaluateAssertions( response, diff --git a/src/features/workbench/components/auth-profile-manager.test.tsx b/src/features/workbench/components/auth-profile-manager.test.tsx index fe6c1f5..b6db4ad 100644 --- a/src/features/workbench/components/auth-profile-manager.test.tsx +++ b/src/features/workbench/components/auth-profile-manager.test.tsx @@ -44,10 +44,13 @@ describe("AuthProfileManager", () => { }; vi.stubGlobal( "fetch", - vi.fn().mockResolvedValue({ + vi.fn(async (input: RequestInfo | URL) => ({ ok: true, - json: async () => structuredClone(configuration), - }), + json: async () => + String(input).includes("/azure") + ? { status: "disconnected", cliAvailable: true } + : structuredClone(configuration), + })), ); vi.mocked(saveAuthProfileAction).mockResolvedValue({ ok: true, @@ -88,4 +91,84 @@ describe("AuthProfileManager", () => { ), ); }); + + it("saves an Azure Key Vault source without a stored secret", async () => { + const user = userEvent.setup(); + const configuration = { + profiles: [ + { + id: "a47ac10b-58cc-4372-a567-0e02b2c3d479", + workspaceId: null, + projectId: "b47ac10b-58cc-4372-a567-0e02b2c3d479", + tokenRequestId: null, + name: "Bearer profile", + type: "bearer" as const, + configuration: { + ...defaultAuthConfiguration(), + token: "••••••••", + }, + inherited: false, + overridden: false, + }, + ], + tokenRequests: [], + }; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => ({ + ok: true, + json: async () => + String(input).includes("/azure") + ? { status: "disconnected", cliAvailable: true } + : structuredClone(configuration), + })), + ); + vi.mocked(saveAuthProfileAction).mockResolvedValue({ + ok: true, + data: { id: configuration.profiles[0].id }, + }); + + render( + , + ); + + await screen.findByDisplayValue("Bearer profile"); + await user.selectOptions( + screen.getByLabelText("Source"), + "azure_key_vault", + ); + await user.type( + screen.getByLabelText("Vault URL"), + "https://workbench-secrets.vault.azure.net/", + ); + await user.type(screen.getByLabelText("Secret name"), "bearer-token"); + await user.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => + expect(saveAuthProfileAction).toHaveBeenCalledWith( + expect.objectContaining({ + configuration: expect.objectContaining({ + token: "", + secretReferences: expect.objectContaining({ + token: expect.objectContaining({ + provider: "azure_key_vault", + vaultUrl: "https://workbench-secrets.vault.azure.net/", + secretName: "bearer-token", + }), + }), + }), + }), + ), + ); + }); }); diff --git a/src/features/workbench/components/auth-profile-manager.tsx b/src/features/workbench/components/auth-profile-manager.tsx index e3a07d2..e0d4ed9 100644 --- a/src/features/workbench/components/auth-profile-manager.tsx +++ b/src/features/workbench/components/auth-profile-manager.tsx @@ -1,9 +1,17 @@ "use client"; -import { ArrowLeft, LoaderCircle, Plus, Save, Trash2 } from "lucide-react"; +import { + ArrowLeft, + CheckCircle2, + LoaderCircle, + Plus, + Save, + Trash2, +} from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; +import type { AzureConnectionState } from "@/features/authentication/azure/domain"; import { deleteAuthProfileAction, saveAuthOverrideAction, @@ -13,12 +21,15 @@ import { type AuthConfiguration, type AuthProfileConfiguration, type AuthProfileDetail, + type AuthSecretField, type AuthType, authTypes, defaultAuthConfiguration, } from "@/features/authentication/domain"; import { cn } from "@/lib/utils"; +import { AzureConnection } from "./azure-connection"; + type Draft = Omit & { inherited: boolean; overridden: boolean; @@ -90,6 +101,186 @@ function TextField({ ); } +function SecretField({ + configuration, + label, + name, + onChange, +}: { + configuration: AuthProfileConfiguration; + label: string; + name: AuthSecretField; + onChange: (configuration: AuthProfileConfiguration) => void; +}) { + const reference = configuration.secretReferences[name]; + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ + ok: boolean; + message: string; + } | null>(null); + + const updateReference = (changes: Partial>) => { + if (!reference) return; + onChange({ + ...configuration, + secretReferences: { + ...configuration.secretReferences, + [name]: { ...reference, ...changes }, + }, + }); + setTestResult(null); + }; + + const testReference = async () => { + if (!reference) return; + setTesting(true); + setTestResult(null); + try { + const response = await fetch("/api/configuration/azure/key-vault", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reference }), + }); + const payload = (await response.json()) as + { ok: true } | { error: string }; + if (!response.ok || "error" in payload) { + throw new Error( + "error" in payload ? payload.error : "Reference test failed.", + ); + } + setTestResult({ ok: true, message: "Secret reference is accessible." }); + } catch (error) { + setTestResult({ + ok: false, + message: + error instanceof Error ? error.message : "Reference test failed.", + }); + } finally { + setTesting(false); + } + }; + + return ( +
+
+

{label}

+ +
+ {reference ? ( +
+ + + +
+ +

+ Without a version, requests use the latest secret. +

+
+ {testResult ? ( +

+ {testResult.message} +

+ ) : null} +
+ ) : ( + + onChange({ ...configuration, [name]: event.target.value }) + } + type="password" + value={configuration[name]} + /> + )} +
+ ); +} + export function AuthProfileManager({ onClose, project, @@ -104,6 +295,8 @@ export function AuthProfileManager({ ); const [draft, setDraft] = useState(null); const [busy, setBusy] = useState(false); + const [azureConnection, setAzureConnection] = + useState(null); const [notice, setNotice] = useState<{ tone: "success" | "error"; text: string; @@ -247,6 +440,8 @@ export function AuthProfileManager({ ) : null} + + {!configuration || !draft || !config ? (
))} @@ -352,12 +552,11 @@ export function AuthProfileManager({ {draft.type === "bearer" ? ( <> - - - - - ) : null} {draft.type === "oauth2_refresh_token" ? ( - ) : null} { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("AzureConnection", () => { + it("starts and cancels device-code login entirely from the UI", async () => { + const user = userEvent.setup(); + const fetchMock = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/login") && init?.method === "POST") { + return { + ok: true, + json: async () => ({ + status: "waiting", + cliAvailable: true, + verificationUrl: "https://microsoft.com/devicelogin", + userCode: "AB12-CD34", + expiresAt: "2026-07-19T15:00:00.000Z", + }), + }; + } + if (url.endsWith("/login") && init?.method === "DELETE") { + return { ok: true, json: async () => ({ ok: true }) }; + } + return { + ok: true, + json: async () => ({ status: "disconnected", cliAvailable: true }), + }; + }, + ); + vi.stubGlobal("fetch", fetchMock); + + render(); + await user.click( + await screen.findByRole("button", { name: "Connect Azure" }), + ); + await user.type( + screen.getByLabelText(/Tenant ID or domain/), + "contoso.onmicrosoft.com", + ); + await user.click(screen.getByRole("button", { name: "Start sign-in" })); + + expect(await screen.findByText("AB12-CD34")).toBeVisible(); + expect(fetchMock).toHaveBeenCalledWith( + "/api/configuration/azure/login", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ tenant: "contoso.onmicrosoft.com" }), + }), + ); + + await user.click(screen.getByRole("button", { name: "Cancel sign-in" })); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + "/api/configuration/azure/login", + expect.objectContaining({ method: "DELETE" }), + ), + ); + }); + + it("shows only sanitized connected account metadata", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + status: "connected", + cliAvailable: true, + account: { + name: "Personal subscription", + username: "user@example.test", + tenantId: "00000000-0000-0000-0000-000000000000", + subscriptionId: "11111111-1111-1111-1111-111111111111", + }, + }), + }), + ); + + render(); + expect(await screen.findByText("user@example.test")).toBeVisible(); + expect(screen.getByText(/Personal subscription/)).toBeVisible(); + expect(screen.queryByText(/access.?token/i)).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/workbench/components/azure-connection.tsx b/src/features/workbench/components/azure-connection.tsx new file mode 100644 index 0000000..b9222d3 --- /dev/null +++ b/src/features/workbench/components/azure-connection.tsx @@ -0,0 +1,402 @@ +"use client"; + +import { + Check, + Cloud, + Copy, + ExternalLink, + LoaderCircle, + LogOut, + X, +} from "lucide-react"; +import { Dialog } from "radix-ui"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import type { AzureConnectionState } from "@/features/authentication/azure/domain"; +import { cn } from "@/lib/utils"; + +async function requestAzure( + path: string, + init?: RequestInit, +): Promise { + const response = await fetch(path, { cache: "no-store", ...init }); + const payload = (await response.json()) as + AzureConnectionState | { error: string }; + if (!response.ok || "error" in payload) { + throw new Error( + "error" in payload ? payload.error : "Azure operation failed.", + ); + } + return payload; +} + +const jsonMutation = (method: "POST" | "DELETE", body: unknown = {}) => ({ + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), +}); + +export function AzureConnection({ + onStateChange, +}: { + onStateChange?: (state: AzureConnectionState | null) => void; +}) { + const [state, setState] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [tenant, setTenant] = useState(""); + const [busy, setBusy] = useState(false); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const pollTimer = useRef | null>(null); + + useEffect(() => { + onStateChange?.(state); + }, [onStateChange, state]); + + const load = useCallback(async () => { + const next = await requestAzure("/api/configuration/azure"); + setState(next); + return next; + }, []); + + useEffect(() => { + let active = true; + requestAzure("/api/configuration/azure") + .then((next) => { + if (active) setState(next); + }) + .catch((caught: unknown) => { + if (active) + setError( + caught instanceof Error + ? caught.message + : "Azure connection could not be loaded.", + ); + }); + return () => { + active = false; + if (pollTimer.current) clearTimeout(pollTimer.current); + }; + }, [load]); + + useEffect(() => { + if ( + !dialogOpen || + !state || + !["starting", "waiting"].includes(state.status) + ) + return; + let active = true; + const poll = async () => { + try { + const next = await load(); + if (!active) return; + if (next.status === "connected") { + setDialogOpen(false); + setError(null); + return; + } + if (next.status === "starting" || next.status === "waiting") { + pollTimer.current = setTimeout(() => void poll(), 1_250); + } + } catch (caught) { + if (active) + setError( + caught instanceof Error + ? caught.message + : "Azure sign-in status could not be checked.", + ); + } + }; + pollTimer.current = setTimeout(() => void poll(), 500); + return () => { + active = false; + if (pollTimer.current) clearTimeout(pollTimer.current); + }; + }, [dialogOpen, load, state]); + + const connect = async () => { + setBusy(true); + setError(null); + setCopied(false); + try { + const next = await requestAzure( + "/api/configuration/azure/login", + jsonMutation("POST", { tenant }), + ); + setState(next); + setDialogOpen(true); + } catch (caught) { + setError( + caught instanceof Error ? caught.message : "Azure sign-in failed.", + ); + } finally { + setBusy(false); + } + }; + + const cancel = async () => { + setBusy(true); + try { + await requestAzure( + "/api/configuration/azure/login", + jsonMutation("DELETE"), + ); + setDialogOpen(false); + setState({ status: "disconnected", cliAvailable: true }); + setError(null); + } catch (caught) { + setError( + caught instanceof Error + ? caught.message + : "Sign-in could not be cancelled.", + ); + } finally { + setBusy(false); + } + }; + + const disconnect = async () => { + if (!window.confirm("Disconnect the current Azure account?")) return; + setBusy(true); + setError(null); + try { + await requestAzure("/api/configuration/azure", jsonMutation("DELETE")); + setState({ status: "disconnected", cliAvailable: true }); + } catch (caught) { + setError( + caught instanceof Error + ? caught.message + : "Azure could not be disconnected.", + ); + } finally { + setBusy(false); + } + }; + + const waiting = state?.status === "starting" || state?.status === "waiting"; + const connected = state?.status === "connected" ? state.account : null; + const stateError = state?.status === "failed" ? state.error : null; + + return ( + <> +
+
+
+ + +
+

Azure connection

+ {connected ? ( +
+

+ {connected.username} +

+

+ {connected.name} · tenant {connected.tenantId} +

+
+ ) : ( +

+ Connect your Microsoft account to resolve authentication + secrets from Azure Key Vault. +

+ )} +
+
+ {connected ? ( + + ) : ( + + )} +
+ {!state ? ( +

+ Checking Azure connection… +

+ ) : !state.cliAvailable ? ( +

+ Azure CLI is unavailable. Use the standard Workbench Docker image + with Azure support. +

+ ) : null} + {error || stateError ? ( +

+ {error ?? stateError} +

+ ) : null} +
+ + { + if (!open && waiting) void cancel(); + else setDialogOpen(open); + }} + > + + + +
+
+ + Connect Microsoft Azure + + + Sign in as yourself. Workbench never receives your Microsoft + password or exposes Azure tokens to the browser. + +
+ {!waiting ? ( + + + + ) : null} +
+ + {!waiting ? ( +
+ {error || stateError ? ( +

+ {error ?? stateError} +

+ ) : null} + +
+ + + + +
+
+ ) : state?.status === "waiting" && state.userCode ? ( +
+
+

+ Enter this temporary code +

+

+ {state.userCode} +

+ +
+ +

+ Waiting for + Microsoft sign-in… +

+ +
+ ) : ( +
+ Starting secure + Microsoft sign-in… +
+ )} +
+
+
+ + ); +} diff --git a/src/test/integration/authentication-output.test.ts b/src/test/integration/authentication-output.test.ts index 6fefa6f..f7e57f9 100644 --- a/src/test/integration/authentication-output.test.ts +++ b/src/test/integration/authentication-output.test.ts @@ -192,6 +192,12 @@ databaseDescribe("authentication and request outputs", () => { expiresInJsonPath: null, secret: false, }, + { + name: "reflectedAuthorization", + jsonPath: "$.authorization", + expiresInJsonPath: null, + secret: true, + }, ], }); @@ -203,6 +209,11 @@ databaseDescribe("authentication and request outputs", () => { expect(first.outputs).toEqual([ expect.objectContaining({ name: "entityId", value: "42" }), ]); + expect(first.outputs).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "reflectedAuthorization" }), + ]), + ); expect(JSON.stringify(first)).not.toContain("oauth-access-secret"); expect(first.response?.bodyPreview).toContain("••••••••"); diff --git a/tests/e2e/workbench.spec.ts b/tests/e2e/workbench.spec.ts index b53b328..6d04556 100644 --- a/tests/e2e/workbench.spec.ts +++ b/tests/e2e/workbench.spec.ts @@ -270,6 +270,18 @@ test.describe.serial("workspace and project management", () => { test("extracts and reuses a saved OAuth token without exposing it", async ({ page, }) => { + await page.route("**/api/configuration/azure", async (route) => { + if ( + new URL(route.request().url()).pathname !== "/api/configuration/azure" + ) { + await route.continue(); + return; + } + await route.fulfill({ + contentType: "application/json", + json: { status: "disconnected", cliAvailable: true }, + }); + }); await page.goto("/"); await page.getByRole("button", { name: "Core API", exact: true }).click(); await page @@ -305,7 +317,9 @@ test.describe.serial("workspace and project management", () => { .getByLabel("Token URL") .fill("https://identity.example.test/oauth/token"); await page.getByLabel("Client ID").fill("workbench-demo"); - await page.getByLabel("Client secret").fill("not-a-real-secret"); + await page + .getByLabel("Client secret", { exact: true }) + .fill("not-a-real-secret"); await page.getByLabel("Scope", { exact: true }).fill("facts:read"); await page.getByLabel("Audience").fill("facts-api"); await page.getByRole("button", { name: "Save" }).click(); @@ -313,6 +327,19 @@ test.describe.serial("workspace and project management", () => { await expectNoAccessibilityViolations(page); await captureDocumentation(page, "phase-10-oauth-client-credentials"); + if (documentationMode) { + await page.getByRole("button", { name: "New profile" }).click(); + await page.getByLabel("Name").fill("Key Vault bearer token"); + await page.getByLabel("Type").selectOption("bearer"); + await page.getByLabel("Source").selectOption("azure_key_vault"); + await page + .getByLabel("Vault URL") + .fill("https://workbench-demo.vault.azure.net/"); + await page.getByLabel("Secret name").fill("commerce-api-token"); + await expectNoAccessibilityViolations(page); + await captureDocumentation(page, "phase-12-azure-key-vault"); + } + await page.getByRole("button", { name: "New profile" }).click(); await page.getByLabel("Name").fill(authProfileName); await page.getByLabel("Type").selectOption("request_derived");