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
37 changes: 37 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,43 @@ 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.

### Use a generic OIDC issuer for MCP

The issuer configured for browser login also protects `/mcp`. Agents present an
end-user access token, and the server records the person who obtained it. The
server never issues client credentials and never runs an authorization server of
its own.

The issuer must provide four things:

- an OpenID Connect discovery document at `<issuer>/.well-known/openid-configuration`;
- access tokens signed as JWTs with RS256 or ES256, verifiable against the
published JWKS;
- the authorization code flow with S256 PKCE;
- an access token whose `aud` contains the exact `<ARTIFACT_SERVER_ORIGIN>/mcp`.

The audience is the one step an operator must configure. Providers do not bind a
resource URL on their own. In Keycloak, add a client scope with an audience
mapper whose included custom audience is that exact URL, and assign the scope to
the client the agents use. Entra exposes an API and uses its application ID URI.
Okta sets the audience on a custom authorization server. A provider that
supports RFC 8707 resource indicators can bind it per request instead.

Register the client the agents use in one of two supported ways:

- the issuer offers RFC 7591 dynamic client registration, its discovery document
advertises `registration_endpoint`, and each client registers itself;
- an administrator registers one client in the issuer and gives its client ID to
the agents that need it.

Artifact Server publishes RFC 9728 protected-resource metadata at
`/.well-known/oauth-protected-resource/mcp` naming the issuer, and answers an
unauthenticated MCP request with `401` and a `resource_metadata` challenge, so a
compliant client finds the issuer without further configuration.

Clients that cannot complete OAuth keep using administration-issued API keys.
Tokens that name another resource, and ID tokens, are refused.

## 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/helm/artifact-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ 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.

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`
followed by `/mcp` in `aud`, which the provider produces from an audience mapper
or an RFC 8707 resource indicator.

One installation has one browser-login provider. The chart rejects values that
configure neither provider or configure WorkOS and OIDC together.

Expand Down
6 changes: 3 additions & 3 deletions project/spec/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1964,15 +1964,15 @@ 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.
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.
owner: mcp
source: {file: artifact-server-product-spec.html, anchor: mcp}
acceptance:
behavior: {id: MCP-013-B, description: "Connect locally through the stdio bridge with no visible credential, connect to a private OAuth server through browser approval, and connect to a no-OAuth server using an administration-issued revocable API key."}
behavior: {id: MCP-013-B, description: "Connect locally through the stdio bridge with no visible credential, connect to a private OAuth server through browser approval including a generic OIDC issuer whose access token names the exact /mcp resource, and connect to a no-OAuth server using an administration-issued revocable API key."}
failure: {id: MCP-013-F, description: "Startup logs, diagnostics, project files, generic OIDC ID tokens, browser login cookies, and wrong-resource tokens cannot expose or substitute for a valid MCP credential."}
deployments: [local, single_server, kubernetes, aws, gcp]
status: implementing
proof_gap: Local stdio and API-key paths are implemented, but the combined private OAuth, no-OAuth, local, wrong-credential, and credential-leak matrix has no deployment-specific MCP-013 evidence.
proof_gap: Local stdio, API-key, and generic OIDC access-token paths are implemented and covered by the local conformance suite and the real Keycloak harness, but the combined private OAuth, no-OAuth, local, wrong-credential, and credential-leak matrix has no deployment-specific MCP-013 evidence.
depends_on: [AUTH-009]
evidence: []

Expand Down
13 changes: 8 additions & 5 deletions project/spec/decisions/0020-generic-oidc-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,14 @@ the end-session endpoint.

## What stays excluded

- MCP OAuth. The MCP bearer path needs an authorization server that clients can
register against and whose tokens it can introspect. A bare enterprise
identity provider is generally not that server, so MCP authorization stays
WorkOS-only and OIDC installations use administration-issued API keys, which
already work everywhere.
- MCP OAuth, until [0028](./0028-oidc-mcp-oauth.md) reversed this. The exclusion
read that the MCP bearer path needs an authorization server clients can
register against and whose tokens it can introspect, and that a bare
enterprise identity provider is generally not that server. Introspection was
never part of the path: the WorkOS verifier reads a JWT against a discovered
JWKS, which a Keycloak, Entra, or Okta access token supports as well.
Registration is an operator step rather than a protocol gap. 0028 records what
an issuer must provide instead.
- Directory sync, SCIM, role and group mapping, and provisioning beyond the
existing bootstrap-administrator rule. Admission stays explicit.
- Refresh tokens and `offline_access`. Sessions are server-side records with
Expand Down
127 changes: 127 additions & 0 deletions project/spec/decisions/0028-oidc-mcp-oauth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# 0028: MCP OAuth through the configured OIDC issuer

**Status:** Accepted
**Date:** September 14, 2026

Supersedes the "MCP OAuth" exclusion recorded in
[0020: Generic OIDC browser login](0020-generic-oidc-login.md).

## Decision

An installation that configures a generic OIDC issuer for browser login now
also accepts end-user access tokens from that issuer at `/mcp`. A second
implementation of the existing `ExternalMcpBearerVerifier` port,
`OidcMcpBearerVerifier`, sits beside the WorkOS one. Managed API keys keep
working and are still checked first, so nothing an installation already uses
changes.

0020 excluded this on the reading that a bare enterprise identity provider is
not an authorization server MCP clients can register against. Keycloak, Entra,
Okta, and Auth0 all publish `/.well-known/openid-configuration`, most of them
offer dynamic client registration, and an MCP client that reads RFC 9728
protected-resource metadata reaches the issuer on its own. What the exclusion
actually costs is real: without it every agent on a self-hosted installation
shares one API key, and the server records one author for everyone behind it.

## What it does

Startup fetches the issuer's OIDC discovery document, validates it, and keeps
three things: the JWKS URI, the userinfo endpoint, and the document itself. The
document is served back at `/.well-known/oauth-authorization-server`, and
`/.well-known/oauth-protected-resource/mcp` names the issuer as the
authorization server for the `<origin>/mcp` resource. An unauthenticated MCP
request answers 401 with `resource_metadata`, which is the whole handshake an
MCP client needs.

A presented token is verified against the discovered JWKS: signature, RS256 or
ES256, exact issuer, audience, expiry, and a non-empty subject, with the same
thirty-second clock tolerance browser login uses. The member binding is
`oidc:<normalized issuer>` paired with `sub`, the same binding browser login
writes, so one person keeps one membership whichever way they arrive.

Identity on first use comes from the token's own `email`, `name`,
`given_name`, `family_name`, and `preferred_username` claims. An installation
whose access tokens carry no email falls back to the discovered userinfo
endpoint, called once with the presented token. Both paths end at the existing
admission gate, which still decides who may enter.

## Recorded decisions

### The audience is the MCP URL, and nothing else

`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
audience, and refusing that would refuse Keycloak. Binding the resource URL is
the operator's step, through an audience mapper or an RFC 8707 resource
indicator, and the deployment guide says so.

### An ID token is not an MCP credential

A payload `typ` of `ID` is refused. Keycloak marks its ID tokens that way, and
MCP-013-F requires that an ID token cannot substitute for an MCP credential.

### An issuer that cannot serve its keys is unavailable, not a bad token

A key-set response that is not 200 arrives from `jose` as a generic error, which
would otherwise read as an invalid token and answer 401. The key-set fetch
therefore raises its own failure, and the endpoint answers with a provider
failure instead of blaming the credential.

### Discovery runs at startup, and a down issuer only turns MCP OAuth off

The protected-resource document cannot be served without the discovery
document, so it is read once at startup, like the WorkOS path reads its
authorization-server metadata. Unlike that path, a discovery failure is not
fatal: the process writes one warning to stderr and starts with browser login
and managed API keys, which is exactly the behavior an installation had before
this change. An identity provider that is briefly down must not take the
artifact server down with it, and browser login keeps its own lazy discovery
anyway.

### The credential travels to identity resolution

`resolveIdentity` now receives the credential beside the verified claims. The
WorkOS implementation ignores it and reads its own API. The OIDC implementation
needs it, because an issuer's userinfo endpoint answers the presenter of the
token, not a server credential.

### Client registration belongs to the issuer

The installation registers one client, or the issuer offers RFC 7591 dynamic
registration and clients register themselves. Artifact Server advertises
whatever `registration_endpoint` the issuer publishes and issues no client
credentials of its own, which keeps MCP-014 intact: no embedded authorization
server appears here.

## What stays excluded

- OAuth on the HTTP API. `apiOAuthResource` and `externalApiBearerVerifier`
stay unset, so `/api/` keeps accepting managed API keys only. Advertising an
authorization server for a resource that cannot accept its tokens would be a
false promise.
- Scope checks. The resource-bound audience grants MCP access, matching the
rule MCP-011 already records for WorkOS.
- Dynamic client registration by Artifact Server. The issuer owns registration;
Artifact Server only points clients at it.

## Rejected alternatives

### Keep MCP on API keys for OIDC installations

This is the status quo 0020 recorded. It gives every agent the same identity,
makes revocation all-or-nothing, and puts a long-lived shared secret into every
client configuration, including gateways that forward other people's requests.

### Cache the profile claims from `verify` for `resolveIdentity` to read

This avoids the port change by keeping token claims in a map between two calls
of the same request. It adds a cache with an eviction policy, a race, and a
failure mode that only appears under load, to avoid passing a value that the
caller already holds.

### Accept any audience and rely on the issuer check

An access token minted for another service in the same realm would then open
this one. Audience binding is the property that makes a resource server safe to
point several clients at.
2 changes: 2 additions & 0 deletions src/application/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ export interface VerifiedExternalMcpBearer {

/** External MCP token verification and first-use identity resolution. */
export interface ExternalMcpBearerVerifier {
/** The credential travels along so a provider can read the profile it carries. */
readonly resolveIdentity: (
verified: VerifiedExternalMcpBearer,
credential: Redacted.Redacted,
) => Effect.Effect<
ExternalIdentity,
AuthenticationRequired | IdentityProviderFailure
Expand Down
38 changes: 34 additions & 4 deletions src/cli/lifecycle-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import {
startExternalStorageServer,
type ExternalStorageServerConfig,
} from "../external-storage/start-external-storage-server.js";
import {
createOidcHostedAuthentication,
type OidcHostedAuthentication,
} from "../identity/oidc-hosted-authentication.js";
import {createOidcIdentityProvider} from
"../identity/oidc-identity-provider.js";
import {createWorkOsHostedAuthentication} from
Expand Down Expand Up @@ -49,6 +53,7 @@ import {waitForProcessSignal} from "./wait-for-process-signal.js";
import {
assertAtMostOneBrowserLoginProvider,
loadOidcConfiguration,
type OidcConfiguration,
} from "./oidc-configuration.js";
import {loadWorkOsConfiguration} from "./workos-configuration.js";
import {writeGitHistoryConfigurationWarnings} from
Expand Down Expand Up @@ -308,6 +313,9 @@ function configureExternalStorageStart(
const hostedAuthentication = workOs === null
? null
: await createWorkOsHostedAuthentication(workOs);
const oidcAuthentication = oidc === null
? null
: await oidcAuthenticationOrBrowserOnly(oidc);
const browserAccess = hostedAuthentication !== null
? privateTeamBrowserAccess(browserLoginKinds.workOs)
: oidc !== null
Expand Down Expand Up @@ -338,10 +346,10 @@ function configureExternalStorageStart(
...hostedAuthentication,
};
}
if (oidc !== null) {
if (oidcAuthentication !== null) {
serverConfig = {
...serverConfig,
interactiveIdentityProvider: createOidcIdentityProvider(oidc),
...oidcAuthentication,
};
}
const server = await startExternalStorageServer(serverConfig);
Expand Down Expand Up @@ -376,6 +384,9 @@ async function startCompactServer(
const hostedAuthentication = workOs === null
? null
: await createWorkOsHostedAuthentication(workOs);
const oidcAuthentication = oidc === null
? null
: await oidcAuthenticationOrBrowserOnly(oidc);
const browserAccess = hostedAuthentication !== null
? privateTeamBrowserAccess(browserLoginKinds.workOs)
: oidc !== null
Expand Down Expand Up @@ -407,15 +418,34 @@ async function startCompactServer(
...hostedAuthentication,
};
}
if (oidc !== null) {
if (oidcAuthentication !== null) {
serverConfig = {
...serverConfig,
interactiveIdentityProvider: createOidcIdentityProvider(oidc),
...oidcAuthentication,
};
}
return startLocalServer(serverConfig);
}

type OidcServerAuthentication =
| OidcHostedAuthentication
| Pick<OidcHostedAuthentication, "interactiveIdentityProvider">;

/** MCP OAuth needs the issuer at startup; browser login must survive it being down. */
async function oidcAuthenticationOrBrowserOnly(
oidc: OidcConfiguration,
): Promise<OidcServerAuthentication> {
try {
return await createOidcHostedAuthentication(oidc);
} catch (cause) {
const reason = cause instanceof Error ? cause.message : "the request failed";
process.stderr.write(
`OIDC configuration warning (discovery_failed): MCP OAuth stays off for ${oidc.issuer}: ${reason}\n`,
);
return {interactiveIdentityProvider: createOidcIdentityProvider(oidc)};
}
}

async function lifecycleConfiguration(
options: LifecycleOptions,
): Promise<CompactRuntimeConfiguration | ExternalStorageRuntimeConfiguration> {
Expand Down
62 changes: 62 additions & 0 deletions src/identity/oidc-hosted-authentication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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 {requireOidcIssuer} from "./oidc-issuer.js";
import {
OidcMcpBearerVerifier,
type OidcMcpBearerVerifierConfig,
} from "./oidc-mcp-bearer-verifier.js";
import {loadOidcAuthorizationServer} from "./oidc-oauth-metadata.js";

export interface OidcHostedAuthenticationConfig {
readonly applicationOrigin: string;
readonly clientId: string;
readonly clientSecret: Redacted.Redacted | null;
readonly fetch?: typeof globalThis.fetch;
readonly issuer: string;
readonly scopes: string;
}

export interface OidcHostedAuthentication {
readonly externalMcpOAuthVerifier: ExternalMcpBearerVerifier;
readonly interactiveIdentityProvider: InteractiveIdentityProvider;
readonly mcpOAuthResource: McpOAuthResourceConfiguration;
}

/** Build browser and MCP authentication from one generic OIDC issuer. */
export async function createOidcHostedAuthentication(
config: OidcHostedAuthenticationConfig,
): Promise<OidcHostedAuthentication> {
const issuer = requireOidcIssuer(config.issuer, "ARTIFACT_SERVER_OIDC_ISSUER");
const resource = new URL("/mcp", config.applicationOrigin).toString();
const authorizationServer = await loadOidcAuthorizationServer(
issuer,
config.fetch === undefined ? {} : {fetch: config.fetch},
);
let verifierConfig: OidcMcpBearerVerifierConfig = {
audience: resource,
issuer,
jwksUri: authorizationServer.jwksUri,
userInfoEndpoint: authorizationServer.userInfoEndpoint,
};
if (config.fetch !== undefined) {
verifierConfig = {...verifierConfig, fetch: config.fetch};
}
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,
},
};
}
Loading