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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
56 changes: 52 additions & 4 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ARTIFACT_SERVER_ORIGIN>/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
Expand All @@ -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
`<ARTIFACT_SERVER_ORIGIN>/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
`<ARTIFACT_SERVER_ORIGIN>/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/<tenant-id>/v2.0
ARTIFACT_SERVER_OIDC_CLIENT_ID=<application-client-id>
ARTIFACT_SERVER_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc-client-secret
ARTIFACT_SERVER_OIDC_SUBJECT_CLAIM=oid
ARTIFACT_SERVER_OIDC_MCP_AUDIENCE=<application-client-id>
ARTIFACT_SERVER_OIDC_MCP_SCOPES=<ARTIFACT_SERVER_ORIGIN>/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 <application-client-id> \
--client-secret --callback-port 33418 artifact-server <ARTIFACT_SERVER_ORIGIN>/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.
Expand Down
6 changes: 6 additions & 0 deletions packaging/compose/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions packaging/compose/compose.external-storage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
3 changes: 3 additions & 0 deletions packaging/compose/compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
5 changes: 5 additions & 0 deletions packaging/helm/artifact-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
16 changes: 14 additions & 2 deletions packaging/helm/artifact-server/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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" -}}
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions packaging/helm/artifact-server/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
3 changes: 3 additions & 0 deletions packaging/helm/artifact-server/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ identity:
oidcClientId: ""
oidcIssuer: ""
oidcScopes: ""
oidcSubjectClaim: ""
oidcMcpAudience: ""
oidcMcpScopes: ""

serviceAccount:
create: true
Expand Down
16 changes: 15 additions & 1 deletion project/spec/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <origin>/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 <origin>/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.
Expand Down Expand Up @@ -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 <origin>/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 <origin>/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:
Expand Down
3 changes: 3 additions & 0 deletions project/spec/decisions/0028-oidc-mcp-oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ARTIFACT_SERVER_ORIGIN>/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
Expand Down
132 changes: 132 additions & 0 deletions project/spec/decisions/0029-oidc-provider-claim-mapping.md
Original file line number Diff line number Diff line change
@@ -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` | `<origin>/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 `<origin>/mcp`, so the
scope reads `<origin>/mcp/<scope>` 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; `<origin>/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 `<origin>/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:<issuer>` 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 `<origin>/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.
Loading