Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 19 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,14 @@ 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 |
| Authentication profile | Key Vault-backed fields |
| ------------------------ | ------------------------------------------- |
| Bearer token | Token |
| Basic authentication | Password |
| API key | Key value |
| OAuth client credentials | Client ID and client secret |
| OAuth password | Client ID, client secret, and password |
| OAuth refresh token | Client ID, 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
Expand Down Expand Up @@ -213,9 +213,11 @@ Read [Workflows and assertions](docs/workflows-and-assertions.md).

Prerequisite: Docker Desktop or Docker Engine with Compose.

Download or copy [`docker-compose.yml`](docker-compose.yml) into an empty
directory, then start Workbench using the published release image. Cloning the
repository is not required.

```bash
git clone https://github.com/josh-uk/workbench.git
cd workbench
docker compose up -d
```

Expand All @@ -234,9 +236,11 @@ docker compose down

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.
optional Azure sign-in respectively. Compose pulls
`ghcr.io/josh-uk/workbench:latest` by default. Set `WORKBENCH_IMAGE` to pin a
version such as `ghcr.io/josh-uk/workbench:1.1.1`. Compose defaults use
development credentials; set unique `POSTGRES_*` values before exposing the
database beyond the local Docker network.

### Container images

Expand All @@ -247,7 +251,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 `v1.1.0`
Images receive `latest` and full-commit-SHA tags. Version tags such as `v1.1.1`
also publish semantic-version tags, an SBOM, build provenance, and a GitHub
release. GitHub Container Registry visibility is managed separately from the
public repository.
Expand All @@ -274,6 +278,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build

| Name | Purpose | Compose default |
| --------------------------- | ---------------------------------------------- | ------------------------------------ |
| `WORKBENCH_IMAGE` | Published Workbench image or pinned version | `ghcr.io/josh-uk/workbench:latest` |
| `DATABASE_URL` | Server-only PostgreSQL connection URL | Generated from the PostgreSQL values |
| `POSTGRES_DB` | Local database name | `workbench` |
| `POSTGRES_USER` | Local database user | `workbench` |
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
services:
app:
image: workbench-dev
build:
context: .
target: development
command: npm run dev -- --hostname 0.0.0.0
environment:
Expand Down
4 changes: 1 addition & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ name: workbench

services:
app:
build:
context: .
target: runner
image: ${WORKBENCH_IMAGE:-ghcr.io/josh-uk/workbench:latest}
init: true
restart: unless-stopped
environment:
Expand Down
20 changes: 10 additions & 10 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,17 @@ timeout, TLS, and response-size controls.

## Azure Key Vault credential sources

The following secret-bearing fields can use either **Stored in Workbench** or
The following credential 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 |
| Profile | Supported Key Vault fields |
| -------------------------- | ------------------------------------------- |
| Bearer token | Token |
| Basic authentication | Password |
| API key in header or query | Key value |
| OAuth client credentials | Client ID and client secret |
| OAuth password | Client ID, client secret, and password |
| OAuth refresh token | Client ID, 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.
Expand Down Expand Up @@ -78,7 +78,7 @@ 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
in v1.1.1. 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.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "workbench",
"version": "1.1.0",
"version": "1.1.1",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
4 changes: 4 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@
border-color: var(--border);
}

html {
font-size: 110%;
}

body {
background: var(--background);
color: var(--foreground);
Expand Down
74 changes: 74 additions & 0 deletions src/features/authentication/azure/http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";

import { AzureAuthenticationError } from "./domain";
import { assertTrustedMutation } from "./http";

function mutationRequest(url: string, headers: Record<string, string> = {}) {
return new Request(url, {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body: "{}",
});
}

describe("Azure HTTP mutation protection", () => {
it("accepts an origin matching the public Host header", () => {
const request = mutationRequest(
"http://0.0.0.0:3000/api/configuration/azure/login",
{ host: "localhost:3000", origin: "http://localhost:3000" },
);

expect(() => assertTrustedMutation(request)).not.toThrow();
});

it("accepts an origin supplied by a TLS reverse proxy", () => {
const request = mutationRequest(
"http://app:3000/api/configuration/azure/login",
{
host: "app:3000",
origin: "https://workbench.example.com",
"x-forwarded-host": "workbench.example.com",
"x-forwarded-proto": "https",
},
);

expect(() => assertTrustedMutation(request)).not.toThrow();
});

it("rejects a different origin", () => {
const request = mutationRequest(
"http://0.0.0.0:3000/api/configuration/azure/login",
{ host: "localhost:3000", origin: "https://example.com" },
);

expect(() => assertTrustedMutation(request)).toThrowError(
AzureAuthenticationError,
);
});

it("rejects requests identified by the browser as cross-site", () => {
const request = mutationRequest(
"http://localhost:3000/api/configuration/azure/login",
{
host: "localhost:3000",
origin: "http://localhost:3000",
"sec-fetch-site": "cross-site",
},
);

expect(() => assertTrustedMutation(request)).toThrowError(
AzureAuthenticationError,
);
});

it("continues to require JSON mutations", () => {
const request = new Request(
"http://localhost:3000/api/configuration/azure/login",
{ method: "POST", body: "tenant=contoso" },
);

expect(() => assertTrustedMutation(request)).toThrow(
"Azure requests must use JSON.",
);
});
});
42 changes: 41 additions & 1 deletion src/features/authentication/azure/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,54 @@ export function assertTrustedMutation(request: Request) {
);
}
const origin = request.headers.get("origin");
if (origin && origin !== new URL(request.url).origin) {
const fetchSite = request.headers.get("sec-fetch-site");
if (
fetchSite === "cross-site" ||
(origin && !requestOrigins(request).has(origin))
) {
throw new AzureAuthenticationError(
"Cross-origin Azure requests are not allowed.",
"AZURE_REQUEST_FORBIDDEN",
);
}
}

function requestOrigins(request: Request) {
const requestUrl = new URL(request.url);
const origins = new Set([requestUrl.origin]);
const host = request.headers.get("host");
const forwardedHost = firstForwardedValue(
request.headers.get("x-forwarded-host"),
);
const forwardedProtocol = firstForwardedValue(
request.headers.get("x-forwarded-proto"),
);

addOrigin(origins, requestUrl.protocol, host);
addOrigin(origins, forwardedProtocol, forwardedHost ?? host);
return origins;
}

function firstForwardedValue(value: string | null) {
return value?.split(",", 1)[0]?.trim() || null;
}

function addOrigin(
origins: Set<string>,
protocol: string | null,
host: string | null,
) {
const normalizedProtocol = protocol?.replace(/:$/, "");
if (!normalizedProtocol || !host || !/^https?$/.test(normalizedProtocol)) {
return;
}
try {
origins.add(new URL(`${normalizedProtocol}://${host}`).origin);
} catch {
// Invalid forwarding metadata is never trusted as an allowed origin.
}
}

export function azureErrorResponse(error: unknown) {
const message =
error instanceof ZodError
Expand Down
30 changes: 26 additions & 4 deletions src/features/authentication/domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
import {
defaultAuthConfiguration,
parseAuthConfiguration,
secretFieldsForAuthType,
referencedFieldsForAuthType,
} from "./domain";

describe("authentication secret references", () => {
Expand All @@ -14,6 +14,7 @@ describe("authentication secret references", () => {
token: null,
password: null,
key: null,
clientId: null,
clientSecret: null,
refreshToken: null,
});
Expand All @@ -38,12 +39,33 @@ describe("authentication secret references", () => {
);
});

it("clears a stored client ID when Key Vault owns it", () => {
const configuration = parseAuthConfiguration({
...defaultAuthConfiguration(),
clientId: "must-not-remain",
secretReferences: {
clientId: {
provider: "azure_key_vault",
vaultUrl: "https://workbench-secrets.vault.azure.net/",
secretName: "oauth-client-id",
version: "",
},
},
});

expect(configuration.clientId).toBe("");
expect(configuration.secretReferences.clientId?.secretName).toBe(
"oauth-client-id",
);
});

it("resolves only fields used by each authentication type", () => {
expect(secretFieldsForAuthType("bearer")).toEqual(["token"]);
expect(secretFieldsForAuthType("oauth2_password")).toEqual([
expect(referencedFieldsForAuthType("bearer")).toEqual(["token"]);
expect(referencedFieldsForAuthType("oauth2_password")).toEqual([
"clientId",
"clientSecret",
"password",
]);
expect(secretFieldsForAuthType("request_derived")).toEqual([]);
expect(referencedFieldsForAuthType("request_derived")).toEqual([]);
});
});
Loading
Loading