Skip to content
Open
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ ARTIFACT_SERVER_ORIGIN=https://artifactserver.example
# ARTIFACT_SERVER_OIDC_CLIENT_SECRET=replace_me
# ARTIFACT_SERVER_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc-client-secret
# ARTIFACT_SERVER_OIDC_SCOPES=openid email profile
# Automatic member admission after a validated OIDC browser login is off by default.
# Use exactly true or false. With true, an empty domain list trusts the configured
# issuer's access restrictions; a list permits new members only from exact email
# domains (case-insensitive, no wildcards or implicit subdomains). Existing members
# keep their roles, and deactivated identities stay denied. Bootstrap the first
# administrator before other users sign in. These settings do not admit MCP users.
# ARTIFACT_SERVER_OIDC_AUTO_ADMIT=false
# ARTIFACT_SERVER_OIDC_ALLOWED_EMAIL_DOMAINS=example.com,example.org

# External-storage runtime. The bucket must already exist. Leave endpoint unset for
# AWS S3; set it and force path style only for a compatible provider that needs it.
Expand Down
23 changes: 23 additions & 0 deletions deploy/cloudflare/src/d1-identity-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,29 @@ export function createD1IdentityRepository(

findMember,

findMemberByEmail: async (installationId, email) => {
const row = await database.prepare(`${memberSelect}
WHERE installation_id = ? AND email = ?
`).bind(installationId, email)
.first<z.input<typeof memberRowSchema>>();
return row === null ? null : memberRowSchema.parse(row);
},

findMemberByExternalIdentity: async (installationId, provider, subject) => {
const row = await database.prepare(`
SELECT member.id, member.installation_id AS installationId,
member.email, member.display_name AS displayName, member.role,
member.status, member.created_at AS createdAt,
member.updated_at AS updatedAt
FROM external_identities AS external
JOIN installation_members AS member ON member.id = external.member_id
WHERE member.installation_id = ? AND external.provider = ?
AND external.subject = ?
`).bind(installationId, provider, subject)
.first<z.input<typeof memberRowSchema>>();
return row === null ? null : memberRowSchema.parse(row);
},

hasMembers: async (installationId) => {
const row = await database.prepare(`
SELECT EXISTS(
Expand Down
19 changes: 18 additions & 1 deletion deploy/cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ import {
} from "../../../src/identity/oidc-identity-provider.js";
import {createWorkOsHostedAuthentication} from
"../../../src/identity/workos-hosted-authentication.js";
import {parseOidcAdmissionPolicy} from
"../../../src/identity/oidc-admission-policy.js";

/** Bindings and variables the deployed Artifact Server worker reads. */
export interface WorkerEnvironment {
Expand All @@ -57,6 +59,8 @@ export interface WorkerEnvironment {
readonly ARTIFACT_SERVER_CLOUDFLARE_ARTIFACTS_NAMESPACE?: string;
readonly ARTIFACT_SERVER_D1_DATABASE: D1Database;
readonly ARTIFACT_SERVER_INSTALLATION_ID: string;
readonly ARTIFACT_SERVER_OIDC_AUTO_ADMIT?: string;
readonly ARTIFACT_SERVER_OIDC_ALLOWED_EMAIL_DOMAINS?: string;
readonly ARTIFACT_SERVER_OIDC_CLIENT_ID?: string;
readonly ARTIFACT_SERVER_OIDC_CLIENT_SECRET?: string;
readonly ARTIFACT_SERVER_OIDC_ISSUER?: string;
Expand Down Expand Up @@ -142,6 +146,13 @@ async function createCloudflareRuntime(
environment: WorkerEnvironment,
): Promise<CloudflareRuntime> {
validateEnvironment(environment);
const oidcIdentityProvider = oidcAuthentication(environment);
const admissionPolicy = oidcIdentityProvider === null ? null :
parseOidcAdmissionPolicy(
requireEnvironmentValue(environment.ARTIFACT_SERVER_OIDC_ISSUER),
environment.ARTIFACT_SERVER_OIDC_AUTO_ADMIT,
environment.ARTIFACT_SERVER_OIDC_ALLOWED_EMAIL_DOMAINS,
);
await migrateD1(
environment.ARTIFACT_SERVER_D1_DATABASE,
environment.ARTIFACT_SERVER_INSTALLATION_ID,
Expand All @@ -158,7 +169,6 @@ async function createCloudflareRuntime(
environment.ARTIFACT_SERVER_INSTALLATION_ID,
);
const gitHistory = await initializeGitHistory(environment, repository, blobs);
const oidcIdentityProvider = oidcAuthentication(environment);
const hostedAuthentication = await workOsAuthentication(environment);
const browserAccess = hostedAuthentication !== null
? privateTeamBrowserAccess(browserLoginKinds.workOs)
Expand Down Expand Up @@ -189,6 +199,11 @@ async function createCloudflareRuntime(
repository,
staging,
};
if (admissionPolicy !== null) {
Object.assign(applicationAdapters, {
externalIdentityAdmissionPolicy: admissionPolicy,
});
}
if (gitHistory.provider !== null) {
Object.assign(applicationAdapters, {gitHistoryProvider: gitHistory.provider});
}
Expand Down Expand Up @@ -347,6 +362,8 @@ function oidcValues(
environment: WorkerEnvironment,
): readonly (string | undefined)[] {
return [
environment.ARTIFACT_SERVER_OIDC_AUTO_ADMIT,
environment.ARTIFACT_SERVER_OIDC_ALLOWED_EMAIL_DOMAINS,
environment.ARTIFACT_SERVER_OIDC_CLIENT_ID,
environment.ARTIFACT_SERVER_OIDC_CLIENT_SECRET,
environment.ARTIFACT_SERVER_OIDC_ISSUER,
Expand Down
138 changes: 136 additions & 2 deletions deploy/cloudflare/tests/oidc-login-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {tmpdir} from "node:os";
import {join} from "node:path";

import {z} from "zod";
import {exportJWK, generateKeyPair, SignJWT, type JWK} from "jose";
import {unstable_dev, type Unstable_DevWorker} from "wrangler";
import {afterAll, beforeAll, describe, expect, it} from "vitest";

Expand All @@ -22,9 +23,18 @@ const notReadySchema = z.object({

let issuerOrigin: string;
let issuer: Server;
let signingKey: CryptoKey;
const issuedTokens = new Map<string, string>();

beforeAll(async () => {
issuer = await startDiscoveryStub();
const signing = await generateKeyPair("ES256");
signingKey = signing.privateKey;
issuer = await startDiscoveryStub({
...await exportJWK(signing.publicKey),
alg: "ES256",
kid: "worker-oidc-test",
use: "sig",
});
const address = assignedAddressSchema.parse(issuer.address());
issuerOrigin =
`http://127.0.0.1:${address.port}/cdn-cgi/access/sso/oidc/${clientId}`;
Expand All @@ -37,6 +47,42 @@ afterAll(async () => {
});

describe("Cloudflare Worker browser-login provider", () => {
it("AUTH-029: configured OIDC policy admits a new Worker member and rejects an unlisted domain", async () => {
const directory = await mkdtemp(join(tmpdir(), "artifact-server-oidc-admission-"));
const worker = await startWorker(directory, {
ARTIFACT_SERVER_OIDC_AUTO_ADMIT: "true",
ARTIFACT_SERVER_OIDC_ALLOWED_EMAIL_DOMAINS: " EXAMPLE.TEST ",
ARTIFACT_SERVER_OIDC_CLIENT_ID: clientId,
ARTIFACT_SERVER_OIDC_ISSUER: `${issuerOrigin}/`,
});
try {
expect((await completeWorkerLogin(
worker, "administrator@example.test", "administrator",
)).status).toBe(303);
const admitted = await completeWorkerLogin(
worker, "new-member@example.test", "new-member",
);
expect(admitted.status).toBe(303);
const sessionCookie = admitted.headers.getSetCookie()
.find((cookie) => cookie.startsWith("__Host-artifact_session="))
?.split(";")[0];
if (sessionCookie === undefined) throw new Error("Missing admitted session.");
const session = await worker.fetch(`${origin}/api/v1/session`, {
headers: {Cookie: sessionCookie},
});
expect(session.status).toBe(200);
expect(await session.json()).toMatchObject({
principal: {kind: "human", membershipRole: "member"},
});
expect((await completeWorkerLogin(
worker, "unlisted@attacker.test", "unlisted",
)).status).toBe(403);
} finally {
await worker.stop();
await rm(directory, {force: true, recursive: true});
}
}, 120_000);

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, {
Expand Down Expand Up @@ -106,6 +152,41 @@ describe("Cloudflare Worker browser-login provider", () => {
}
}, 120_000);

it.each([
{ARTIFACT_SERVER_OIDC_AUTO_ADMIT: "yes"},
{ARTIFACT_SERVER_OIDC_ALLOWED_EMAIL_DOMAINS: "*.example.test"},
])("AUTH-029: refuses invalid OIDC admission configuration %j", async (settings) => {
const directory = await mkdtemp(join(tmpdir(), "artifact-server-oidc-policy-"));
const worker = await startWorker(directory, {
ARTIFACT_SERVER_OIDC_CLIENT_ID: clientId,
ARTIFACT_SERVER_OIDC_ISSUER: issuerOrigin,
...settings,
});
try {
const ready = await worker.fetch(`${origin}/ready`);
expect(ready.status).toBe(503);
expect(notReadySchema.parse(await ready.json()).error)
.toBe("artifact_server_not_ready");
} finally {
await worker.stop();
await rm(directory, {force: true, recursive: true});
}
}, 120_000);

it("AUTH-029: policy settings alone require an OIDC provider", async () => {
const directory = await mkdtemp(join(tmpdir(), "artifact-server-oidc-policy-alone-"));
const worker = await startWorker(directory, {
ARTIFACT_SERVER_OIDC_AUTO_ADMIT: "false",
});
try {
const ready = await worker.fetch(`${origin}/ready`);
expect(ready.status).toBe(503);
} finally {
await worker.stop();
await rm(directory, {force: true, recursive: true});
}
}, 120_000);

it("fails private-team runtime initialization when no provider is configured", async () => {
const directory = await mkdtemp(join(tmpdir(), "artifact-server-oidc-none-"));
const worker = await startWorker(directory, {});
Expand All @@ -123,9 +204,62 @@ describe("Cloudflare Worker browser-login provider", () => {
}, 120_000);
});

function startDiscoveryStub(): Promise<Server> {
async function completeWorkerLogin(
worker: Unstable_DevWorker,
email: string,
subject: string,
): Promise<Awaited<ReturnType<Unstable_DevWorker["fetch"]>>> {
const login = await worker.fetch(`${origin}/auth/login`, {redirect: "manual"});
expect(login.status).toBe(302);
const authorization = new URL(login.headers.get("location") ?? "");
const nonce = authorization.searchParams.get("nonce");
const state = authorization.searchParams.get("state");
if (nonce === null || state === null) throw new Error("Missing OIDC handshake.");
const token = await new SignJWT({email, email_verified: true, nonce})
.setProtectedHeader({alg: "ES256", kid: "worker-oidc-test"})
.setIssuer(issuerOrigin)
.setAudience(clientId)
.setSubject(subject)
.setIssuedAt()
.setExpirationTime("5m")
.sign(signingKey);
issuedTokens.set(nonce, token);
const callback = new URL("/auth/callback", origin);
callback.searchParams.set("code", nonce);
callback.searchParams.set("state", state);
const cookie = login.headers.getSetCookie()
.map((entry) => entry.split(";")[0]).join("; ");
return worker.fetch(callback.toString(), {
headers: {Cookie: cookie},
redirect: "manual",
});
}

function startDiscoveryStub(publicJwk: JWK): Promise<Server> {
const server = createServer((request, response) => {
const issuerPath = `/cdn-cgi/access/sso/oidc/${clientId}`;
if (request.url === `${issuerPath}/jwks`) {
response.writeHead(200, {"content-type": "application/json"});
response.end(JSON.stringify({keys: [publicJwk]}));
return;
}
if (request.url === `${issuerPath}/token` && request.method === "POST") {
let body = "";
request.on("data", (chunk: Buffer) => {
body += new TextDecoder().decode(chunk);
});
request.on("end", () => {
const code = new URLSearchParams(body).get("code") ?? "";
const token = issuedTokens.get(code);
issuedTokens.delete(code);
response.writeHead(token === undefined ? 400 : 200, {
"content-type": "application/json",
});
response.end(JSON.stringify(token === undefined
? {error: "invalid_grant"} : {id_token: token}));
});
return;
}
if (request.url !== `${issuerPath}/.well-known/openid-configuration`) {
response.writeHead(404).end();
return;
Expand Down
28 changes: 27 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Each remote deployment needs these boundaries:
1. Configure one HTTPS application origin.
2. Configure a separate wildcard content domain.
3. Configure WorkOS or one generic OIDC provider.
4. Admit team members through Artifact Server.
4. Admit team members through Artifact Server, or enable OIDC automatic admission.
5. Store service credentials outside source control.
6. Back up the database and artifact files.
7. Pin release deployments to an immutable image digest.
Expand All @@ -39,6 +39,32 @@ Local-owner access works only on an exact loopback origin. Do not use it for rem

Remote deployments use WorkOS or a generic OIDC provider. Network access and application authorization remain separate controls.

### Admit members through OIDC browser login

By default, a successful identity-provider login still requires an active Artifact
Server membership. For an issuer that already restricts who may sign in, such as
Dex with a corporate-domain filter, set `ARTIFACT_SERVER_OIDC_AUTO_ADMIT=true` to
admit new browser users as normal members after OIDC token validation succeeds.
The switch accepts exactly `true` or `false` and defaults to `false`.

Leave `ARTIFACT_SERVER_OIDC_ALLOWED_EMAIL_DOMAINS` unset or empty to trust the
configured issuer's access restrictions. To restrict new registrations further,
set a comma-separated list such as `example.com,example.org`. Domains are trimmed
and lowercased, then matched against the complete email domain: `example.com`
does not accept `sub.example.com`, `otherexample.com`, or wildcard entries.

The domain list applies only to new automatic registrations. Explicitly admitted
members keep their roles and can sign in outside that list. A deactivated member
cannot regain access by changing the email attached to the same issuer and subject.
The first member still must be the configured bootstrap administrator; automatic
admission does not replace that bootstrap step or grant administrator privileges.
An explicitly unverified email remains denied. WorkOS, local-owner login, and MCP
bearer authentication keep their existing admission rules.

Both policy variables require a complete OIDC configuration, even when automatic
admission is disabled. Invalid switch values or malformed domain lists fail startup.
For Helm, use `identity.oidcAutoAdmit` and `identity.oidcAllowedEmailDomains`.

### Use a generic OIDC issuer for MCP

The issuer configured for browser login also protects `/mcp`. Agents present an
Expand Down
28 changes: 28 additions & 0 deletions packaging/helm/artifact-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,34 @@ the chart mounts it as a file. `identity.oidcScopes` overrides the default
`openid email profile`. The chart rejects a partial OIDC configuration, and an
OIDC client secret or scope list without an issuer and client.

Browser login requires an active membership by default. To let the configured
issuer admit new users as normal members after token validation, enable
`identity.oidcAutoAdmit` (default `false`). Leave
`identity.oidcAllowedEmailDomains` at its default empty list to trust the issuer's
access restrictions, or provide exact email domains for new registrations:

```yaml
identity:
oidcIssuer: https://idp.example.com/realms/main
oidcClientId: artifact-server
oidcAutoAdmit: true
oidcAllowedEmailDomains:
- example.com
- example.org
```

Domains are matched case-insensitively against the complete email domain; wildcards
and implicit subdomain matches are unsupported. The chart rejects enabled
automatic admission or a nonempty domain list without an OIDC issuer and client.
The rendered variables are `ARTIFACT_SERVER_OIDC_AUTO_ADMIT` and
`ARTIFACT_SERVER_OIDC_ALLOWED_EMAIL_DOMAINS`.

Bootstrap the configured first administrator before other users sign in. Existing
members keep their roles and may sign in outside the registration domain list;
deactivated identities stay denied even when their provider email changes.
Automatic admission applies only to OIDC browser login and grants no administrator
role. MCP bearer authentication and WorkOS admission keep their existing rules.

The same issuer also protects the MCP endpoint: agents may present an end-user
access token instead of an API key, and the server binds each call to the person
who obtained it. Such a token must name `configuration.applicationOrigin`
Expand Down
7 changes: 7 additions & 0 deletions packaging/helm/artifact-server/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- if and (not $hasOidcIssuer) (or (ne .Values.identity.oidcScopes "") (ne .Values.secret.keys.oidcClientSecret "")) -}}
{{- fail "identity.oidcScopes and secret.keys.oidcClientSecret require identity.oidcClientId and identity.oidcIssuer" -}}
{{- end -}}
{{- if and (not $hasOidcIssuer) (or .Values.identity.oidcAutoAdmit (gt (len .Values.identity.oidcAllowedEmailDomains) 0)) -}}
{{- fail "identity.oidcAutoAdmit and identity.oidcAllowedEmailDomains require identity.oidcClientId and identity.oidcIssuer" -}}
{{- end -}}
{{- if and $hasOidcIssuer $hasWorkosIssuer -}}
{{- fail "one installation has one browser-login provider: configure the identity.workos values or the identity.oidc values, not both" -}}
{{- end -}}
Expand Down Expand Up @@ -186,6 +189,10 @@ app.kubernetes.io/managed-by: {{ .Release.Service }}
value: /run/secrets/artifact-server/s3-secret-access-key
{{- end }}
{{- if .Values.identity.oidcIssuer }}
- name: ARTIFACT_SERVER_OIDC_AUTO_ADMIT
value: {{ .Values.identity.oidcAutoAdmit | quote }}
- name: ARTIFACT_SERVER_OIDC_ALLOWED_EMAIL_DOMAINS
value: {{ join "," .Values.identity.oidcAllowedEmailDomains | quote }}
{{- if .Values.secret.keys.oidcClientSecret }}
- name: ARTIFACT_SERVER_OIDC_CLIENT_SECRET_FILE
value: /run/secrets/artifact-server/oidc-client-secret
Expand Down
Loading