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
1 change: 1 addition & 0 deletions apps/site/.nimbus/routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"/docs/deploy",
"/docs/deploy/aws",
"/docs/deploy/cloudflare",
"/docs/deploy/cloudflare-access",
"/docs/deploy/compose",
"/docs/deploy/google-cloud",
"/docs/deploy/kubernetes",
Expand Down
102 changes: 102 additions & 0 deletions apps/site/src/content/docs/docs/deploy/cloudflare-access.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
---
title: Use Cloudflare Access for sign-in
description: Let people sign in to a Cloudflare deployment without WorkOS by connecting Artifact Server to Cloudflare Access through OIDC.
sidebar:
order: 1.5
---

Cloudflare Access can provide browser sign-in for an Artifact Server deployment. You add an OIDC application record in Cloudflare Access, then give the resulting client settings to the Artifact Server deployment. You do not deploy another application.

<Aside type="caution" title="Browser sign-in only">
This configuration does not provide browser authorization for remote MCP clients. After signing in, an administrator can issue scoped API keys for remote MCP clients and unattended agents. Use WorkOS if the installation needs the currently supported hosted MCP browser-authorization flow.
</Aside>

## Before you begin

You need:

- a Cloudflare Zero Trust organization;
- an identity provider enabled in Cloudflare Access;
- the final Artifact Server application domain; and
- the email address configured as `bootstrapAdministratorEmail`.

Cloudflare can use its own account membership as the identity provider. Enable **Restrict to account members** if only members of that Cloudflare account may sign in. Otherwise, connect the identity provider your team already uses.

## Add Artifact Server to Cloudflare Access

In Cloudflare Zero Trust, open **Access controls → Applications** and create a **SaaS application** that uses **OIDC**. This record represents Artifact Server. It is not a second Worker or website.

Configure the OIDC application:

1. Add this redirect URL, replacing the example domain:

```text
https://artifacts.example.com/auth/callback
```

2. Enable the `openid`, `email`, and `profile` scopes.
3. Enable Proof Key for Code Exchange (PKCE).
4. Select the identity providers people may use.
5. Add an Access policy that allows the intended people.
6. Create the application, then copy its client ID, client secret, and configuration endpoint.

Open the configuration endpoint and copy the exact value of its `issuer` field. Artifact Server checks that value against the signed ID token and the discovery document.

Cloudflare maintains the current [generic OIDC application instructions](https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/saas-apps/generic-oidc-saas/). Follow those instructions if the Cloudflare interface differs from the steps above.

## Configure the deployment

Copy `deploy/cloudflare/examples/cloudflare-access.config.json` to a file
outside the repository and replace every example value.

Add the non-secret OIDC settings and a non-secret reference for the client secret to the Cloudflare deployment JSON:

```json
{
"oidcClientId": "YOUR_CLOUDFLARE_ACCESS_CLIENT_ID",
"oidcClientSecretRef": "cloudflare-secrets-store://artifact-server-oidc-client-secret",
"oidcIssuer": "https://YOUR_TEAM.cloudflareaccess.com/cdn-cgi/access/sso/oidc/YOUR_CLIENT_ID",
"oidcScopes": "openid email profile"
}
```

Use the exact issuer from Cloudflare's configuration endpoint. Do not copy the client secret into the JSON file.

Resolve the referenced secret into the deployment process environment without
printing it or adding it to shell history:

```sh
read -rsp 'Cloudflare Access client secret: ' ARTIFACT_SERVER_OIDC_CLIENT_SECRET
export ARTIFACT_SERVER_OIDC_CLIENT_SECRET
```

In CI, load the same variable from the approved secret manager. Do not commit
the value or paste it into an issue.

Do not include `workosClientId`, `workosIssuer`, or `workosApiKeySecretRef` in the same deployment. One installation uses one browser-login provider.

Run the normal Cloudflare lifecycle commands:

```sh
pnpm plan
pnpm deploy
```

## Verify sign-in

Open the Artifact Server application domain in a private browser window. Sign in through Cloudflare Access. The person whose email matches `bootstrapAdministratorEmail` becomes the first administrator.

Check these outcomes before inviting the team:

- Cloudflare returns the browser to `/auth/callback` on the application domain.
- Artifact Server opens the signed-in review application.
- A person not allowed by the Access policy cannot complete sign-in.
- A person allowed by Access but not admitted to Artifact Server cannot enter the installation.

The Worker test suite covers the generic OIDC path and Cloudflare-shaped issuer URLs. A live Cloudflare Access round trip is not yet attached to the release ledger. Verify the flow in a non-production stage before using it in production.

## Connect remote agents

Sign in as an administrator. Open **Settings → API keys** and issue a key for the person or service that will use it. Grant only the capabilities that client needs and set an expiration date. Add the key through the client's secret input or deployment secret manager.

Cloudflare Access service tokens can restrict whether a request reaches the Worker, but Artifact Server does not currently turn a service token into an Artifact Server user or service identity. A service token alone does not replace the scoped Artifact Server API key.
5 changes: 4 additions & 1 deletion apps/site/src/content/docs/docs/deploy/cloudflare.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ Cloudflare is Artifact Server's live-qualified direct-cloud target. The deployme
| Durable records | D1 |
| Artifact and staged-upload bytes | R2 |
| Provisioning and lifecycle | Alchemy |
| Hosted browser and MCP authorization | WorkOS |
| Browser authentication | WorkOS or generic OIDC, including Cloudflare Access |
| Hosted MCP browser authorization | WorkOS |
| Optional private Git history | Cloudflare Artifacts |

## Current release boundary
Expand Down Expand Up @@ -59,6 +60,8 @@ pnpm destroy

The deployment requires a Cloudflare account, two domains, D1 and R2 bindings, identity-provider settings, and Cloudflare-hosted Alchemy state. Read `deploy/cloudflare/README.md` before you create resources.

For browser sign-in managed by Cloudflare, follow [Use Cloudflare Access for sign-in](/docs/deploy/cloudflare-access/). WorkOS remains available for installations that also need its hosted MCP browser-authorization flow.

## Optional Cloudflare Artifacts history

Cloudflare Artifacts can keep a private Git copy of selected projects. It is off by default. Artifact Server's D1 and R2 data remains authoritative. The integration creates one repository for each artifact. A durable queue copies new and existing versions. The integration issues bounded read tokens for clones.
Expand Down
10 changes: 9 additions & 1 deletion apps/site/src/content/docs/docs/mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,15 @@ Add the exact MCP address to the client:
https://artifacts.example.com/mcp
```

The client opens the selected identity provider. Complete browser authorization. Then return to the client. The access token is valid only for that exact `/mcp` resource. A token for another installation or application resource is not valid.
When the server advertises browser authorization, the client opens the selected
identity provider. Complete browser authorization, then return to the client.
The access token is valid only for that exact `/mcp` resource. A token for
another installation or application resource is not valid.

Generic OIDC browser login, including Cloudflare Access, does not by itself add
MCP browser authorization. For those installations, an administrator issues a
scoped user or service API key under **Settings → API keys**. Add the key through
the MCP client's secret input. Do not put it in a project file.

## Install the Artifact Server skill

Expand Down
65 changes: 58 additions & 7 deletions deploy/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,18 @@ sharing, and API-key services as the other deployments. The Cloudflare package
supplies D1 repositories and direct R2 bindings instead of SQLite, Postgres, or
an S3 client.

The package accepts an Artifact Server API token and can also bind one complete
WorkOS hosted-authentication configuration: API key secret, client ID, and
exact AuthKit issuer. The verifier, protected-resource metadata, and browser
login wiring are implemented. Live browser approval, refresh, revocation, and
named-client qualification remain release gates, so this package must not yet
be advertised as the complete hosted Artifact Server service.
The package accepts an Artifact Server API token and exactly one browser-login
configuration. WorkOS supplies browser login and hosted MCP authorization.
Generic OIDC supplies browser login through a compatible provider, including
Cloudflare Access. OIDC deployments use scoped Artifact Server API keys for
remote MCP clients and unattended agents.

The WorkOS verifier, protected-resource metadata, and browser-login wiring are
implemented. Live browser approval, refresh, revocation, and named-client
qualification remain release gates, so this package must not yet be advertised
as the complete hosted Artifact Server service. The generic OIDC Worker path is
covered locally, but a live Cloudflare Access round trip is not attached to the
release ledger.

The isolated WorkOS staging environment has CIMD, compatibility DCR, the exact
staging MCP resource, and its callback configured. The secret-free dashboard
Expand Down Expand Up @@ -90,7 +96,52 @@ export ARTIFACT_SERVER_API_TOKEN="$(openssl rand -base64 32)"
```

The parser rejects unknown fields. The parser also rejects unsafe production,
capacity, domain, DNS, WorkOS, and deletion-protection combinations.
capacity, domain, DNS, browser-authentication, and deletion-protection
combinations.

## Choose browser authentication

Configure WorkOS or generic OIDC. Do not configure both.

For WorkOS, add all three non-secret fields to the configuration document:

```json
{
"workosApiKeySecretRef": "cloudflare-secrets-store://artifact-server-workos",
"workosClientId": "client_replace_me",
"workosIssuer": "https://replace.authkit.app"
}
```

The deployment process resolves the referenced secret into
`ARTIFACT_SERVER_WORKOS_API_KEY` before it runs Alchemy.

For Cloudflare Access or another OIDC provider, register this callback:

```text
https://<applicationDomain>/auth/callback
```

Then add these fields:

```json
{
"oidcClientId": "artifact-server",
"oidcClientSecretRef": "cloudflare-secrets-store://artifact-server-oidc-client-secret",
"oidcIssuer": "https://identity.example.com",
"oidcScopes": "openid email profile"
}
```

`oidcClientSecretRef` is optional for a public PKCE client. When it is present,
the deployment process resolves the referenced secret into
`ARTIFACT_SERVER_OIDC_CLIENT_SECRET` before it runs Alchemy. The configuration
document contains only the stable secret reference, never the secret value.

For Cloudflare Access setup and verification, read
[`Use Cloudflare Access for sign-in`](https://artifactserver.com/docs/deploy/cloudflare-access/).
The checked-in starting configuration is
[`examples/cloudflare-access.config.json`](./examples/cloudflare-access.config.json).

`src/deployment-input.ts` applies Cloudflare package pins after it calls the
shared parser in `src/deployment/index.ts`.
Expand Down
22 changes: 17 additions & 5 deletions deploy/cloudflare/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import type * as Redacted from "effect/Redacted";

import {
cloudflareDeploymentDocumentConfig,
parseCloudflareDeploymentInput,
} from "./src/deployment-input.ts";
import { defineCloudflareFoundation } from "./src/stack.ts";
import {
type CloudflareAuthenticationSecrets,
defineCloudflareFoundation,
} from "./src/stack.ts";

export default Alchemy.Stack(
"artifact-server-cloudflare",
Expand All @@ -20,15 +22,25 @@ export default Alchemy.Stack(
const document = yield* cloudflareDeploymentDocumentConfig;
const input = yield* parseCloudflareDeploymentInput(document);
const apiToken = yield* Config.redacted("ARTIFACT_SERVER_API_TOKEN");
let workOsApiKey: Redacted.Redacted | undefined;
let authenticationSecrets: CloudflareAuthenticationSecrets = {};
if (input.workosApiKeySecretRef !== undefined) {
workOsApiKey = yield* Config.redacted("ARTIFACT_SERVER_WORKOS_API_KEY");
authenticationSecrets = {
...authenticationSecrets,
workOsApiKey: yield* Config.redacted("ARTIFACT_SERVER_WORKOS_API_KEY"),
};
}
if (input.oidcClientSecretRef !== undefined) {
authenticationSecrets = {
...authenticationSecrets,
oidcClientSecret:
yield* Config.redacted("ARTIFACT_SERVER_OIDC_CLIENT_SECRET"),
};
}
return yield* defineCloudflareFoundation(
input,
apiToken,
undefined,
workOsApiKey,
authenticationSecrets,
);
}),
);
35 changes: 35 additions & 0 deletions deploy/cloudflare/examples/cloudflare-access.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"applicationDomain": "artifacts.example.com",
"backupRetentionDays": 7,
"bootstrapAdministratorEmail": "administrator@example.com",
"capacity": {
"cpu": 1,
"maximumInstances": 1,
"memoryMiB": 128,
"minimumInstances": 0
},
"cloudflareAccountId": "00000000000000000000000000000000",
"compatibilityDate": "2026-08-15",
"contentDomain": "artifact-content.example.net",
"databasePlan": "small",
"deletionProtection": false,
"dnsZoneIds": {
"application": "11111111111111111111111111111111",
"content": "22222222222222222222222222222222"
},
"environment": "development",
"ingress": "public",
"installationName": "team-artifacts",
"oidcClientId": "YOUR_CLOUDFLARE_ACCESS_CLIENT_ID",
"oidcClientSecretRef": "cloudflare-secrets-store://artifact-server-oidc-client-secret",
"oidcIssuer": "https://YOUR_TEAM.cloudflareaccess.com/cdn-cgi/access/sso/oidc/YOUR_CLIENT_ID",
"oidcScopes": "openid email profile",
"region": "global",
"requestLogSampleRate": 0.01,
"resourceTags": {
"purpose": "artifact-server"
},
"stage": "development",
"stateStore": "cloudflare",
"target": "cloudflare"
}
39 changes: 36 additions & 3 deletions deploy/cloudflare/src/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ const WEB_ASSET_DIRECTORY = new URL("../../../dist/web", import.meta.url).pathna
export type CloudflareZoneResolver =
typeof Cloudflare.Zone.resolveZoneId;

/** Secret values supplied to the Cloudflare authentication adapters. */
export interface CloudflareAuthenticationSecrets {
readonly oidcClientSecret?: Redacted.Redacted;
readonly workOsApiKey?: Redacted.Redacted;
}

const accountMismatch = (
expectedAccountId: string,
actualAccountId: string,
Expand Down Expand Up @@ -112,7 +118,7 @@ export const defineCloudflareFoundation = Effect.fn(
apiToken: Redacted.Redacted,
resolveZoneId: CloudflareZoneResolver =
Cloudflare.Zone.resolveZoneId,
workOsApiKey?: Redacted.Redacted,
authenticationSecrets: CloudflareAuthenticationSecrets = {},
) {
const manifest = buildCloudflareDeploymentManifest(input);
const credentials = yield* yield* Cloudflare.CloudflareEnvironment;
Expand Down Expand Up @@ -174,18 +180,45 @@ export const defineCloudflareFoundation = Effect.fn(
input.workosApiKeySecretRef !== undefined &&
input.workosClientId !== undefined && input.workosIssuer !== undefined
) {
if (workOsApiKey === undefined) {
if (authenticationSecrets.workOsApiKey === undefined) {
return yield* Effect.die(
new Error("Configured WorkOS authentication requires its deployment secret."),
);
}
workerEnvironment = {
...workerEnvironment,
ARTIFACT_SERVER_WORKOS_API_KEY: workOsApiKey,
ARTIFACT_SERVER_WORKOS_API_KEY: authenticationSecrets.workOsApiKey,
ARTIFACT_SERVER_WORKOS_CLIENT_ID: input.workosClientId,
ARTIFACT_SERVER_WORKOS_ISSUER: input.workosIssuer,
};
}
if (input.oidcClientId !== undefined && input.oidcIssuer !== undefined) {
workerEnvironment = {
...workerEnvironment,
ARTIFACT_SERVER_OIDC_CLIENT_ID: input.oidcClientId,
ARTIFACT_SERVER_OIDC_ISSUER: input.oidcIssuer,
};
if (input.oidcClientSecretRef !== undefined) {
const oidcClientSecret = authenticationSecrets.oidcClientSecret;
if (oidcClientSecret === undefined) {
return yield* Effect.die(
new Error(
"Configured OIDC authentication requires its deployment secret.",
),
);
}
workerEnvironment = {
...workerEnvironment,
ARTIFACT_SERVER_OIDC_CLIENT_SECRET: oidcClientSecret,
};
}
if (input.oidcScopes !== undefined) {
workerEnvironment = {
...workerEnvironment,
ARTIFACT_SERVER_OIDC_SCOPES: input.oidcScopes,
};
}
}
const workerProps: Cloudflare.WorkerProps = {
assets: {
directory: WEB_ASSET_DIRECTORY,
Expand Down
Loading