Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Copyright 2026 GoodRx, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { NextRequest } from 'next/server';

const mockGetUser = jest.fn();
const mockDelete = jest.fn();

jest.mock('server/lib/get-user', () => ({
__esModule: true,
getUser: (...args: unknown[]) => mockGetUser(...args),
getRequestUserIdentity: (req: unknown) => {
const payload = mockGetUser(req) as { sub?: string; realm_access?: { roles?: string[] } } | null;
return payload ? { userId: payload.sub, roles: payload.realm_access?.roles ?? [] } : null;
},
requireRequestUserIdentity: (req: unknown) => {
const payload = mockGetUser(req) as { sub?: string; realm_access?: { roles?: string[] } } | null;
if (!payload?.sub) throw new Error('unauthorized');
return { userId: payload.sub, roles: payload.realm_access?.roles ?? [] };
},
}));

jest.mock('server/services/keycloak/mcpOauthClients', () => ({
__esModule: true,
default: {
getInstance: jest.fn(() => ({
delete: (...args: unknown[]) => mockDelete(...args),
})),
},
}));

import { DELETE } from './route';

function request(): NextRequest {
return {
method: 'DELETE',
headers: new Headers({ 'x-request-id': 'request-2' }),
nextUrl: new URL('http://localhost/api/v2/config/mcp/oauth-clients/lifecycle-mcp-client-1'),
text: jest.fn().mockResolvedValue(''),
} as unknown as NextRequest;
}

beforeEach(() => {
jest.clearAllMocks();
process.env.ENABLE_AUTH = 'true';
mockGetUser.mockReturnValue({ sub: 'admin-user', realm_access: { roles: ['admin'] } });
mockDelete.mockResolvedValue(undefined);
});

it('deletes a Lifecycle-managed MCP OAuth client', async () => {
const response = await DELETE(request(), {
params: Promise.resolve({ clientId: 'lifecycle-mcp-client-1' }),
});
expect(response.status).toBe(204);
expect(mockDelete).toHaveBeenCalledWith('lifecycle-mcp-client-1', 'admin-user', 'request-2');
});

it('keeps deletion admin-only', async () => {
mockGetUser.mockReturnValue({ sub: 'ordinary-user', realm_access: { roles: ['user'] } });
const response = await DELETE(request(), {
params: Promise.resolve({ clientId: 'lifecycle-mcp-client-1' }),
});
expect(response.status).toBe(403);
expect(mockDelete).not.toHaveBeenCalled();
});
73 changes: 73 additions & 0 deletions src/app/api/v2/config/mcp/oauth-clients/[clientId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Copyright 2026 GoodRx, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { NextRequest, NextResponse } from 'next/server';
import { createApiHandler } from 'server/lib/createApiHandler';
import { requireRequestUserIdentity } from 'server/lib/get-user';
import McpOauthClientService from 'server/services/keycloak/mcpOauthClients';

/**
* @openapi
* /api/v2/config/mcp/oauth-clients/{clientId}:
* delete:
* summary: Delete a pre-registered Lifecycle MCP OAuth client
* description: Deletes only a public OAuth client that is marked as managed by Lifecycle MCP.
* tags:
* - Config
* operationId: deleteLifecycleMcpOauthClient
* parameters:
* - in: path
* name: clientId
* required: true
* schema:
* type: string
* description: Lifecycle-generated OAuth client ID.
* responses:
* '204':
* description: MCP OAuth client deleted.
* '401':
* description: Unauthorized.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
* '403':
* description: Forbidden.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
* '404':
* description: Lifecycle-managed MCP OAuth client not found.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
* '503':
* description: Keycloak client management is unavailable.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
*/
const deleteHandler = async (req: NextRequest, { params }: { params: Promise<{ clientId: string }> }) => {
const { clientId } = await params;
const identity = requireRequestUserIdentity(req);
await McpOauthClientService.getInstance().delete(clientId, identity.userId, req.headers.get('x-request-id'));
return new NextResponse(null, { status: 204 });
};

export const DELETE = createApiHandler(deleteHandler, { auth: 'session', roles: ['admin'] });
93 changes: 93 additions & 0 deletions src/app/api/v2/config/mcp/oauth-clients/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Copyright 2026 GoodRx, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { NextRequest } from 'next/server';

const mockGetUser = jest.fn();
const mockList = jest.fn();
const mockCreate = jest.fn();

jest.mock('server/lib/get-user', () => ({
__esModule: true,
getUser: (...args: unknown[]) => mockGetUser(...args),
getRequestUserIdentity: (req: unknown) => {
const payload = mockGetUser(req) as { sub?: string; realm_access?: { roles?: string[] } } | null;
return payload ? { userId: payload.sub, roles: payload.realm_access?.roles ?? [] } : null;
},
requireRequestUserIdentity: (req: unknown) => {
const payload = mockGetUser(req) as { sub?: string; realm_access?: { roles?: string[] } } | null;
if (!payload?.sub) throw new Error('unauthorized');
return { userId: payload.sub, roles: payload.realm_access?.roles ?? [] };
},
}));

jest.mock('server/services/keycloak/mcpOauthClients', () => ({
__esModule: true,
default: {
getInstance: jest.fn(() => ({
list: (...args: unknown[]) => mockList(...args),
create: (...args: unknown[]) => mockCreate(...args),
})),
},
}));

import { GET, POST } from './route';

const CLIENT = {
clientId: 'lifecycle-mcp-client-1',
name: 'Desktop tool',
redirectUris: ['http://127.0.0.1:8123/callback'],
createdAt: '2026-08-01T20:00:00.000Z',
};

function request(method: 'GET' | 'POST', body?: unknown): NextRequest {
return {
method,
headers: new Headers({ 'x-request-id': 'request-1' }),
nextUrl: new URL('http://localhost/api/v2/config/mcp/oauth-clients'),
json: jest.fn().mockResolvedValue(body),
text: jest.fn().mockResolvedValue(body === undefined ? '' : JSON.stringify(body)),
} as unknown as NextRequest;
}

beforeEach(() => {
jest.clearAllMocks();
process.env.ENABLE_AUTH = 'true';
mockGetUser.mockReturnValue({ sub: 'admin-user', realm_access: { roles: ['admin'] } });
mockList.mockResolvedValue([CLIENT]);
mockCreate.mockResolvedValue(CLIENT);
});

it('lists Lifecycle-managed MCP OAuth clients', async () => {
const response = await GET(request('GET'));
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ request_id: 'request-1', data: [CLIENT], error: null });
});

it('creates a client as the authenticated administrator', async () => {
const body = { name: 'Desktop tool', redirectUris: ['http://127.0.0.1:8123/callback'] };
const response = await POST(request('POST', body));
expect(response.status).toBe(201);
expect(mockCreate).toHaveBeenCalledWith(body, 'admin-user', 'request-1');
});

it('keeps list and create admin-only', async () => {
mockGetUser.mockReturnValue({ sub: 'ordinary-user', realm_access: { roles: ['user'] } });
expect((await GET(request('GET'))).status).toBe(403);
expect((await POST(request('POST', { name: 'Client', redirectUris: ['https://example.com/callback'] }))).status).toBe(
403
);
});
129 changes: 129 additions & 0 deletions src/app/api/v2/config/mcp/oauth-clients/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* Copyright 2026 GoodRx, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { NextRequest } from 'next/server';
import { createApiHandler } from 'server/lib/createApiHandler';
import { requireRequestUserIdentity } from 'server/lib/get-user';
import { errorResponse, successResponse } from 'server/lib/response';
import McpOauthClientService from 'server/services/keycloak/mcpOauthClients';

/**
* @openapi
* /api/v2/config/mcp/oauth-clients:
* get:
* summary: List pre-registered Lifecycle MCP OAuth clients
* description: Returns public OAuth clients created and managed by Lifecycle for MCP sign-in.
* tags:
* - Config
* operationId: listLifecycleMcpOauthClients
* responses:
* '200':
* description: Lifecycle-managed MCP OAuth clients.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ListLifecycleMcpOauthClientsSuccessResponse'
* '401':
* description: Unauthorized.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
* '403':
* description: Forbidden.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
* '503':
* description: Keycloak client management is unavailable.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
* post:
* summary: Pre-register a Lifecycle MCP OAuth client
* description: Creates a public Authorization Code client with PKCE, consent, and fixed Lifecycle MCP scopes.
* tags:
* - Config
* operationId: createLifecycleMcpOauthClient
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CreateLifecycleMcpOauthClient'
* responses:
* '201':
* description: MCP OAuth client created.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LifecycleMcpOauthClientSuccessResponse'
* '400':
* description: Invalid client name or redirect URI.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
* '401':
* description: Unauthorized.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
* '403':
* description: Forbidden.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
* '409':
* description: The client conflicts with existing state or the client limit was reached.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
* '503':
* description: Keycloak client management is unavailable.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiErrorResponse'
*/
const getHandler = async (req: NextRequest) => {
const clients = await McpOauthClientService.getInstance().list();
return successResponse(clients, { status: 200 }, req);
};

const postHandler = async (req: NextRequest) => {
let body: unknown;
try {
body = await req.json();
} catch {
return errorResponse(new Error('Invalid JSON in request body'), { status: 400 }, req);
}
const identity = requireRequestUserIdentity(req);
const client = await McpOauthClientService.getInstance().create(
body,
identity.userId,
req.headers.get('x-request-id')
);
return successResponse(client, { status: 201 }, req);
};

export const GET = createApiHandler(getHandler, { auth: 'session', roles: ['admin'] });
export const POST = createApiHandler(postHandler, { auth: 'session', roles: ['admin'] });
8 changes: 8 additions & 0 deletions src/server/lib/v2RoutePolicyManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,14 @@ export const V2_ROUTE_POLICY_MANIFEST: readonly V2RoutePolicyEntry[] = [
{ method: 'PATCH', route: '/api/v2/config/metadata/{id}', policy: 'session', roles: ['admin'] },
{ method: 'GET', route: '/api/v2/config/mcp', policy: 'session', roles: ['admin'] },
{ method: 'PUT', route: '/api/v2/config/mcp', policy: 'session', roles: ['admin'] },
{ method: 'GET', route: '/api/v2/config/mcp/oauth-clients', policy: 'session', roles: ['admin'] },
{ method: 'POST', route: '/api/v2/config/mcp/oauth-clients', policy: 'session', roles: ['admin'] },
{
method: 'DELETE',
route: '/api/v2/config/mcp/oauth-clients/{clientId}',
policy: 'session',
roles: ['admin'],
},
{ method: 'GET', route: '/api/v2/config/sites', policy: 'session', roles: ['admin'] },
{ method: 'PUT', route: '/api/v2/config/sites', policy: 'session', roles: ['admin'] },
{ method: 'GET', route: '/api/v2/environments', policy: 'principal', scope: 'env:read' },
Expand Down
Loading
Loading