diff --git a/apps/site/.nimbus/routes.json b/apps/site/.nimbus/routes.json index 19c46d3..48f7a15 100644 --- a/apps/site/.nimbus/routes.json +++ b/apps/site/.nimbus/routes.json @@ -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", diff --git a/apps/site/src/content/docs/docs/deploy/cloudflare-access.mdx b/apps/site/src/content/docs/docs/deploy/cloudflare-access.mdx new file mode 100644 index 0000000..104e70e --- /dev/null +++ b/apps/site/src/content/docs/docs/deploy/cloudflare-access.mdx @@ -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. + + + +## 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. diff --git a/apps/site/src/content/docs/docs/deploy/cloudflare.mdx b/apps/site/src/content/docs/docs/deploy/cloudflare.mdx index 815e72e..1a1696b 100644 --- a/apps/site/src/content/docs/docs/deploy/cloudflare.mdx +++ b/apps/site/src/content/docs/docs/deploy/cloudflare.mdx @@ -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 @@ -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. diff --git a/apps/site/src/content/docs/docs/mcp.mdx b/apps/site/src/content/docs/docs/mcp.mdx index a2a1933..e565d15 100644 --- a/apps/site/src/content/docs/docs/mcp.mdx +++ b/apps/site/src/content/docs/docs/mcp.mdx @@ -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 diff --git a/deploy/cloudflare/README.md b/deploy/cloudflare/README.md index ca47c69..9801fb9 100644 --- a/deploy/cloudflare/README.md +++ b/deploy/cloudflare/README.md @@ -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 @@ -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:///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`. diff --git a/deploy/cloudflare/alchemy.run.ts b/deploy/cloudflare/alchemy.run.ts index 12bd4f7..ae38c4f 100644 --- a/deploy/cloudflare/alchemy.run.ts +++ b/deploy/cloudflare/alchemy.run.ts @@ -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", @@ -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, ); }), ); diff --git a/deploy/cloudflare/examples/cloudflare-access.config.json b/deploy/cloudflare/examples/cloudflare-access.config.json new file mode 100644 index 0000000..c37263a --- /dev/null +++ b/deploy/cloudflare/examples/cloudflare-access.config.json @@ -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" +} diff --git a/deploy/cloudflare/src/stack.ts b/deploy/cloudflare/src/stack.ts index 668d0ad..aa7ebc4 100644 --- a/deploy/cloudflare/src/stack.ts +++ b/deploy/cloudflare/src/stack.ts @@ -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, @@ -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; @@ -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, diff --git a/deploy/cloudflare/tests/alchemy-plan.test.ts b/deploy/cloudflare/tests/alchemy-plan.test.ts index 6564e84..0bb1573 100644 --- a/deploy/cloudflare/tests/alchemy-plan.test.ts +++ b/deploy/cloudflare/tests/alchemy-plan.test.ts @@ -250,7 +250,7 @@ describe("Alchemy foundation plan", () => { hostedInput, Redacted.make("test-only-runtime-token-value"), resolveZoneId, - Redacted.make("test-only-workos-key-value"), + {workOsApiKey: Redacted.make("test-only-workos-key-value")}, ), ), options, @@ -282,6 +282,64 @@ describe("Alchemy foundation plan", () => { ); }); + it("binds generic OIDC settings without WorkOS", async () => { + const oidcInput = { + ...validDeploymentInput, + oidcClientId: "artifact-server-cloudflare", + oidcClientSecretRef: + "cloudflare-secrets-store://artifact-server-oidc-client-secret", + oidcIssuer: + "https://team.cloudflareaccess.com/cdn-cgi/access/sso/oidc/client-id", + oidcScopes: "openid email profile", + }; + const resolveZoneId: CloudflareZoneResolver = ({hostname}) => + Effect.succeed( + hostname === oidcInput.applicationDomain + ? oidcInput.dnsZoneIds?.application ?? "" + : oidcInput.dnsZoneIds?.content ?? "", + ); + const scratch = Test.scratchStack(options, "oidc-auth-plan"); + const oidcClientSecret = "test-only-oidc-client-secret-value"; + const plan = await Test.run( + scratch.plan( + defineCloudflareFoundation( + oidcInput, + Redacted.make("test-only-runtime-token-value"), + resolveZoneId, + {oidcClientSecret: Redacted.make(oidcClientSecret)}, + ), + ), + options, + ); + const worker = Object.values(plan.resources).find( + ({resource}) => resource.Type === "Cloudflare.Worker", + ); + const environment = worker?.resource.Props.env; + + expect(environment).toMatchObject({ + ARTIFACT_SERVER_OIDC_CLIENT_ID: oidcInput.oidcClientId, + ARTIFACT_SERVER_OIDC_ISSUER: oidcInput.oidcIssuer, + ARTIFACT_SERVER_OIDC_SCOPES: oidcInput.oidcScopes, + }); + expect(environment).not.toHaveProperty("ARTIFACT_SERVER_WORKOS_CLIENT_ID"); + expect(JSON.stringify(environment)).not.toContain(oidcClientSecret); + + await expect( + Test.run( + scratch.plan( + defineCloudflareFoundation( + oidcInput, + Redacted.make("test-only-runtime-token-value"), + resolveZoneId, + ), + ), + options, + ), + ).rejects.toThrow( + "Configured OIDC authentication requires its deployment secret.", + ); + }); + it("keeps private ingress off domains and workers.dev", async () => { const { dnsZoneIds: _dnsZoneIds, diff --git a/deploy/cloudflare/tests/oidc-login-runtime.test.ts b/deploy/cloudflare/tests/oidc-login-runtime.test.ts index a1f209f..a90a465 100644 --- a/deploy/cloudflare/tests/oidc-login-runtime.test.ts +++ b/deploy/cloudflare/tests/oidc-login-runtime.test.ts @@ -26,7 +26,8 @@ let issuer: Server; beforeAll(async () => { issuer = await startDiscoveryStub(); const address = assignedAddressSchema.parse(issuer.address()); - issuerOrigin = `http://127.0.0.1:${address.port}`; + issuerOrigin = + `http://127.0.0.1:${address.port}/cdn-cgi/access/sso/oidc/${clientId}`; }, 30_000); afterAll(async () => { @@ -36,7 +37,7 @@ afterAll(async () => { }); describe("Cloudflare Worker browser-login provider", () => { - it("starts a generic OIDC login from the configured issuer", async () => { + it("starts generic OIDC login from a path-based issuer", async () => { const directory = await mkdtemp(join(tmpdir(), "artifact-server-oidc-")); const worker = await startWorker(directory, { ARTIFACT_SERVER_OIDC_CLIENT_ID: clientId, @@ -48,8 +49,10 @@ describe("Cloudflare Worker browser-login provider", () => { }); expect(started.status).toBe(302); const location = new URL(started.headers.get("location") ?? ""); - expect(location.origin).toBe(issuerOrigin); - expect(location.pathname).toBe("/authorize"); + expect(location.origin).toBe(new URL(issuerOrigin).origin); + expect(location.pathname).toBe( + `/cdn-cgi/access/sso/oidc/${clientId}/authorize`, + ); expect(location.searchParams.get("client_id")).toBe(clientId); expect(location.searchParams.get("redirect_uri")) .toBe(`${origin}/auth/callback`); @@ -122,16 +125,18 @@ describe("Cloudflare Worker browser-login provider", () => { function startDiscoveryStub(): Promise { const server = createServer((request, response) => { - if (request.url !== "/.well-known/openid-configuration") { + const issuerPath = `/cdn-cgi/access/sso/oidc/${clientId}`; + if (request.url !== `${issuerPath}/.well-known/openid-configuration`) { response.writeHead(404).end(); return; } const host = request.headers.host ?? "127.0.0.1"; + const requestIssuer = `http://${host}${issuerPath}`; const document = { - authorization_endpoint: `http://${host}/authorize`, - issuer: `http://${host}`, - jwks_uri: `http://${host}/jwks`, - token_endpoint: `http://${host}/token`, + authorization_endpoint: `${requestIssuer}/authorize`, + issuer: requestIssuer, + jwks_uri: `${requestIssuer}/jwks`, + token_endpoint: `${requestIssuer}/token`, }; response.writeHead(200, {"content-type": "application/json"}); response.end(JSON.stringify(document)); diff --git a/project/spec/cloud-deployment-contract.md b/project/spec/cloud-deployment-contract.md index 0f8e2fe..f3b9f21 100644 --- a/project/spec/cloud-deployment-contract.md +++ b/project/spec/cloud-deployment-contract.md @@ -106,6 +106,10 @@ The Cloudflare package additionally receives: | `cloudflareAccountId` | yes | Target account identifier | | `compatibilityDate` | yes | Exact date pinned by the package release | | `stateStore` | yes | `cloudflare`; local state is allowed only for individual development | +| `oidcClientId` | OIDC browser login only | Non-secret OIDC client identifier for this installation | +| `oidcIssuer` | OIDC browser login only | Exact HTTPS issuer whose discovery document and ID tokens Artifact Server verifies | +| `oidcClientSecretRef` | no | Provider secret reference for a confidential client; omit for a public PKCE client | +| `oidcScopes` | no | Space-separated scopes; defaults to `openid email profile` | Alchemy and the Cloudflare compatibility date are exact package dependencies. Team and CI deployments use `Cloudflare.state()`; the account-level state @@ -175,8 +179,10 @@ provider secret manager. They are not plain stack outputs or task-definition values. The direct-cloud Pulumi packages inject the API token, database URL, and optional WorkOS key from their secret manager and expose typed WorkOS inputs only; generic OIDC runs on those deployments through container environment -variables, with typed OIDC inputs a scoped follow-up. Compose and Kubernetes use -the file variants below and support either browser-login family. +variables, with typed OIDC inputs a scoped follow-up. The Cloudflare package +exposes typed OIDC inputs and binds an optional client secret as a Worker +secret. Compose and Kubernetes use the file variants below and support either +browser-login family. A package supplies one browser-login family or neither. The WorkOS variables and the generic OIDC variables are mutually exclusive, and each family is diff --git a/src/deployment/cloud-deployment-contract.ts b/src/deployment/cloud-deployment-contract.ts index 399f911..94528bb 100644 --- a/src/deployment/cloud-deployment-contract.ts +++ b/src/deployment/cloud-deployment-contract.ts @@ -192,6 +192,10 @@ export const CloudflareCloudDeploymentInput = Schema.Struct({ cloudflareAccountId: identifier, cloudflareArtifactsNamespace: Schema.optionalKey(cloudflareArtifactsNamespace), compatibilityDate, + oidcClientId: Schema.optionalKey(identifier), + oidcClientSecretRef: Schema.optionalKey(providerResourceIdentifier), + oidcIssuer: Schema.optionalKey(httpsUrl), + oidcScopes: Schema.optionalKey(nonEmptyString), stage: identifier, stateStore: Schema.Literals(["cloudflare", "local"]), target: Schema.Literal("cloudflare"), @@ -272,6 +276,33 @@ export const CloudDeploymentInput = uncheckedCloudDeploymentInput.check( path: ["workosClientId"], }); } + if (input.target === "cloudflare") { + const oidcPresence = [ + input.oidcClientId, + input.oidcClientSecretRef, + input.oidcIssuer, + input.oidcScopes, + ].map((value) => value !== undefined); + const oidcConfigured = oidcPresence.some((present) => present); + if ( + oidcConfigured && + (input.oidcClientId === undefined || input.oidcIssuer === undefined) + ) { + issues.push({ + issue: + "OIDC issuer and client must be configured together; secret reference and scopes are optional", + path: ["oidcClientId"], + }); + } + if ( + oidcConfigured && workOsPresence.some((present) => present) + ) { + issues.push({ + issue: "WorkOS and OIDC browser-login settings are mutually exclusive", + path: ["oidcClientId"], + }); + } + } if ( input.target === "cloudflare" && input.environment !== "development" && diff --git a/tests/deployment/cloud-deployment-contract.test.ts b/tests/deployment/cloud-deployment-contract.test.ts index dcb732f..2f61aec 100644 --- a/tests/deployment/cloud-deployment-contract.test.ts +++ b/tests/deployment/cloud-deployment-contract.test.ts @@ -52,6 +52,39 @@ describe("shared cloud deployment contract", () => { }); }); + test("accepts complete Cloudflare OIDC configuration", async () => { + const [confidentialClient, publicClient] = await Promise.all([ + Effect.runPromise(parseCloudDeploymentInput(cloudflareInput({ + oidcClientId: "artifact-server-cloudflare", + oidcClientSecretRef: + "cloudflare-secrets-store://artifact-server-oidc-client-secret", + oidcIssuer: + "https://team.cloudflareaccess.com/cdn-cgi/access/sso/oidc/client-id", + oidcScopes: "openid email profile", + }))), + Effect.runPromise(parseCloudDeploymentInput(cloudflareInput({ + oidcClientId: "artifact-server-public-client", + oidcIssuer: "https://identity.example.com", + }))), + ]); + + expect(confidentialClient).toMatchObject({ + oidcClientId: "artifact-server-cloudflare", + oidcClientSecretRef: + "cloudflare-secrets-store://artifact-server-oidc-client-secret", + oidcIssuer: + "https://team.cloudflareaccess.com/cdn-cgi/access/sso/oidc/client-id", + oidcScopes: "openid email profile", + target: "cloudflare", + }); + expect(publicClient).toMatchObject({ + oidcClientId: "artifact-server-public-client", + oidcIssuer: "https://identity.example.com", + target: "cloudflare", + }); + expect(publicClient).not.toHaveProperty("oidcClientSecretRef"); + }); + test.each([ ["floating image tag", awsInput({imageReference: "artifact-server:latest"})], ["same registrable content domain", awsInput({ @@ -81,6 +114,22 @@ describe("shared cloud deployment contract", () => { ["incomplete WorkOS configuration", awsInput({ workosClientId: "client_01", })], + ["incomplete Cloudflare OIDC configuration", cloudflareInput({ + oidcScopes: "openid email profile", + })], + ["Cloudflare OIDC configured beside WorkOS", cloudflareInput({ + oidcClientId: "artifact-server-cloudflare", + oidcIssuer: + "https://team.cloudflareaccess.com/cdn-cgi/access/sso/oidc/client-id", + workosApiKeySecretRef: "cloudflare-secrets-store://artifact-server-workos", + workosClientId: "client_01", + workosIssuer: "https://artifact-server.authkit.example", + })], + ["Cloudflare OIDC fields on AWS", awsInput({ + oidcClientId: "artifact-server-cloudflare", + oidcIssuer: + "https://team.cloudflareaccess.com/cdn-cgi/access/sso/oidc/client-id", + })], ["credential-bearing telemetry URL", awsInput({ otlpEndpoint: "https://collector:password@telemetry.example.org/v1/traces", })],