Skip to content

Commit 6b44efa

Browse files
fix(plugin-auth): register an MCP client that omits application_type with a loopback or custom-scheme redirect as native (#25156)
Fixes #25118 Clause-②: no (a runtime security-boundary change the maintainer ruled; no published type, export or metadata key moves) Metadata-protocol: none ## What changes plugin-auth now passes `clientRegistrationDefaultApplicationType: 'infer'` to `@better-auth/oauth-provider` 1.7.12. A comment names this card, ruling `6329352251` (letter A1) and the measured boundary. The whole production change is that one option line in the `oauthProvider({ ... })` options of `packages/plugins/plugin-auth/src/auth-manager.ts`. There is no hook on `/oauth2/register` and no second classification rule (ruling A1, items 1 and 4). - **Before:** a dynamic registration that omitted `application_type` was filled as `web`. The web redirect rule refuses a loopback `http` or custom-scheme redirect, so every local MCP client (Claude Code, Cursor, VS Code, Codex CLI) was refused at registration with `invalid_redirect_uri`. - **After:** the provider's own classifier treats such a body as `native` when any redirect URI uses a non-http(s) scheme or `http` on exactly `localhost`, `127.0.0.1` or `[::1]`, and it then holds each redirect to the native rule. Every other body stays `web`. An `application_type` the client sends is never overridden. - **Scope:** the classifier runs only on the dynamic registration source (`registerEndpoint` in the installed dist). The Setup form's managed `create-client` door (`/sys-oauth-application/register`) is not affected. The option does nothing while DCR is off. - **No layer in front:** plugin-auth puts nothing in front of `/oauth2/register`. The global before-hook has no branch for that path, and `auth-plugin.ts` mounts no raw route on it. ## The measured boundary, pinned on the real provider The new file `packages/plugins/plugin-auth/src/auth-manager.mcp-oauth-dcr-infer.test.ts` boots the real better-auth 1.7.12 and `@better-auth/oauth-provider` 1.7.12 handler from the exact options `AuthManager` produces (MCP on, so DCR on), then drives `POST /oauth2/register` and `GET /oauth2/authorize`. Every body omits `application_type`, as the clients send it. The first cell sends the card's own reproduction body and asserts that it has no `application_type` key. The existing `auth-manager.mcp-oauth-resource.test.ts` registers with `application_type: native` spelled out, which is why it stayed green through this defect. | Cell (no `application_type` unless stated) | Registration | Authorize | |---|---|---| | The card's body: public client, `http://localhost` redirect | 201 `native`, no secret | refused without PKCE, consent with PKCE | | Public, `http://127.0.0.1` redirect | 201 `native` | refused without PKCE, consent with PKCE | | Public, custom-scheme (`cursor://`) redirect | 201 `native` | refused without PKCE, consent with PKCE | | Public, `https://evil.example/cb` alone | 201 `web` (unchanged) | refused without PKCE, consent with PKCE | | Public, `http://evil.example/cb` | 400 `invalid_redirect_uri`, no client stored | n/a | | Public, `http://localhost.evil.example/cb` | 400 `invalid_redirect_uri`, no client stored | n/a | | Public, loopback, explicit `application_type: web` | 400 `invalid_redirect_uri` (a sent type is never overridden) | n/a | | Confidential (`client_secret_basic`), loopback | 201 `native`, **issued a `client_secret`** | refused without PKCE, consent with PKCE | | Auth method omitted (filled as `client_secret_basic`), loopback | 201 `native`, **issued a `client_secret`** | refused without PKCE, consent with PKCE | | Confidential (`client_secret_basic`), custom scheme | 201 `native`, **issued a `client_secret`** | refused without PKCE, consent with PKCE | | Public, loopback plus `https://evil.example/cb` in one body | 201 `native` (any qualifying redirect classifies the body) | refused without PKCE, consent with PKCE | | DCR off (`OS_OIDC_DCR_ENABLED=false`), the card's body | 403 `access_denied`, no client stored | n/a | - **Not "public clients only".** The confidential rows are stated as they behave. The classifier reads redirect URIs and never `token_endpoint_auth_method`. The same confidential body with `application_type: native` spelled out is already accepted on `main`, so no registrable client shape is added; only the meaning of an omitted field changes (ruling A1, item 3). - **PKCE.** PKCE stays required at authorize for every registered client: always for a public client, and for a confidential DCR client because `clientRegistrationRequirePKCE` keeps its `true` default. - **The PKCE check is a differential.** "Refused without PKCE" means a redirect back to the client's own redirect URI with `error=invalid_request`, a description that names PKCE, and no code. The paired request is identical apart from the S256 PKCE parameters (same client, session, redirect, scopes and resource) and reaches the consent page, so the refusal cannot have another cause. - **Liveness.** The existing test "every option AuthManager passes occurs in the installed provider" (`auth-manager.mcp-oauth-resource.test.ts`) now also covers the new option name. ## Verification The package readings below were taken at `5a7e1f02a`, on the shared container. The head `6d4fd2a5a` is that commit merged with `origin/main` `9cfc57533`. The four incoming commits touch `cloud-connection`, `runtime`, `service-automation`, `verify`, docs and an ADR, and none of those is in plugin-auth's dependency closure. So the package readings stand (the scoped re-check of AGENTS §10), and the gate union was re-run on `6d4fd2a5a`. The test imports `./auth-manager` from source, so the ablation needed no rebuild to reach the run. - **New file:** `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/auth-manager.mcp-oauth-dcr-infer.test.ts` gave `Test Files 1 passed (1)`, `Tests 12 passed (12)`, lock `VERDICT command-exit 0`. - **Ablation (one-time, not committed):** `scripts/ablation-replace.mjs` deleted the option line. Proof that it landed: anchor count 1 then 0, blob `9c00cadb` then `a4a53154`. Result: `Tests 7 failed | 5 passed (12)`, lock `VERDICT command-exit 1`. - Red, as predicted: all seven native cells (the card's body, `127.0.0.1`, custom scheme, the three confidential cells, the mixed body). Each got `400 invalid_redirect_uri: web clients require https redirect URIs on non-loopback hosts`, the production symptom. - Green, as predicted: the five cells that do not depend on the option (`https` alone stays `web`; the two foreign `http` hosts and explicit `web` stay refused; DCR off is 403). - Restore proven: blob equals HEAD `9c00cadb`, `git diff HEAD` empty, `git status --porcelain` empty. - **Package suite:** `pnpm --filter @objectstack/plugin-auth test` gave `Test Files 182 passed (182)`, `Tests 3450 passed | 10 skipped (3460)`, lock `VERDICT command-exit 0`. - **Typecheck:** `pnpm --filter @objectstack/plugin-auth typecheck` exited 0. That covers the source, the examples, and the test layer under `tsconfig.test.json` (debt ledger held, no growth). - **Build and published surface:** `pnpm --filter @objectstack/plugin-auth build` exited 0. The built `dist/*.d.ts` has 0 hits for `clientRegistrationDefaultApplicationType`, while `dist/index.mjs` has 1 (positive control), so no published type moves. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 70 commands at `6d4fd2a5a` (merge base `9cfc57533`). All 70 ran and exited 0, with each code captured before any pipe. The `--ran` reconciliation reads "70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN". The rest of the farm and the type-check lanes run in CI. ## Acceptance owed after this lands Ruling `6329352251` item 5 (carried from `6327761812`) requires a browser walk on a deployed environment after the cloud pin moves: `claude mcp add --transport http NAME https://ENV-HOST/api/v1/mcp`, then `/mcp`, then Authenticate, completing in the browser and listing the tools. That walk makes the environment's Setup, Integrations, Connect an Agent page true. **Not measured here:** it needs a deployed environment. ## Acceptance notes - The comment above `rawApp.post(... /sys-oauth-application/register ...)` in `auth-plugin.ts` still says DCR on `/oauth2/register` is something "we don't want to enable broadly". DCR has been on by default whenever the MCP server is on (`resolveDcrEnabled`), so that sentence is stale. It is noted only: it is outside this card's file surface and changes no behaviour. - The `describe` titles in `auth-manager.mcp-oauth-resource.test.ts` still say "installed 1.7.2" while the installed provider is 1.7.12. This is naming only, noted and not changed. --- _Generated by [Claude Code](https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2a2f5c2 commit 6b44efa

3 files changed

Lines changed: 405 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): an MCP client that registers over OAuth dynamic client registration with a loopback or custom-scheme redirect, and no `application_type`, is registered as a native client instead of being refused
6+
7+
Clause-②: no (a runtime security-boundary change the maintainer ruled; no published type, export or metadata key moves)
8+
Metadata-protocol: none
9+
10+
Local MCP clients (Claude Code, Cursor, VS Code, Codex CLI) register at `POST /api/v1/auth/oauth2/register` with a loopback `http` redirect (`http://localhost:PORT/...`, `http://127.0.0.1:PORT/...`) or a custom-scheme redirect, and send no `application_type`. The OAuth provider used to fill the missing type with `web` and refuse both redirect forms (`invalid_redirect_uri: web clients require https redirect URIs on non-loopback hosts`), so these clients could not connect to the MCP endpoint over OAuth and API keys were the only working path.
11+
12+
plugin-auth now passes `clientRegistrationDefaultApplicationType: 'infer'` to `@better-auth/oauth-provider`. A dynamic registration that omits `application_type` is classified `native` when any redirect URI uses a non-http(s) scheme or `http` on exactly `localhost`, `127.0.0.1` or `[::1]`, and every redirect URI is then validated by the native rule. Otherwise it stays `web`. What this changes and what it keeps:
13+
14+
- Accepted as `native`: the body a local MCP client sends (a public client with a loopback or custom-scheme redirect).
15+
- Unchanged: an `application_type` the client sends is never overridden; `https://example.com/cb` alone stays `web`; `http://example.com/cb` and `http://localhost.example.com/cb` stay refused; with dynamic client registration off, every registration is still refused.
16+
- Also accepted as `native`: a confidential registration (`client_secret_basic`, or no `token_endpoint_auth_method`, which the provider fills as `client_secret_basic`) with a loopback or custom-scheme redirect and no `application_type`, which is issued a `client_secret`. The same body was already accepted with `application_type: 'native'` spelled out, so no new client shape becomes registrable.
17+
- PKCE stays required at `/oauth2/authorize` for every one of these clients, and every token still needs the user's sign-in and consent.
18+
19+
The Setup form's own OAuth application door (`/sys-oauth-application/register`) is not affected.
Lines changed: 345 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,345 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* MCP OAuth: anonymous Dynamic Client Registration (RFC 7591) by a client that
5+
* sends NO `application_type` (#25118, ruling 6329352251, A1). Every reading
6+
* is taken from the REAL `@better-auth/oauth-provider`, booted from the exact
7+
* options AuthManager hands it, never from that options object.
8+
*
9+
* ## Why this file exists
10+
*
11+
* Local MCP clients (Claude Code, Cursor, VS Code, Codex CLI) register with a
12+
* loopback `http` or a custom-scheme redirect and omit `application_type`. The
13+
* provider's default filled the missing type with `web` and refused both
14+
* redirect forms, so the client failed before its browser opened.
15+
* `auth-manager.mcp-oauth-resource.test.ts` registers with an explicit
16+
* `application_type: 'native'`, which is why it stayed green through that
17+
* defect. Every body below omits the field the way those clients do, and the
18+
* first cell sends the card's own request body byte for byte.
19+
*
20+
* ## The boundary as measured, not as it reads
21+
*
22+
* 1. A public client with a loopback (`localhost`, `127.0.0.1`) or a
23+
* custom-scheme redirect registers as `native`.
24+
* 2. `https://evil.example/cb` alone stays `web`; `http://evil.example/cb` and
25+
* `http://localhost.evil.example` stay refused; an explicit `web` with a
26+
* loopback redirect stays refused (a sent type is never overridden).
27+
* 3. The provider classifies by redirect URIs and never reads
28+
* `token_endpoint_auth_method`. So this is NOT "public clients only": a
29+
* CONFIDENTIAL registration with a loopback or custom-scheme redirect and no
30+
* type registers as `native` and is issued a `client_secret`. The same body
31+
* with `application_type: 'native'` spelled out was already accepted.
32+
* "Any redirect qualifies" also lets an https redirect ride beside a
33+
* loopback one in the same body.
34+
* 4. For EVERY client registered here, /oauth2/authorize without PKCE is
35+
* refused, and the same request with S256 PKCE reaches consent. The pair
36+
* differs only in the PKCE parameters, so the refusal cannot have another
37+
* cause.
38+
* 5. With DCR off the option opens nothing: the card's body is refused.
39+
*/
40+
41+
import { describe, it, expect, vi, beforeAll } from 'vitest';
42+
43+
import { AuthManager } from './auth-manager';
44+
import { buildJwtPluginSchema } from './auth-schema-config.js';
45+
46+
// Same pattern as auth-manager.mcp-oauth-resource.test.ts: better-auth is
47+
// mocked so AuthManager's own instance build stays cheap, and the options it
48+
// hands `oauthProvider()` are captured. The server every assertion runs
49+
// against is a SEPARATE, REAL one booted from those options.
50+
vi.mock('better-auth', () => ({
51+
betterAuth: vi.fn(() => ({ handler: vi.fn(), api: {} })),
52+
}));
53+
vi.mock('@better-auth/oauth-provider', () => ({
54+
oauthProvider: vi.fn((opts: any) => ({ id: 'oauth-provider', _opts: opts })),
55+
}));
56+
57+
import { oauthProvider } from '@better-auth/oauth-provider';
58+
59+
const BASE_URL = 'https://acme.example.com';
60+
const AUTH_BASE_PATH = '/api/v1/auth';
61+
const ISSUER = `${BASE_URL}${AUTH_BASE_PATH}`;
62+
const MCP_RESOURCE = `${BASE_URL}/api/v1/mcp`;
63+
// RFC 7636 Appendix B challenge.
64+
const PKCE_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
65+
66+
const LOOPBACK = 'http://localhost:63678/callback';
67+
const LOOPBACK_IPV4 = 'http://127.0.0.1:5000/callback';
68+
const CUSTOM_SCHEME = 'cursor://anysphere.cursor-retrieval/oauth/callback';
69+
const HTTPS_FOREIGN = 'https://evil.example/cb';
70+
const HTTP_FOREIGN = 'http://evil.example/cb';
71+
const LOOPBACK_LOOKALIKE = 'http://localhost.evil.example/cb';
72+
73+
/**
74+
* The card's reproduction body, verbatim: a public client, a loopback `http`
75+
* redirect, and no `application_type`.
76+
*/
77+
const CARD_BODY = {
78+
client_name: 'probe',
79+
redirect_uris: [LOOPBACK],
80+
grant_types: ['authorization_code', 'refresh_token'],
81+
response_types: ['code'],
82+
token_endpoint_auth_method: 'none',
83+
};
84+
85+
/** The card's body with other redirects and/or another auth method. */
86+
function cardBodyWith(overrides: Record<string, unknown>, dropAuthMethod = false): Record<string, unknown> {
87+
const body: Record<string, unknown> = { ...CARD_BODY, ...overrides };
88+
if (dropAuthMethod) delete body.token_endpoint_auth_method;
89+
return body;
90+
}
91+
92+
const ENV_KEYS = ['OS_MCP_SERVER_ENABLED', 'OS_OIDC_PROVIDER_ENABLED', 'OS_OIDC_DCR_ENABLED'] as const;
93+
94+
/**
95+
* The options AuthManager actually hands `oauthProvider()`, under `env`.
96+
* The process env is restored before this returns.
97+
*/
98+
async function captureProviderOptions(env: Partial<Record<(typeof ENV_KEYS)[number], string>>): Promise<any> {
99+
const saved: Record<string, string | undefined> = {};
100+
for (const k of ENV_KEYS) {
101+
saved[k] = process.env[k];
102+
delete process.env[k];
103+
}
104+
Object.assign(process.env, env);
105+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
106+
try {
107+
const manager = new AuthManager({ secret: 'test-secret-at-least-32-chars-long', baseUrl: BASE_URL });
108+
await manager.getAuthInstance();
109+
} finally {
110+
warnSpy.mockRestore();
111+
for (const k of ENV_KEYS) {
112+
if (saved[k] === undefined) delete process.env[k];
113+
else process.env[k] = saved[k];
114+
}
115+
}
116+
const opts = (oauthProvider as any).mock.calls.at(-1)?.[0];
117+
expect(opts, 'AuthManager must register the oauthProvider plugin').toBeDefined();
118+
return opts;
119+
}
120+
121+
/**
122+
* Boots a REAL better-auth server carrying the REAL provider configured with
123+
* `opts`, on the in-memory adapter. The table list is derived from the
124+
* plugin's own resolved schema, and `jwt()` gets the platform's schema; both
125+
* reasons are recorded in auth-manager.mcp-oauth-resource.test.ts.
126+
*/
127+
async function bootRealAuthorizationServer(opts: any): Promise<{ auth: any; clientCount: () => number }> {
128+
const [{ betterAuth }, { memoryAdapter }, { jwt }, { oauthProvider: realOauthProvider }] = await Promise.all([
129+
vi.importActual<typeof import('better-auth')>('better-auth'),
130+
import('better-auth/adapters/memory'),
131+
import('better-auth/plugins'),
132+
vi.importActual<typeof import('@better-auth/oauth-provider')>('@better-auth/oauth-provider'),
133+
]);
134+
const plugin = realOauthProvider(opts);
135+
const pluginSchema = (plugin as any).schema as Record<string, { modelName?: string }>;
136+
const db: Record<string, any[]> = {};
137+
for (const m of ['user', 'session', 'account', 'verification', 'jwks']) db[m] = [];
138+
for (const [model, def] of Object.entries(pluginSchema ?? {})) db[def.modelName ?? model] = [];
139+
const jwtPlugin = jwt({ schema: buildJwtPluginSchema() as any });
140+
const jwksModel = (jwtPlugin as any).schema?.jwks?.modelName ?? 'jwks';
141+
db[jwksModel] = db[jwksModel] ?? [];
142+
const auth = betterAuth({
143+
baseURL: BASE_URL,
144+
basePath: AUTH_BASE_PATH,
145+
secret: 'test-secret-at-least-32-chars-long',
146+
database: memoryAdapter(db),
147+
emailAndPassword: { enabled: true },
148+
plugins: [jwtPlugin, plugin as any],
149+
});
150+
await auth.$context;
151+
const clientModel = pluginSchema?.oauthClient?.modelName ?? 'oauthClient';
152+
return { auth, clientCount: () => db[clientModel]?.length ?? 0 };
153+
}
154+
155+
async function register(auth: any, body: Record<string, unknown>): Promise<{ status: number; body: any }> {
156+
const res = await auth.handler(
157+
new Request(`${ISSUER}/oauth2/register`, {
158+
method: 'POST',
159+
headers: { 'content-type': 'application/json' },
160+
body: JSON.stringify(body),
161+
}),
162+
);
163+
return { status: res.status, body: (await res.json().catch(() => null)) as any };
164+
}
165+
166+
async function signUp(auth: any): Promise<string> {
167+
const res = await auth.handler(
168+
new Request(`${ISSUER}/sign-up/email`, {
169+
method: 'POST',
170+
headers: { 'content-type': 'application/json' },
171+
body: JSON.stringify({ email: 'dev@acme.example.com', password: 'password-12345', name: 'Dev' }),
172+
}),
173+
);
174+
return String(res.headers.get('set-cookie') ?? '')
175+
.split(',')
176+
.map((c: string) => c.split(';')[0]!.trim())
177+
.join('; ');
178+
}
179+
180+
/** GET /oauth2/authorize for `clientId`, with or without S256 PKCE. */
181+
async function authorize(
182+
auth: any,
183+
cookie: string,
184+
clientId: string,
185+
redirectUri: string,
186+
pkce: boolean,
187+
): Promise<{ status: number; location: string }> {
188+
const url = new URL(`${ISSUER}/oauth2/authorize`);
189+
url.searchParams.set('client_id', clientId);
190+
url.searchParams.set('redirect_uri', redirectUri);
191+
url.searchParams.set('response_type', 'code');
192+
url.searchParams.set('scope', 'openid profile email data:read');
193+
url.searchParams.set('state', 'st');
194+
url.searchParams.set('resource', MCP_RESOURCE);
195+
if (pkce) {
196+
url.searchParams.set('code_challenge', PKCE_CHALLENGE);
197+
url.searchParams.set('code_challenge_method', 'S256');
198+
}
199+
const res = await auth.handler(new Request(url.toString(), { method: 'GET', headers: { cookie } }));
200+
return { status: res.status, location: res.headers.get('location') ?? '' };
201+
}
202+
203+
describe("DCR of a client that omits application_type, on the real provider (#25118, 'infer')", () => {
204+
let server: { auth: any; clientCount: () => number };
205+
let cookie: string;
206+
207+
beforeAll(async () => {
208+
// MCP on ⇒ the OAuth provider is wired and DCR follows it (resolveDcrEnabled).
209+
const opts = await captureProviderOptions({ OS_MCP_SERVER_ENABLED: 'true' });
210+
server = await bootRealAuthorizationServer(opts);
211+
cookie = await signUp(server.auth);
212+
});
213+
214+
/**
215+
* The registration is accepted with `applicationType`, and the authorize
216+
* step refuses it without PKCE while the same request with PKCE reaches
217+
* consent.
218+
*/
219+
async function expectRegisteredAndPkceBound(
220+
body: Record<string, unknown>,
221+
expected: { applicationType: 'native' | 'web'; authMethod: string; secret: boolean },
222+
): Promise<void> {
223+
const reg = await register(server.auth, body);
224+
expect(reg.status, JSON.stringify(reg.body)).toBe(201);
225+
expect(reg.body.client_id, 'a registered client has an id').toBeTruthy();
226+
expect(reg.body.application_type).toBe(expected.applicationType);
227+
expect(reg.body.token_endpoint_auth_method).toBe(expected.authMethod);
228+
expect(typeof reg.body.client_secret === 'string', 'whether a client_secret is issued').toBe(expected.secret);
229+
230+
const redirectUri = (body.redirect_uris as string[])[0]!;
231+
232+
const withoutPkce = await authorize(server.auth, cookie, reg.body.client_id, redirectUri, false);
233+
expect(withoutPkce.location, 'the refusal goes back to the client, never to consent').not.toContain('/oauth/consent');
234+
expect(withoutPkce.location.startsWith(redirectUri), withoutPkce.location).toBe(true);
235+
const refusal = new URL(withoutPkce.location);
236+
expect(refusal.searchParams.get('error'), withoutPkce.location).toBe('invalid_request');
237+
expect(refusal.searchParams.get('error_description') ?? '', 'the refusal names PKCE').toMatch(/pkce/i);
238+
expect(refusal.searchParams.get('code'), 'no authorization code without PKCE').toBeNull();
239+
240+
// Differential: the same client, session, redirect and scopes, plus PKCE.
241+
const withPkce = await authorize(server.auth, cookie, reg.body.client_id, redirectUri, true);
242+
expect(withPkce.location, 'with PKCE the same request reaches consent').toContain('/oauth/consent');
243+
expect(withPkce.location).not.toContain('error=');
244+
}
245+
246+
async function expectRefusedRedirect(body: Record<string, unknown>): Promise<void> {
247+
const before = server.clientCount();
248+
const reg = await register(server.auth, body);
249+
expect(reg.status, JSON.stringify(reg.body)).toBe(400);
250+
expect(reg.body?.error, JSON.stringify(reg.body)).toBe('invalid_redirect_uri');
251+
expect(reg.body?.client_id, 'a refused registration has no client id').toBeUndefined();
252+
expect(server.clientCount(), 'a refused registration stores no client').toBe(before);
253+
}
254+
255+
describe('accepted as native: the shapes native MCP clients send', () => {
256+
it("the card's own body (public client, loopback http redirect) registers as native", async () => {
257+
expect(Object.keys(CARD_BODY), 'the card body omits application_type, as MCP clients do').not.toContain(
258+
'application_type',
259+
);
260+
await expectRegisteredAndPkceBound(CARD_BODY, { applicationType: 'native', authMethod: 'none', secret: false });
261+
});
262+
263+
it('a public client with an http://127.0.0.1 redirect registers as native', async () => {
264+
await expectRegisteredAndPkceBound(cardBodyWith({ redirect_uris: [LOOPBACK_IPV4] }), {
265+
applicationType: 'native',
266+
authMethod: 'none',
267+
secret: false,
268+
});
269+
});
270+
271+
it('a public client with a custom-scheme redirect registers as native', async () => {
272+
await expectRegisteredAndPkceBound(cardBodyWith({ redirect_uris: [CUSTOM_SCHEME] }), {
273+
applicationType: 'native',
274+
authMethod: 'none',
275+
secret: false,
276+
});
277+
});
278+
});
279+
280+
describe('unchanged: what a web client may and may not register', () => {
281+
it('https://evil.example/cb alone stays web', async () => {
282+
await expectRegisteredAndPkceBound(cardBodyWith({ redirect_uris: [HTTPS_FOREIGN] }), {
283+
applicationType: 'web',
284+
authMethod: 'none',
285+
secret: false,
286+
});
287+
});
288+
289+
it('http://evil.example/cb stays refused', async () => {
290+
await expectRefusedRedirect(cardBodyWith({ redirect_uris: [HTTP_FOREIGN] }));
291+
});
292+
293+
it('http://localhost.evil.example stays refused (the loopback match is exact)', async () => {
294+
await expectRefusedRedirect(cardBodyWith({ redirect_uris: [LOOPBACK_LOOKALIKE] }));
295+
});
296+
297+
it('an explicit application_type web with a loopback redirect stays refused (a sent type is never overridden)', async () => {
298+
await expectRefusedRedirect(cardBodyWith({ application_type: 'web' }));
299+
});
300+
});
301+
302+
describe('NOT "public clients only": the classifier reads redirect URIs, never the auth method', () => {
303+
it('a confidential client (client_secret_basic) with a loopback redirect registers as native and is issued a client_secret', async () => {
304+
await expectRegisteredAndPkceBound(cardBodyWith({ token_endpoint_auth_method: 'client_secret_basic' }), {
305+
applicationType: 'native',
306+
authMethod: 'client_secret_basic',
307+
secret: true,
308+
});
309+
});
310+
311+
it('a body that omits the auth method (filled as client_secret_basic) with a loopback redirect registers as native and is issued a client_secret', async () => {
312+
await expectRegisteredAndPkceBound(cardBodyWith({}, true), {
313+
applicationType: 'native',
314+
authMethod: 'client_secret_basic',
315+
secret: true,
316+
});
317+
});
318+
319+
it('a confidential client with a custom-scheme redirect registers as native and is issued a client_secret', async () => {
320+
await expectRegisteredAndPkceBound(
321+
cardBodyWith({ token_endpoint_auth_method: 'client_secret_basic', redirect_uris: [CUSTOM_SCHEME] }),
322+
{ applicationType: 'native', authMethod: 'client_secret_basic', secret: true },
323+
);
324+
});
325+
326+
it('ANY qualifying redirect classifies the body: a loopback plus https://evil.example/cb registers as native', async () => {
327+
await expectRegisteredAndPkceBound(cardBodyWith({ redirect_uris: [LOOPBACK, HTTPS_FOREIGN] }), {
328+
applicationType: 'native',
329+
authMethod: 'none',
330+
secret: false,
331+
});
332+
});
333+
});
334+
});
335+
336+
describe("DCR off: 'infer' opens nothing while the registration door is shut", () => {
337+
it("refuses the card's own body with access_denied", async () => {
338+
const opts = await captureProviderOptions({ OS_MCP_SERVER_ENABLED: 'true', OS_OIDC_DCR_ENABLED: 'false' });
339+
const server = await bootRealAuthorizationServer(opts);
340+
const reg = await register(server.auth, CARD_BODY);
341+
expect(reg.status, JSON.stringify(reg.body)).toBe(403);
342+
expect(reg.body?.error, JSON.stringify(reg.body)).toBe('access_denied');
343+
expect(server.clientCount(), 'no client is stored').toBe(0);
344+
});
345+
});

0 commit comments

Comments
 (0)