diff --git a/.env.example b/.env.example index b16835f2..4f913ce7 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,12 @@ 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 +# Microsoft Entra ID: bind people by the tenant-wide object ID instead of the +# per-app sub, accept the API's client ID as the MCP audience, and advertise the +# scope MCP clients must request. See docs/deployment.md. +# ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM=oid +# ARTIFACT_SERVER_OIDC_MCP_AUDIENCE=00000000-0000-0000-0000-000000000000 +# ARTIFACT_SERVER_OIDC_MCP_SCOPES=https://artifacts.example.com/mcp/access # 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. diff --git a/docs/deployment.md b/docs/deployment.md index 873fcbdb..2b0bed45 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -61,10 +61,11 @@ the client the agents use. Okta sets the audience on a custom authorization server. A provider that supports RFC 8707 resource indicators can bind it per request instead. -Microsoft Entra ID cannot protect `/mcp` this way. Its v2.0 access tokens name -the API's client ID in `aud`, not a URL, and its userinfo endpoint accepts only -Microsoft Graph tokens. Entra installations keep browser login and use API keys -for MCP. +A provider that cannot bind the URL can name a different audience instead. Set +`ARTIFACT_SERVER_OIDC_MCP_AUDIENCE` to the exact value its access tokens carry; +it then replaces `/mcp`. Set +`ARTIFACT_SERVER_OIDC_MCP_SCOPES` to the scopes a client must request for such +a token, and the server advertises them in its protected-resource metadata. On first use, the token or the issuer's userinfo response must carry the person's `email` with `email_verified: true`. A person who already signed in @@ -91,6 +92,53 @@ The server reads the issuer's discovery document once at startup. If the issuer cannot be reached then, the server logs a warning and starts with browser login and API keys only. MCP OAuth stays off until the next restart. +### Use Microsoft Entra ID + +Entra ID works for browser login and for `/mcp` with one app registration that +represents Artifact Server: + +1. Register a single-tenant web application. Add the redirect URI + `/auth/callback`, create a client secret, and in the + manifest set `api.requestedAccessTokenVersion` to `2` + (`accessTokenAcceptedVersion` in the older manifest format). +2. Under **Expose an API**, set the Application ID URI to exactly + `/mcp` and add a delegated scope, for example + `access`. MCP clients send that URL as the RFC 8707 `resource` parameter, and + Entra refuses a `resource` that does not match the scope's application + (`AADSTS9010010`). An HTTPS Application ID URI must use a domain verified in + the tenant. +3. Configure the issuer and the Entra-specific values: + + ```sh + ARTIFACT_SERVER_OIDC_ISSUER=https://login.microsoftonline.com//v2.0 + ARTIFACT_SERVER_OIDC_CLIENT_ID= + ARTIFACT_SERVER_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc-client-secret + ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM=oid + ARTIFACT_SERVER_OIDC_MCP_AUDIENCE= + ARTIFACT_SERVER_OIDC_MCP_SCOPES=/mcp/access + ``` + + Entra v2.0 access tokens always name the client ID in `aud`. Its `sub` differs + for every app registration, while `oid` identifies the person across the + tenant, so a later move to another registration keeps every membership. + Choose the subject claim before the first login: changing it later creates + new bindings. +4. Entra offers no dynamic client registration. Give agents the client ID, and + add each client's loopback redirect URI to the app registration. For Claude + Code, for example: + + ```sh + claude mcp add --transport http --client-id \ + --client-secret --callback-port 33418 artifact-server /mcp + ``` + + with `http://localhost:33418/callback` registered as a web redirect URI. + +Entra access tokens carry no `email` and no `email_verified`, and its userinfo +endpoint accepts only Microsoft Graph tokens. A person is therefore recognized +on `/mcp` after signing in through the browser once; a first contact through +MCP alone is refused by the admission rules above. + ## Back up the installation Back up metadata and artifact files as one coordinated recovery set. Use the procedure in the selected deployment guide. diff --git a/packaging/compose/.env.example b/packaging/compose/.env.example index 6b5bd8aa..5edeafe4 100644 --- a/packaging/compose/.env.example +++ b/packaging/compose/.env.example @@ -37,3 +37,9 @@ ARTIFACT_SERVER_OIDC_CLIENT_ID=artifact-server # 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 +# Microsoft Entra ID: bind people by the tenant-wide object ID instead of the +# per-app sub, accept the API's client ID as the MCP audience, and advertise the +# scope MCP clients must request. See docs/deployment.md. +# ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM=oid +# ARTIFACT_SERVER_OIDC_MCP_AUDIENCE=00000000-0000-0000-0000-000000000000 +# ARTIFACT_SERVER_OIDC_MCP_SCOPES=https://artifacts.example.com/mcp/access diff --git a/packaging/compose/compose.external-storage.yaml b/packaging/compose/compose.external-storage.yaml index 9b9644d6..7732e9f9 100644 --- a/packaging/compose/compose.external-storage.yaml +++ b/packaging/compose/compose.external-storage.yaml @@ -17,7 +17,10 @@ services: - ARTIFACT_SERVER_OIDC_CLIENT_ID - ARTIFACT_SERVER_OIDC_CLIENT_SECRET_FILE - ARTIFACT_SERVER_OIDC_ISSUER + - ARTIFACT_SERVER_OIDC_MCP_AUDIENCE + - ARTIFACT_SERVER_OIDC_MCP_SCOPES - ARTIFACT_SERVER_OIDC_SCOPES + - ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM - ARTIFACT_SERVER_ORIGIN=${ARTIFACT_SERVER_ORIGIN:?Set ARTIFACT_SERVER_ORIGIN} - ARTIFACT_SERVER_POSTGRES_MAX_CONNECTIONS=${ARTIFACT_SERVER_POSTGRES_MAX_CONNECTIONS:-10} - ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS=${ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS:-1000} diff --git a/packaging/compose/compose.yaml b/packaging/compose/compose.yaml index 76f20945..89063e40 100644 --- a/packaging/compose/compose.yaml +++ b/packaging/compose/compose.yaml @@ -16,7 +16,10 @@ services: - ARTIFACT_SERVER_OIDC_CLIENT_SECRET - ARTIFACT_SERVER_OIDC_CLIENT_SECRET_FILE - ARTIFACT_SERVER_OIDC_ISSUER + - ARTIFACT_SERVER_OIDC_MCP_AUDIENCE + - ARTIFACT_SERVER_OIDC_MCP_SCOPES - ARTIFACT_SERVER_OIDC_SCOPES + - ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM - ARTIFACT_SERVER_ORIGIN=${ARTIFACT_SERVER_ORIGIN:?Set the HTTPS application origin} - ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS=${ARTIFACT_SERVER_READINESS_WITHDRAWAL_MS:-1000} - ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE=${ARTIFACT_SERVER_REQUEST_LOG_SAMPLE_RATE:-0.01} diff --git a/packaging/helm/artifact-server/README.md b/packaging/helm/artifact-server/README.md index db6b055d..3922ec21 100644 --- a/packaging/helm/artifact-server/README.md +++ b/packaging/helm/artifact-server/README.md @@ -141,6 +141,11 @@ who obtained it. Such a token must name `configuration.applicationOrigin` followed by `/mcp` in `aud`, which the provider produces from an audience mapper or an RFC 8707 resource indicator. +`identity.oidcMcpAudience` replaces that audience for a provider that names +something else, `identity.oidcMcpScopes` lists the scopes MCP clients must +request, and `identity.oidcSubjectClaim` changes the claim that binds a person +from `sub`. Microsoft Entra ID uses all three; see the deployment guide. + One installation has one browser-login provider. The chart rejects values that configure neither provider or configure WorkOS and OIDC together. diff --git a/packaging/helm/artifact-server/templates/_helpers.tpl b/packaging/helm/artifact-server/templates/_helpers.tpl index bb50b641..55283bcf 100644 --- a/packaging/helm/artifact-server/templates/_helpers.tpl +++ b/packaging/helm/artifact-server/templates/_helpers.tpl @@ -71,8 +71,8 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} {{- if ne $hasOidcClient $hasOidcIssuer -}} {{- fail "identity.oidcClientId and identity.oidcIssuer must be configured together" -}} {{- end -}} -{{- 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" -}} +{{- if and (not $hasOidcIssuer) (or (ne .Values.identity.oidcScopes "") (ne .Values.identity.oidcSubjectClaim "") (ne .Values.identity.oidcMcpAudience "") (ne .Values.identity.oidcMcpScopes "") (ne .Values.secret.keys.oidcClientSecret "")) -}} +{{- fail "identity.oidcScopes, identity.oidcSubjectClaim, identity.oidcMcpAudience, identity.oidcMcpScopes, and secret.keys.oidcClientSecret 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" -}} @@ -198,6 +198,18 @@ app.kubernetes.io/managed-by: {{ .Release.Service }} - name: ARTIFACT_SERVER_OIDC_SCOPES value: {{ .Values.identity.oidcScopes | quote }} {{- end }} +{{- if .Values.identity.oidcSubjectClaim }} +- name: ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM + value: {{ .Values.identity.oidcSubjectClaim | quote }} +{{- end }} +{{- if .Values.identity.oidcMcpAudience }} +- name: ARTIFACT_SERVER_OIDC_MCP_AUDIENCE + value: {{ .Values.identity.oidcMcpAudience | quote }} +{{- end }} +{{- if .Values.identity.oidcMcpScopes }} +- name: ARTIFACT_SERVER_OIDC_MCP_SCOPES + value: {{ .Values.identity.oidcMcpScopes | quote }} +{{- end }} {{- end }} {{- if .Values.identity.workosClientId }} - name: ARTIFACT_SERVER_WORKOS_API_KEY_FILE diff --git a/packaging/helm/artifact-server/values.schema.json b/packaging/helm/artifact-server/values.schema.json index 7f459499..deecb2eb 100644 --- a/packaging/helm/artifact-server/values.schema.json +++ b/packaging/helm/artifact-server/values.schema.json @@ -126,13 +126,16 @@ "identity": { "type": "object", "additionalProperties": false, - "required": ["workosClientId", "workosIssuer", "oidcClientId", "oidcIssuer", "oidcScopes"], + "required": ["workosClientId", "workosIssuer", "oidcClientId", "oidcIssuer", "oidcScopes", "oidcSubjectClaim", "oidcMcpAudience", "oidcMcpScopes"], "properties": { "workosClientId": {"type": "string"}, "workosIssuer": {"type": "string"}, "oidcClientId": {"type": "string"}, "oidcIssuer": {"type": "string"}, - "oidcScopes": {"type": "string"} + "oidcScopes": {"type": "string"}, + "oidcSubjectClaim": {"type": "string"}, + "oidcMcpAudience": {"type": "string"}, + "oidcMcpScopes": {"type": "string"} } }, "serviceAccount": { diff --git a/packaging/helm/artifact-server/values.yaml b/packaging/helm/artifact-server/values.yaml index 258c6f86..3ac2014d 100644 --- a/packaging/helm/artifact-server/values.yaml +++ b/packaging/helm/artifact-server/values.yaml @@ -44,6 +44,9 @@ identity: oidcClientId: "" oidcIssuer: "" oidcScopes: "" + oidcSubjectClaim: "" + oidcMcpAudience: "" + oidcMcpScopes: "" serviceAccount: create: true diff --git a/project/spec/conformance.yml b/project/spec/conformance.yml index 7e009b1f..a6b40ee8 100644 --- a/project/spec/conformance.yml +++ b/project/spec/conformance.yml @@ -1710,6 +1710,20 @@ requirements: depends_on: [AUTH-001, AUTH-023, AUTH-025] evidence: [] + - id: AUTH-029 + kind: constraint + behavior: A generic OIDC installation may bind people by a configured subject claim on browser login and MCP alike, require one configured MCP audience instead of /mcp, and advertise the scopes MCP clients must request; an issuer that omits code_challenge_methods_supported is accepted while one that lists methods without S256 is refused. + owner: authorization + source: {file: decisions/0029-oidc-provider-claim-mapping.md, anchor: decision} + acceptance: + behavior: {id: AUTH-029-B, description: "Against an issuer shaped like Microsoft Entra ID, the protected-resource metadata advertises the configured scopes, browser login binds the configured claim, a changed sub keeps one membership, and an email-less access token naming the configured audience reaches MCP as the same member."} + failure: {id: AUTH-029-F, description: "A missing or blank subject claim fails browser login and MCP without members or sessions; tokens naming /mcp beside a configured audience and ID tokens sharing that audience are refused; a discovery document listing PKCE methods without S256 is refused; partial or malformed claim, audience, and scope configuration fails startup."} + deployments: [local, single_server, kubernetes, aws, gcp] + status: implementing + proof_gap: Local conformance tests drive a stub issuer shaped like Entra, and a manual run proved Claude Code browser approval against a real Entra tenant. Deployment-specific evidence and an automated real-Entra run are not recorded. + depends_on: [AUTH-019, AUTH-021, MCP-013] + evidence: [] + - id: SEC-001 kind: security behavior: Published websites are treated as untrusted arbitrary browser JavaScript. @@ -1964,7 +1978,7 @@ requirements: - id: MCP-013 kind: behavior - behavior: Local mode connects through artifactserver connect and a stdio bridge without browser OAuth or a human-visible secret; self-hosted mode uses browser OAuth when compatible authorization is configured and otherwise uses administration-issued scoped API keys. A generic OIDC issuer is compatible authorization when it signs verifiable access-token JWTs, binds the exact /mcp audience, and offers a client registration path its clients can use; the installation registers that client and never runs an authorization server of its own. + behavior: Local mode connects through artifactserver connect and a stdio bridge without browser OAuth or a human-visible secret; self-hosted mode uses browser OAuth when compatible authorization is configured and otherwise uses administration-issued scoped API keys. A generic OIDC issuer is compatible authorization when it signs verifiable access-token JWTs, binds the exact /mcp audience or the one audience the installation configures, and offers a client registration path its clients can use; the installation registers that client and never runs an authorization server of its own. owner: mcp source: {file: artifact-server-product-spec.html, anchor: mcp} acceptance: diff --git a/project/spec/decisions/0028-oidc-mcp-oauth.md b/project/spec/decisions/0028-oidc-mcp-oauth.md index e9a6141b..af74bad1 100644 --- a/project/spec/decisions/0028-oidc-mcp-oauth.md +++ b/project/spec/decisions/0028-oidc-mcp-oauth.md @@ -49,6 +49,9 @@ admission gate, which still decides who may enter. ### The audience is the MCP URL, and nothing else +> Amended by [0029](0029-oidc-provider-claim-mapping.md): an installation may +> configure one different audience, which then replaces the MCP URL. + `aud` must contain `/mcp`, per the MCP specification, and there is no setting that accepts a different value. Membership in a multi-valued `aud` is enough: Keycloak names `account` beside the requested diff --git a/project/spec/decisions/0029-oidc-provider-claim-mapping.md b/project/spec/decisions/0029-oidc-provider-claim-mapping.md new file mode 100644 index 00000000..9494413e --- /dev/null +++ b/project/spec/decisions/0029-oidc-provider-claim-mapping.md @@ -0,0 +1,132 @@ +# 0029: OIDC provider claim mapping for Microsoft Entra ID + +**Status:** Accepted +**Date:** October 8, 2026 + +Amends "The audience is the MCP URL, and nothing else" in +[0028: MCP OAuth through the configured OIDC issuer](0028-oidc-mcp-oauth.md). + +## Decision + +A generic OIDC installation can now name three provider-specific facts that +were fixed before, each through one optional variable whose default keeps +today's behavior: + +| Variable | Default | What it changes | +| --- | --- | --- | +| `ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM` | `sub` | The ID-token and access-token claim that binds a person to a member | +| `ARTIFACT_SERVER_OIDC_MCP_AUDIENCE` | `/mcp` | The one `aud` value an MCP access token must contain | +| `ARTIFACT_SERVER_OIDC_MCP_SCOPES` | none | The scopes advertised as `scopes_supported` in the MCP protected-resource metadata | + +Discovery also stops refusing an issuer that leaves +`code_challenge_methods_supported` out. An issuer that lists its methods +without `S256` is still refused, and both browser login and MCP clients always +send `S256`. + +All three variables belong to the `ARTIFACT_SERVER_OIDC_*` family, so they take +part in the all-or-nothing and WorkOS-exclusion checks like the existing ones. + +## Why + +Microsoft Entra ID is the identity provider of many companies that would run +Artifact Server as a private-team installation, and 0028 recorded that it +"cannot protect `/mcp` this way". Tested against a real Entra tenant with +Claude Code as the MCP client, four facts stood in the way, and each maps to +one change: + +1. **Discovery omits the PKCE methods.** Entra supports S256 but its v2.0 + discovery document has no `code_challenge_methods_supported`, so startup + turned MCP OAuth off with "OIDC discovery does not advertise S256 PKCE". +2. **`aud` is the API's client ID.** Entra v2.0 access tokens always name the + resource application's client ID in `aud`, never a URL, whatever the + client requested. +3. **Clients must ask for the API's own scope.** Without one, Entra issues a + Microsoft Graph token that no third party can verify. An MCP client learns + which scope to request from `scopes_supported`, which this server did not + publish. +4. **`sub` is pairwise per app registration.** The same person has a different + `sub` for every application, while `oid` is stable across the tenant. An + installation that moves to a new app registration, or splits browser and + MCP into two, would otherwise orphan every member binding. + +One fact needs no code. An MCP client sends the protected resource URL as the +RFC 8707 `resource` parameter, and Entra refuses a `resource` that does not +match the requested scope's application (`AADSTS9010010`). The fix is on the +Entra side: the API's Application ID URI is set to `/mcp`, so the +scope reads `/mcp/` and matches. The deployment guide records +this step. + +With these changes, Claude Code completed browser approval against Entra +(authorization code with S256 PKCE, a pre-registered client, and the +`resource` parameter) and called `/mcp` as the member who signed in through +the browser. That is the browser-approval half of MCP-013-B that the ledger +listed as unproved for a generic OIDC issuer. + +## Recorded decisions + +### The configured audience replaces the resource URL + +When `ARTIFACT_SERVER_OIDC_MCP_AUDIENCE` is set, it is the only accepted +audience; `/mcp` is no longer accepted beside it. Accepting both would +widen the resource server to tokens minted for a second audience nobody +configured. Membership in a multi-valued `aud` is still enough, as 0028 +records for Keycloak. + +The protected-resource metadata keeps naming `/mcp` as the resource. +MCP clients compare it with the URL they connected to and refuse a mismatch, +so only the verified audience changes, not the advertised resource. + +### An ID token stays refused when it shares the audience + +With the client ID as the MCP audience, an ID token issued to the same client +carries the same `aud`. 0028 already refuses any JWT typed or shaped as an ID +token (`nonce`, `at_hash`, `c_hash`, payload `typ` of `ID`), and Entra ID +tokens always carry `nonce` for this flow. AUTH-029-F proves the refusal with +the configured audience. + +### The subject claim applies to both paths at once + +One variable sets the claim for browser login and for MCP tokens, because the +binding `oidc:` plus subject must match whichever way a person +arrives. A token or ID token without the configured claim, or with a blank +one, names nobody and is refused. The userinfo fallback still compares the +userinfo `sub` with the access token's own `sub`, because userinfo answers +with `sub` whatever claim binds the person here. + +Changing the claim on an installation that already has members creates new +bindings on the next login. The deployment guide says to choose it before the +first login. + +### No email fallback + +Entra access tokens carry no `email` and no `email_verified`. This decision +does not map `preferred_username` to an email address. 0028's rule stands: an +access token must carry a verified email before it can link a member or claim +the bootstrap administrator. A person who signed in through the browser once +is recognized on the MCP path by issuer and subject alone, which is the normal +case for an installation whose members sign in to the interface first. + +## What stays excluded + +- Cloudflare. Its worker has no generic OIDC MCP path, and its deployment + contract does not carry these variables yet. +- Any Entra-specific code path or provider name. The variables are generic; + Entra is the case that motivated them. + +## Rejected alternatives + +### Advertise the client ID as the protected resource + +MCP clients refuse protected-resource metadata whose `resource` does not match +the server URL they connected to, so this breaks every compliant client. + +### Accept `/mcp` and the configured audience together + +This accepts tokens for an audience the operator did not choose and gives no +benefit: an installation either binds the URL or configures something else. + +### Use `oid` automatically when the issuer is Microsoft's + +This hides an identity decision behind a hostname match, does not help other +providers with pairwise subjects, and would silently rebind existing +installations on upgrade. diff --git a/project/spec/oidc-interactive-login-spec.md b/project/spec/oidc-interactive-login-spec.md index 9f946654..00f71018 100644 --- a/project/spec/oidc-interactive-login-spec.md +++ b/project/spec/oidc-interactive-login-spec.md @@ -34,6 +34,9 @@ New variables, mirroring Workspaces' `OIDC_*` names under this repo's `ARTIFACT_ | `ARTIFACT_SERVER_OIDC_CLIENT_ID` | yes | Non-empty string. | | `ARTIFACT_SERVER_OIDC_CLIENT_SECRET` / `…_FILE` | no | Optional: a public client using PKCE alone is valid, as in Workspaces. When set, sent as `client_secret` in the token-request form body (`client_secret_post`, what Workspaces does). | | `ARTIFACT_SERVER_OIDC_SCOPES` | no | Default `"openid email profile"` (Workspaces' default). | +| `ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM` | no | Default `sub`. The claim that binds a person on browser login and MCP; Microsoft Entra ID installations use `oid`. Added by [0029](decisions/0029-oidc-provider-claim-mapping.md). | +| `ARTIFACT_SERVER_OIDC_MCP_AUDIENCE` | no | Default `/mcp`. The one `aud` value MCP access tokens must carry; replaces the default. Added by 0029. | +| `ARTIFACT_SERVER_OIDC_MCP_SCOPES` | no | Space-separated scopes advertised as `scopes_supported` for `/mcp`. Added by 0029. | | `ARTIFACT_SERVER_ORIGIN` | yes (shared) | Already required for WorkOS login; the redirect URI is exactly `/auth/callback`. | | `ARTIFACT_SERVER_BOOTSTRAP_ADMIN_EMAIL` | yes (shared) | Already required for WorkOS login; unchanged meaning. | @@ -104,6 +107,7 @@ Module `authorization`, three new requirements in the existing ledger style (sou | AUTH-019 | behavior | A deployment configured with a generic OIDC issuer completes browser login through discovery, auth-code with PKCE and nonce, id_token verification, and normal admission, on every deployment kind. | Sign in against a real stub OIDC provider; an admitted member gets a session, the bootstrap administrator is admitted on first contact, display name and issuer-qualified binding are recorded, and a second login reuses the binding. | An unadmitted verified identity, an unverified email, and a missing email are refused without creating members or sessions. | | AUTH-020 | security | The OIDC callback fails closed on every token-validation defect. | Valid logins succeed under a 30-second clock skew. | Wrong signature, wrong issuer, wrong audience, expired token, replayed or unknown state, expired attempt, and wrong nonce are each refused; no session or member is created; codes and tokens never appear in logs. | | AUTH-021 | constraint | OIDC configuration is all-or-nothing, mutually exclusive with WorkOS, and issuer/endpoint URLs obey the HTTPS rules. | A complete OIDC configuration boots and reports `interactiveIdentityProvider: "oidc"`; absence of both providers keeps login unavailable while keys still work. | Partial OIDC configuration, WorkOS-plus-OIDC together, a non-HTTPS non-local issuer, and discovery documents with a mismatched issuer or credentialed/fragmented endpoint URLs each fail startup or login without weakening the running server. | +| AUTH-029 | constraint | A configured subject claim, MCP audience, and MCP scopes, and issuers that omit the PKCE methods ([0029](decisions/0029-oidc-provider-claim-mapping.md)). | An Entra-shaped issuer binds one member by `oid` across browser login and an email-less MCP token. | Missing subject claim, the default audience beside a configured one, ID tokens sharing the audience, PKCE lists without S256, and malformed configuration are refused. | **Test plan** (repo rules: no module mocks, real boundaries): a stub OIDC provider in `tests/support/stub-oidc-provider.ts` — a real in-process HTTP server that serves `/.well-known/openid-configuration`, a JWKS for a freshly generated ES256 key, an authorize endpoint that redirects with `code` and echoes `state`, and a token endpoint that signs real id_tokens with configurable claims (`nonce`, `aud`, `iss`, `exp`, `email_verified`). Workspaces' module-level `setOidcFetchForTesting` seam is exactly what this repo's no-module-mocking rule forbids; the stub server replaces it. Conformance tests drive the real HTTP app: `GET /auth/login` → follow the redirect to the stub → callback → assert session cookie, member records, and the failure matrix. Local `http://127.0.0.1` issuers are legitimate under the section-3 rule, so the stub needs no TLS. diff --git a/scripts/verify-helm-chart.sh b/scripts/verify-helm-chart.sh index 1063b7d4..a2c80f78 100755 --- a/scripts/verify-helm-chart.sh +++ b/scripts/verify-helm-chart.sh @@ -69,12 +69,18 @@ expect_render_failure "identity.oidcClientId and identity.oidcIssuer must be con "${artifactserver_values[@]}" \ --set identity.oidcIssuer= -expect_render_failure "identity.oidcScopes and secret.keys.oidcClientSecret require" \ +expect_render_failure "and secret.keys.oidcClientSecret require identity.oidcClientId and identity.oidcIssuer" \ "${artifactserver_values[@]}" \ --set identity.oidcClientId= \ --set identity.oidcIssuer= \ --set identity.oidcScopes="openid email profile" +expect_render_failure "and secret.keys.oidcClientSecret require identity.oidcClientId and identity.oidcIssuer" \ + "${artifactserver_values[@]}" \ + --set identity.oidcClientId= \ + --set identity.oidcIssuer= \ + --set identity.oidcMcpAudience=00000000-0000-4000-8000-0000000000a1 + expect_render_failure "one installation has one browser-login provider" \ "${artifactserver_values[@]}" \ --set identity.workosClientId=client_workos \ diff --git a/src/cli/oidc-configuration.ts b/src/cli/oidc-configuration.ts index 8fd42d8a..3a6b341f 100644 --- a/src/cli/oidc-configuration.ts +++ b/src/cli/oidc-configuration.ts @@ -3,6 +3,7 @@ import {z} from "zod"; import {defaultOidcScopes} from "../identity/oidc-identity-provider.js"; import {requireOidcIssuer} from "../identity/oidc-issuer.js"; +import {requireOidcClaimName} from "../identity/oidc-subject.js"; import { loadOptionalCredential, type LoadedSecret, @@ -13,7 +14,12 @@ const oidcEnvironmentSchema = z.object({ ARTIFACT_SERVER_BOOTSTRAP_ADMIN_EMAIL: z.email().optional(), ARTIFACT_SERVER_OIDC_CLIENT_ID: z.string().min(1).optional(), ARTIFACT_SERVER_OIDC_ISSUER: z.string().min(1).optional(), + ARTIFACT_SERVER_OIDC_MCP_AUDIENCE: z.string().trim().regex(/^\S+$/u, { + message: "ARTIFACT_SERVER_OIDC_MCP_AUDIENCE must be one audience value.", + }).optional(), + ARTIFACT_SERVER_OIDC_MCP_SCOPES: z.string().trim().min(1).optional(), ARTIFACT_SERVER_OIDC_SCOPES: z.string().min(1).optional(), + ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM: z.string().min(1).optional(), ARTIFACT_SERVER_ORIGIN: z.url().optional(), }); @@ -24,7 +30,10 @@ export interface OidcConfiguration { readonly clientId: string; readonly clientSecret: Redacted.Redacted | null; readonly issuer: string; + readonly mcpAudience?: string; + readonly mcpScopes?: string; readonly scopes: string; + readonly subjectClaim?: string; } /** Load all-or-nothing OIDC settings, including a file-backed client secret. */ @@ -41,7 +50,10 @@ export async function loadOidcConfiguration( clientSecret === null && parsed.ARTIFACT_SERVER_OIDC_CLIENT_ID === undefined && parsed.ARTIFACT_SERVER_OIDC_ISSUER === undefined && - parsed.ARTIFACT_SERVER_OIDC_SCOPES === undefined + parsed.ARTIFACT_SERVER_OIDC_MCP_AUDIENCE === undefined && + parsed.ARTIFACT_SERVER_OIDC_MCP_SCOPES === undefined && + parsed.ARTIFACT_SERVER_OIDC_SCOPES === undefined && + parsed.ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM === undefined ) { return null; } @@ -53,10 +65,10 @@ export async function loadOidcConfiguration( ]; if (requiredValues.some((value) => value === undefined)) { throw new Error( - "Generic OIDC authentication requires ARTIFACT_SERVER_ORIGIN, ARTIFACT_SERVER_BOOTSTRAP_ADMIN_EMAIL, ARTIFACT_SERVER_OIDC_ISSUER, and ARTIFACT_SERVER_OIDC_CLIENT_ID. ARTIFACT_SERVER_OIDC_CLIENT_SECRET or ARTIFACT_SERVER_OIDC_CLIENT_SECRET_FILE and ARTIFACT_SERVER_OIDC_SCOPES are optional.", + "Generic OIDC authentication requires ARTIFACT_SERVER_ORIGIN, ARTIFACT_SERVER_BOOTSTRAP_ADMIN_EMAIL, ARTIFACT_SERVER_OIDC_ISSUER, and ARTIFACT_SERVER_OIDC_CLIENT_ID. ARTIFACT_SERVER_OIDC_CLIENT_SECRET or ARTIFACT_SERVER_OIDC_CLIENT_SECRET_FILE, ARTIFACT_SERVER_OIDC_SCOPES, ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM, ARTIFACT_SERVER_OIDC_MCP_AUDIENCE, and ARTIFACT_SERVER_OIDC_MCP_SCOPES are optional.", ); } - return { + let configuration: OidcConfiguration = { applicationOrigin: requireString(parsed.ARTIFACT_SERVER_ORIGIN), bootstrapAdministratorEmail: requireString( parsed.ARTIFACT_SERVER_BOOTSTRAP_ADMIN_EMAIL, @@ -69,6 +81,28 @@ export async function loadOidcConfiguration( ), scopes: parsed.ARTIFACT_SERVER_OIDC_SCOPES ?? defaultOidcScopes, }; + if (parsed.ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM !== undefined) { + configuration = { + ...configuration, + subjectClaim: requireOidcClaimName( + parsed.ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM, + "ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM", + ), + }; + } + if (parsed.ARTIFACT_SERVER_OIDC_MCP_AUDIENCE !== undefined) { + configuration = { + ...configuration, + mcpAudience: parsed.ARTIFACT_SERVER_OIDC_MCP_AUDIENCE, + }; + } + if (parsed.ARTIFACT_SERVER_OIDC_MCP_SCOPES !== undefined) { + configuration = { + ...configuration, + mcpScopes: parsed.ARTIFACT_SERVER_OIDC_MCP_SCOPES, + }; + } + return configuration; } /** diff --git a/src/http/create-http-app.ts b/src/http/create-http-app.ts index 45aa95cc..588ac567 100644 --- a/src/http/create-http-app.ts +++ b/src/http/create-http-app.ts @@ -503,6 +503,8 @@ export interface ApiOAuthResourceConfiguration { export interface McpOAuthResourceConfiguration { readonly authorizationServerMetadata: OAuthMetadata; readonly resource: string; + /** Scopes a client must request at the issuer to obtain an MCP token. */ + readonly scopesSupported?: readonly string[]; } /** Machine-readable result of one declared runtime dependency probe. */ @@ -604,12 +606,18 @@ export function createHttpApp( const resourceServerUrl = new URL( dependencies.mcpOAuthResource.resource, ); - const metadataOptions = { + let metadataOptions: Parameters< + typeof buildOAuthProtectedResourceMetadata + >[0] = { oauthMetadata: dependencies.mcpOAuthResource.authorizationServerMetadata, resourceName: "Artifact Server MCP", resourceServerUrl, }; + const scopesSupported = dependencies.mcpOAuthResource.scopesSupported; + if (scopesSupported !== undefined) { + metadataOptions = {...metadataOptions, scopesSupported: [...scopesSupported]}; + } const protectedMetadata = { ...buildOAuthProtectedResourceMetadata(metadataOptions), bearer_methods_supported: ["header"], diff --git a/src/identity/oidc-hosted-authentication.ts b/src/identity/oidc-hosted-authentication.ts index cd2d874c..3d1083c1 100644 --- a/src/identity/oidc-hosted-authentication.ts +++ b/src/identity/oidc-hosted-authentication.ts @@ -3,7 +3,10 @@ import type {Redacted} from "effect"; import type {ExternalMcpBearerVerifier} from "../application/authentication.js"; import type {InteractiveIdentityProvider} from "../application/interactive-login.js"; import type {McpOAuthResourceConfiguration} from "../http/create-http-app.js"; -import {createOidcIdentityProvider} from "./oidc-identity-provider.js"; +import { + createOidcIdentityProvider, + type OidcBrowserLoginSettings, +} from "./oidc-identity-provider.js"; import {requireOidcIssuer} from "./oidc-issuer.js"; import { OidcMcpBearerVerifier, @@ -17,7 +20,16 @@ export interface OidcHostedAuthenticationConfig { readonly clientSecret: Redacted.Redacted | null; readonly fetch?: typeof globalThis.fetch; readonly issuer: string; + /** + * The `aud` value MCP access tokens must carry; `/mcp` when left out. + * Microsoft Entra ID v2.0 tokens name the API's client ID instead. + */ + readonly mcpAudience?: string; + /** Scopes MCP clients must request at the issuer, advertised to them. */ + readonly mcpScopes?: string; readonly scopes: string; + /** The claim that binds a person on both paths; `sub` when left out. */ + readonly subjectClaim?: string; } export interface OidcHostedAuthentication { @@ -37,7 +49,7 @@ export async function createOidcHostedAuthentication( config.fetch === undefined ? {} : {fetch: config.fetch}, ); let verifierConfig: OidcMcpBearerVerifierConfig = { - audience: resource, + audience: config.mcpAudience ?? resource, issuer, jwksUri: authorizationServer.jwksUri, userInfoEndpoint: authorizationServer.userInfoEndpoint, @@ -45,18 +57,31 @@ export async function createOidcHostedAuthentication( if (config.fetch !== undefined) { verifierConfig = {...verifierConfig, fetch: config.fetch}; } + if (config.subjectClaim !== undefined) { + verifierConfig = {...verifierConfig, subjectClaim: config.subjectClaim}; + } + let browserLogin: OidcBrowserLoginSettings = { + applicationOrigin: config.applicationOrigin, + clientId: config.clientId, + clientSecret: config.clientSecret, + issuer, + scopes: config.scopes, + }; + if (config.subjectClaim !== undefined) { + browserLogin = {...browserLogin, subjectClaim: config.subjectClaim}; + } + let mcpOAuthResource: McpOAuthResourceConfiguration = { + authorizationServerMetadata: authorizationServer.metadata, + resource, + }; + const mcpScopes = config.mcpScopes?.split(/\s+/u) + .filter((scope) => scope !== "") ?? []; + if (mcpScopes.length > 0) { + mcpOAuthResource = {...mcpOAuthResource, scopesSupported: mcpScopes}; + } return { externalMcpOAuthVerifier: new OidcMcpBearerVerifier(verifierConfig), - interactiveIdentityProvider: createOidcIdentityProvider({ - applicationOrigin: config.applicationOrigin, - clientId: config.clientId, - clientSecret: config.clientSecret, - issuer, - scopes: config.scopes, - }), - mcpOAuthResource: { - authorizationServerMetadata: authorizationServer.metadata, - resource, - }, + interactiveIdentityProvider: createOidcIdentityProvider(browserLogin), + mcpOAuthResource, }; } diff --git a/src/identity/oidc-identity-provider.ts b/src/identity/oidc-identity-provider.ts index 7b283be2..f7837618 100644 --- a/src/identity/oidc-identity-provider.ts +++ b/src/identity/oidc-identity-provider.ts @@ -14,6 +14,11 @@ import { normalizeOidcIssuer, requireOidcIssuer, } from "./oidc-issuer.js"; +import { + defaultOidcSubjectClaim, + oidcSubjectOf, + requireOidcClaimName, +} from "./oidc-subject.js"; /** Scopes requested when a deployment does not configure its own. */ export const defaultOidcScopes = "openid email profile"; @@ -70,6 +75,8 @@ export interface OidcIdentityProviderConfig { readonly issuer: string; readonly redirectUri: string; readonly scopes: string; + /** The ID-token claim that binds a person; `sub` when left out. */ + readonly subjectClaim?: string; } /** Complete generic OIDC browser-login settings loaded by one deployment. */ @@ -79,6 +86,7 @@ export interface OidcBrowserLoginSettings { readonly clientSecret: Redacted.Redacted | null; readonly issuer: string; readonly scopes: string; + readonly subjectClaim?: string; } /** Generic OpenID Connect adapter for self-hosted browser login. */ @@ -90,6 +98,7 @@ export class OidcIdentityProvider implements InteractiveIdentityProvider { readonly #issuer: string; readonly #redirectUri: string; readonly #scopes: string; + readonly #subjectClaim: string; #discovered: OidcDiscovery | null = null; #keys: JWTVerifyGetKey | null = null; @@ -101,6 +110,10 @@ export class OidcIdentityProvider implements InteractiveIdentityProvider { this.#issuer = requireOidcIssuer(config.issuer, "The OIDC issuer"); this.#redirectUri = config.redirectUri; this.#scopes = config.scopes; + this.#subjectClaim = requireOidcClaimName( + config.subjectClaim ?? defaultOidcSubjectClaim, + "The OIDC subject claim", + ); } readonly start = Effect.fn("OidcIdentityProvider.start")( @@ -171,12 +184,22 @@ export class OidcIdentityProvider implements InteractiveIdentityProvider { if (nonce === null || claims.data.nonce !== nonce) { return yield* providerFailure(); } + const subject = this.#subjectClaim === defaultOidcSubjectClaim + ? claims.data.sub + : oidcSubjectOf(payload, this.#subjectClaim); + if (subject === null) { + return yield* reportFailure( + "identity.oidc.subject_missing", + this.#issuer, + `the ID token carries no ${this.#subjectClaim} claim`, + ); + } const identity: ExternalIdentity = { displayName: displayName(claims.data), email: claims.data.email, emailVerified: claims.data.email_verified !== false, provider: `oidc:${this.#issuer}`, - subject: claims.data.sub, + subject, }; return identity; }, @@ -213,13 +236,17 @@ export class OidcIdentityProvider implements InteractiveIdentityProvider { export function createOidcIdentityProvider( settings: OidcBrowserLoginSettings, ): OidcIdentityProvider { - return new OidcIdentityProvider({ + let config: OidcIdentityProviderConfig = { clientId: settings.clientId, clientSecret: settings.clientSecret, issuer: settings.issuer, redirectUri: new URL("/auth/callback", settings.applicationOrigin).toString(), scopes: settings.scopes, - }); + }; + if (settings.subjectClaim !== undefined) { + config = {...config, subjectClaim: settings.subjectClaim}; + } + return new OidcIdentityProvider(config); } const loadDiscovery = Effect.fn("OidcIdentityProvider.loadDiscovery")( diff --git a/src/identity/oidc-mcp-bearer-verifier.ts b/src/identity/oidc-mcp-bearer-verifier.ts index 9a1a8e27..8b9e33bb 100644 --- a/src/identity/oidc-mcp-bearer-verifier.ts +++ b/src/identity/oidc-mcp-bearer-verifier.ts @@ -18,6 +18,11 @@ import { } from "../core/errors.js"; import type {ExternalIdentity} from "../core/installation-identity.js"; import {requireOidcIssuer} from "./oidc-issuer.js"; +import { + defaultOidcSubjectClaim, + oidcSubjectOf, + requireOidcClaimName, +} from "./oidc-subject.js"; const defaultAlgorithms = ["RS256", "ES256"]; const idTokenType = "ID"; @@ -58,16 +63,22 @@ const userInfoClaims = Schema.Struct({ const decodeAccessTokenClaims = Schema.decodeUnknownEffect(accessTokenClaims); const decodeUserInfoClaims = Schema.decodeUnknownEffect(userInfoClaims); -type AccessTokenClaims = typeof accessTokenClaims.Type; +type DecodedAccessTokenClaims = typeof accessTokenClaims.Type; +type AccessTokenClaims = DecodedAccessTokenClaims & { + /** The value of the configured subject claim, which names the person. */ + readonly subject: string; +}; type ProfileClaims = typeof userInfoClaims.Type; export interface OidcMcpBearerVerifierConfig { readonly algorithms?: readonly string[]; - /** The exact MCP resource URL the issuer must bind into `aud`. */ + /** The exact value the issuer must bind into `aud` for this MCP resource. */ readonly audience: string; readonly fetch?: typeof globalThis.fetch; readonly issuer: string; readonly jwksUri: string; + /** The access-token claim that binds a person; `sub` when left out. */ + readonly subjectClaim?: string; readonly userInfoEndpoint?: string | null; } @@ -79,6 +90,7 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { readonly #issuer: string; readonly #jwks: ReturnType; readonly #provider: string; + readonly #subjectClaim: string; readonly #userInfoEndpoint: string | null; constructor(config: OidcMcpBearerVerifierConfig) { @@ -90,6 +102,10 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { // Browser login records the same provider name, so one person keeps one // membership whether they arrive through the interface or through MCP. this.#provider = `oidc:${this.#issuer}`; + this.#subjectClaim = requireOidcClaimName( + config.subjectClaim ?? defaultOidcSubjectClaim, + "The OIDC subject claim", + ); this.#userInfoEndpoint = config.userInfoEndpoint ?? null; this.#jwks = createRemoteJWKSet(new URL(config.jwksUri), { cacheMaxAge: jwksCacheMilliseconds, @@ -113,7 +129,7 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { expiresAt: claims.exp, provider: this.#provider, scopes: tokenScopes(claims.scope), - subject: claims.sub, + subject: claims.subject, }; return verified; }, @@ -129,14 +145,14 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { return yield* invalidToken("The access token issuer is not supported."); } const claims = yield* this.#verifiedClaims(credential); - if (claims.sub !== verified.subject) { + if (claims.subject !== verified.subject) { return yield* invalidToken( "The access token names a different subject than the verified one.", ); } const email = emailOf(claims); - if (email !== null) return this.#identityOf(claims, email); - return yield* this.#userInfoIdentity(credential, claims.sub); + if (email !== null) return this.#identityOf(claims, claims.subject, email); + return yield* this.#userInfoIdentity(credential, claims); }, ); @@ -160,7 +176,18 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { catch: (cause) => verificationFailure(cause), }).pipe( Effect.flatMap(({payload, protectedHeader}) => - decodeClaims(payload, protectedHeader) + decodeClaims(payload, protectedHeader).pipe( + Effect.flatMap((claims) => { + const subject = this.#subjectClaim === defaultOidcSubjectClaim + ? claims.sub + : oidcSubjectOf(payload, this.#subjectClaim); + return subject === null + ? Effect.fail(invalidToken( + `The OIDC access token carries no ${this.#subjectClaim} claim.`, + )) + : Effect.succeed({...claims, subject}); + }), + ) ), ); } @@ -169,7 +196,7 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { function*( this: OidcMcpBearerVerifier, credential: Redacted.Redacted, - subject: string, + claims: AccessTokenClaims, ) { const endpoint = this.#userInfoEndpoint; if (endpoint === null) { @@ -211,7 +238,8 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { "The OIDC userinfo endpoint returned an invalid profile.", )), ); - if (profile.sub !== subject) { + // Userinfo answers with `sub`, whichever claim binds the person here. + if (profile.sub !== claims.sub) { return yield* providerUnavailable( "The OIDC userinfo endpoint returned a different subject.", ); @@ -222,11 +250,15 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { "The OIDC profile for this access token carries no email address.", ); } - return this.#identityOf(profile, email); + return this.#identityOf(profile, claims.subject, email); }, ); - #identityOf(claims: ProfileClaims, email: string): ExternalIdentity { + #identityOf( + claims: ProfileClaims, + subject: string, + email: string, + ): ExternalIdentity { return { displayName: displayName(claims, email), email, @@ -235,7 +267,7 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { // can link an admitted member or claim the bootstrap administrator. emailVerified: claims.email_verified === true, provider: this.#provider, - subject: claims.sub, + subject, }; } } @@ -243,7 +275,7 @@ export class OidcMcpBearerVerifier implements ExternalMcpBearerVerifier { function decodeClaims( payload: JWTPayload, header: JWTHeaderParameters, -): Effect.Effect { +): Effect.Effect { // An ID token, logout token, or any other JWT from the same issuer is not a // credential for this endpoint even when it names the same audience. if (!isAccessTokenType(header.typ)) { diff --git a/src/identity/oidc-oauth-metadata.ts b/src/identity/oidc-oauth-metadata.ts index 9647bf59..b9c99283 100644 --- a/src/identity/oidc-oauth-metadata.ts +++ b/src/identity/oidc-oauth-metadata.ts @@ -95,7 +95,11 @@ export async function loadOidcAuthorizationServer( "OIDC discovery does not support authorization code login.", ); } - if (!document.code_challenge_methods_supported?.includes("S256")) { + // Microsoft Entra ID supports S256 but leaves this field out of discovery, + // so only an issuer that lists its methods without S256 is refused. Clients + // always send S256, and an issuer that cannot verify it fails the exchange. + const pkceMethods = document.code_challenge_methods_supported; + if (pkceMethods !== undefined && !pkceMethods.includes("S256")) { throw new Error("OIDC discovery does not advertise S256 PKCE."); } let metadata: OAuthMetadata = { diff --git a/src/identity/oidc-subject.ts b/src/identity/oidc-subject.ts new file mode 100644 index 00000000..182232e8 --- /dev/null +++ b/src/identity/oidc-subject.ts @@ -0,0 +1,32 @@ +import {Predicate} from "effect"; +import type {JWTPayload} from "jose"; + +/** The claim that names a person when a deployment does not choose one. */ +export const defaultOidcSubjectClaim = "sub"; + +const claimNamePattern = /^[A-Za-z0-9_.:-]+$/u; + +/** Validate one configured JWT claim name, or refuse the configuration by name. */ +export function requireOidcClaimName(value: string, name: string): string { + const trimmed = value.trim(); + if (!claimNamePattern.test(trimmed)) { + throw new Error(`${name} must name one JWT claim, such as sub or oid.`); + } + return trimmed; +} + +/** + * Read the configured subject claim from verified token claims. + * + * Microsoft Entra ID issues a different `sub` to every app registration, so a + * deployment can bind people by a claim that stays stable across them, such as + * `oid`. The value must be a non-blank string; anything else names nobody. + */ +export function oidcSubjectOf( + claims: JWTPayload, + claim: string, +): string | null { + const value = claims[claim]; + if (!Predicate.isString(value) || value.trim() === "") return null; + return value; +} diff --git a/tests/conformance/auth-029-oidc-provider-claims.test.ts b/tests/conformance/auth-029-oidc-provider-claims.test.ts new file mode 100644 index 00000000..3e9fcc79 --- /dev/null +++ b/tests/conformance/auth-029-oidc-provider-claims.test.ts @@ -0,0 +1,283 @@ +import path from "node:path"; +import {DatabaseSync} from "node:sqlite"; + +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, +} from "@modelcontextprotocol/server"; +import {Redacted} from "effect"; +import type {JWTPayload} from "jose"; +import {afterEach, beforeEach, describe, expect, test} from "vitest"; +import {z} from "zod"; + +import {loadOidcConfiguration} from "../../src/cli/oidc-configuration.js"; +import { + browserLoginKinds, + privateTeamBrowserAccess, +} from "../../src/core/browser-access.js"; +import {createOidcHostedAuthentication} from + "../../src/identity/oidc-hosted-authentication.js"; +import {loadOidcAuthorizationServer} from + "../../src/identity/oidc-oauth-metadata.js"; +import { + createTestInstallation, + removeTestInstallation, + reserveLoopbackPort, + type RunningTestServer, + startTestServer, + type TestInstallation, +} from "../support/runtime-harness.js"; +import { + type RunningStubOidcProvider, + startStubOidcLogin, + startStubOidcProvider, +} from "../support/stub-oidc-provider.js"; + +// Shaped after Microsoft Entra ID v2.0: discovery omits the PKCE methods, every +// app registration sees a different `sub`, the tenant-wide `oid` stays the same, +// and an access token for the API names its client ID in `aud`. +const administratorEmail = "administrator@example.test"; +const clientId = "00000000-0000-4000-8000-0000000000a1"; +const clientSecret = "stub-oidc-client-secret-with-entropy"; +const objectId = "00000000-0000-4000-8000-00000000b0b0"; +const protocolVersion = "2026-07-28"; +const scopes = "openid email profile"; +const externalIdentityRowSchema = z.object({ + member_id: z.string(), + provider: z.string(), + subject: z.string(), +}); +const countRowSchema = z.object({total: z.number().int().nonnegative()}); + +describe("OIDC provider claim mapping", () => { + let applicationOrigin: string; + let installation: TestInstallation; + let mcpScope: string; + let provider: RunningStubOidcProvider; + let server: RunningTestServer; + + beforeEach(async () => { + installation = await createTestInstallation(); + provider = await startStubOidcProvider({clientId, clientSecret}); + const port = await reserveLoopbackPort(); + applicationOrigin = `http://127.0.0.1:${port}`; + mcpScope = `${applicationOrigin}/mcp/access`; + const hosted = await createOidcHostedAuthentication({ + applicationOrigin, + clientId, + clientSecret: Redacted.make(clientSecret), + issuer: provider.issuer, + mcpAudience: clientId, + mcpScopes: `${mcpScope} offline_access`, + scopes, + subjectClaim: "oid", + }); + server = await startTestServer(installation, { + applicationOrigin, + bootstrapAdministratorEmail: administratorEmail, + browserAccess: privateTeamBrowserAccess(browserLoginKinds.oidc), + externalMcpOAuthVerifier: hosted.externalMcpOAuthVerifier, + interactiveIdentityProvider: hosted.interactiveIdentityProvider, + mcpOAuthResource: hosted.mcpOAuthResource, + port, + }); + }); + + afterEach(async () => { + await server.stop(); + await provider.stop(); + await removeTestInstallation(installation); + }); + + test("AUTH-029-B: an issuer without advertised PKCE binds one person by the configured claim across browser login and MCP", async () => { + expect.hasAssertions(); + const protectedMetadata = await fetch( + `${server.baseUrl}/.well-known/oauth-protected-resource/mcp`, + ); + expect(await protectedMetadata.json()).toMatchObject({ + authorization_servers: [provider.issuer], + resource: `${applicationOrigin}/mcp`, + scopes_supported: [mcpScope, "offline_access"], + }); + + provider.claims.email = administratorEmail; + provider.claims.objectId = objectId; + provider.claims.subject = "pairwise-subject-for-app-a"; + expect((await completeStubLogin(server.baseUrl)).status).toBe(303); + const [binding] = externalIdentities(installation); + expect(binding).toMatchObject({ + provider: `oidc:${provider.issuer}`, + subject: objectId, + }); + + // A new app registration changes `sub`; the person keeps one membership. + provider.claims.subject = "pairwise-subject-for-app-b"; + expect((await completeStubLogin(server.baseUrl)).status).toBe(303); + expect(externalIdentities(installation)).toEqual([binding]); + + // The MCP access token carries no email at all, as Entra's does not; the + // bound object ID alone recognizes the member. + const accessToken = await provider.signJwt(accessTokenClaims({ + oid: objectId, + sub: "pairwise-subject-for-the-api", + })); + expect((await mcpDiscovery(accessToken)).status).toBe(200); + expect(rowCount(installation, "installation_members")).toBe(1); + expect(externalIdentities(installation)).toEqual([binding]); + }); + + test("AUTH-029-F: the configured audience, subject claim, and PKCE rule refuse every other contract", async () => { + expect.hasAssertions(); + provider.claims.email = administratorEmail; + provider.claims.objectId = null; + provider.claims.subject = "subject-without-object-id"; + const withoutObjectId = await completeStubLogin(server.baseUrl); + expect(withoutObjectId.status).toBe(502); + expect(withoutObjectId.headers.getSetCookie()).toEqual([]); + expect(rowCount(installation, "installation_members")).toBe(0); + expect(rowCount(installation, "external_identities")).toBe(0); + + const refused = await Promise.all([ + // The configured audience replaces the resource URL instead of adding to it. + accessTokenClaims({aud: `${applicationOrigin}/mcp`, oid: objectId}), + accessTokenClaims({oid: undefined}), + accessTokenClaims({oid: " "}), + // An ID token for the same client names the same audience. + accessTokenClaims({nonce: "browser-login-nonce", oid: objectId}), + ].map(async (claims) => + (await mcpDiscovery(await provider.signJwt(claims))).status + )); + expect(refused).toEqual([401, 401, 401, 401]); + expect(rowCount(installation, "installation_members")).toBe(0); + + await expect(loadOidcAuthorizationServer(provider.issuer, { + fetch: async () => Response.json({ + authorization_endpoint: `${provider.issuer}/authorize`, + code_challenge_methods_supported: ["plain"], + issuer: provider.issuer, + jwks_uri: `${provider.issuer}/jwks`, + response_types_supported: ["code"], + token_endpoint: `${provider.issuer}/token`, + }), + })).rejects.toThrow("S256 PKCE"); + + await expect(loadOidcConfiguration({ + ARTIFACT_SERVER_OIDC_MCP_AUDIENCE: clientId, + })).rejects.toThrow("Generic OIDC authentication requires"); + const complete = { + ARTIFACT_SERVER_BOOTSTRAP_ADMIN_EMAIL: administratorEmail, + ARTIFACT_SERVER_OIDC_CLIENT_ID: clientId, + ARTIFACT_SERVER_OIDC_ISSUER: provider.issuer, + ARTIFACT_SERVER_ORIGIN: applicationOrigin, + }; + await expect(loadOidcConfiguration({ + ...complete, + ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM: "oid sub", + })).rejects.toThrow("ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM must name one JWT claim"); + await expect(loadOidcConfiguration({ + ...complete, + ARTIFACT_SERVER_OIDC_MCP_AUDIENCE: `${clientId} other`, + })).rejects.toThrow("must be one audience value"); + await expect(loadOidcConfiguration({ + ...complete, + ARTIFACT_SERVER_OIDC_MCP_AUDIENCE: clientId, + ARTIFACT_SERVER_OIDC_MCP_SCOPES: mcpScope, + ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM: "oid", + })).resolves.toMatchObject({ + mcpAudience: clientId, + mcpScopes: mcpScope, + subjectClaim: "oid", + }); + }); + + function accessTokenClaims( + overrides: Readonly>, + ): JWTPayload { + const now = Math.floor(Date.now() / 1_000); + const claims: JWTPayload = { + aud: clientId, + azp: "claude-code", + exp: now + 300, + iat: now, + iss: provider.issuer, + name: "Artifact Administrator", + preferred_username: administratorEmail, + scp: "access", + sub: "pairwise-subject-for-the-api", + ver: "2.0", + }; + for (const [claim, value] of Object.entries(overrides)) { + if (value === undefined) delete claims[claim]; + else claims[claim] = value; + } + return claims; + } + + function mcpDiscovery(token: string): Promise { + return fetch(`${server.baseUrl}/mcp`, { + body: JSON.stringify({ + id: crypto.randomUUID(), + jsonrpc: "2.0", + method: "server/discover", + params: { + _meta: { + [CLIENT_CAPABILITIES_META_KEY]: {}, + [CLIENT_INFO_META_KEY]: {name: "oidc-claims-test", version: "1"}, + [PROTOCOL_VERSION_META_KEY]: protocolVersion, + }, + }, + }), + headers: { + Accept: "application/json, text/event-stream", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "MCP-Protocol-Version": protocolVersion, + "Mcp-Method": "server/discover", + }, + method: "POST", + }); + } +}); + +async function completeStubLogin(baseUrl: string): Promise { + const authorization = await startStubOidcLogin(baseUrl); + return fetch(authorization.callbackUrl, { + headers: {Cookie: authorization.handshakeCookie}, + redirect: "manual", + }); +} + +function externalIdentities( + installation: TestInstallation, +): readonly z.infer[] { + const database = openIdentityDatabase(installation); + try { + return database + .prepare( + "SELECT provider, subject, member_id FROM external_identities ORDER BY subject", + ) + .all() + .map((row) => externalIdentityRowSchema.parse(row)); + } finally { + database.close(); + } +} + +function rowCount(installation: TestInstallation, table: string): number { + const database = openIdentityDatabase(installation); + try { + return countRowSchema.parse( + database.prepare(`SELECT COUNT(*) AS total FROM ${table}`).get(), + ).total; + } finally { + database.close(); + } +} + +function openIdentityDatabase(installation: TestInstallation): DatabaseSync { + return new DatabaseSync( + path.join(installation.dataDirectory, "artifact-server.db"), + {readOnly: true}, + ); +} diff --git a/tests/release/helm-chart.test.ts b/tests/release/helm-chart.test.ts index 64fb47f3..c2322b30 100644 --- a/tests/release/helm-chart.test.ts +++ b/tests/release/helm-chart.test.ts @@ -610,7 +610,10 @@ async function writeValues( identity: { oidcClientId: "helm-integration", oidcIssuer: "https://identity.example.test", + oidcMcpAudience: "", + oidcMcpScopes: "", oidcScopes: "openid email profile", + oidcSubjectClaim: "", workosClientId: "", workosIssuer: "", }, diff --git a/tests/support/stub-oidc-provider.ts b/tests/support/stub-oidc-provider.ts index c0594360..dd2f0c2e 100644 --- a/tests/support/stub-oidc-provider.ts +++ b/tests/support/stub-oidc-provider.ts @@ -71,6 +71,8 @@ export interface StubOidcClaims { issuer: string | null; name: string | null; nonce: string | null; + /** An Entra-style tenant-wide object ID, minted as `oid` when set. */ + objectId: string | null; subject: string; } @@ -94,6 +96,8 @@ export interface RunningStubOidcProvider { readonly defects: StubOidcDefects; readonly issuer: string; authorizationRequests(): readonly StubAuthorizationRequest[]; + /** Sign an arbitrary JWT payload with this issuer's published key. */ + signJwt(payload: JWTPayload): Promise; stop(): Promise; tokenRequests(): readonly StubTokenRequest[]; } @@ -140,6 +144,7 @@ export async function startStubOidcProvider( issuer: null, name: "Stub Administrator", nonce: null, + objectId: null, subject: "stub-oidc-subject-1", }; const defects: StubOidcDefects = { @@ -286,6 +291,9 @@ export async function startStubOidcProvider( clientId, defects, issuer, + signJwt: (payload) => new SignJWT(payload) + .setProtectedHeader({alg: signingAlgorithm, kid: signingKeyId}) + .sign(signing.privateKey), stop: () => closeServer(server), tokenRequests: () => tokenRequests, }; @@ -319,6 +327,7 @@ function idTokenPayload( if (claims.familyName !== null) payload["family_name"] = claims.familyName; if (claims.givenName !== null) payload["given_name"] = claims.givenName; if (claims.name !== null) payload["name"] = claims.name; + if (claims.objectId !== null) payload["oid"] = claims.objectId; return payload; }