feat: require explicit opt-in for MCP and OAuth registration
This commit is contained in:
1 parent
16acac5530
commit
819e1c7256
7 files changed
+65
-12
No files matched your search
@@ -54,6 +54,10 @@ Optional: set `TWO_FACTOR_ENCRYPTION_KEY` to a 64-character hex string to enable
|
||||
authentication. Generate one with `openssl rand -hex 32`. Two-factor authentication is unavailable
|
||||
and cannot be required until this key is set.
|
||||
|
||||
MCP and its OAuth endpoints are disabled by default. To enable them, set `MCP_ENABLED=1` and
|
||||
`OAUTH_ISSUER` to Umami's public URL. Dynamic client registration remains disabled unless
|
||||
`OAUTH_DCR_ENABLED=1` is also set.
|
||||
|
||||
The connection URL format:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -44,6 +44,11 @@ All tools call the Cloud API gateway, which validates the key and routes request
|
||||
|
||||
## Remote: self-hosted with OAuth
|
||||
|
||||
MCP is disabled by default. Set `MCP_ENABLED=1` and set `OAUTH_ISSUER` to the public URL of
|
||||
your Umami instance before exposing this endpoint. Dynamic client registration is disabled by
|
||||
default; clients should use Client ID Metadata Documents. Set `OAUTH_DCR_ENABLED=1` only when a
|
||||
client requires registration compatibility.
|
||||
|
||||
Add the MCP endpoint to your client:
|
||||
|
||||
```text
|
||||
@@ -53,10 +58,7 @@ https://your-umami.example.com/mcp
|
||||
The client discovers `/.well-known/oauth-protected-resource/mcp`, sends you to Umami to sign in
|
||||
and approve access (`websites:read`, `analytics:read`), and receives an OAuth token. No API key
|
||||
is pasted anywhere. Clients register via Client ID Metadata Documents; dynamic client registration
|
||||
is available for compatibility (`OAUTH_DISABLE_DCR=1` turns it off).
|
||||
Registration is limited to 20 attempts per hour per IP when Redis is configured. Without Redis,
|
||||
the limit is shared by all clients in each server process and resets when that process restarts.
|
||||
Use Redis for a shared limit across multiple processes or serverless instances.
|
||||
is available only when `OAUTH_DCR_ENABLED=1` is set.
|
||||
|
||||
Self-hosted instances can alternatively pass an API key (`umami_…`) as the bearer token.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export function OPTIONS() {
|
||||
|
||||
/**
|
||||
* Dynamic Client Registration (RFC 7591) compatibility endpoint for MCP clients that do not yet
|
||||
* support Client ID Metadata Documents. Public clients only; disable with OAUTH_DISABLE_DCR=1.
|
||||
* support Client ID Metadata Documents. Public clients only; opt in with OAUTH_DCR_ENABLED=1.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (!isDynamicRegistrationEnabled()) {
|
||||
|
||||
@@ -38,7 +38,7 @@ const handler = createUmamiMcpHttpHandler({
|
||||
});
|
||||
|
||||
async function handle(request: Request) {
|
||||
if (!isOAuthEnabled() && process.env.MCP_DISABLED === '1') {
|
||||
if (!isOAuthEnabled()) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||
import { getOAuthEndpoints, isDynamicRegistrationEnabled, isOAuthEnabled } from './config';
|
||||
|
||||
function clearFeatureFlags() {
|
||||
delete process.env.MCP_ENABLED;
|
||||
delete process.env.OAUTH_DCR_ENABLED;
|
||||
delete process.env.DISABLE_LOGIN;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearFeatureFlags();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearFeatureFlags();
|
||||
});
|
||||
|
||||
describe('MCP feature gates', () => {
|
||||
test('keeps OAuth and MCP unavailable until explicitly enabled', () => {
|
||||
expect(isOAuthEnabled()).toBe(false);
|
||||
expect(isDynamicRegistrationEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test('requires a separate opt-in for dynamic client registration', () => {
|
||||
process.env.MCP_ENABLED = '1';
|
||||
|
||||
expect(isOAuthEnabled()).toBe(true);
|
||||
expect(isDynamicRegistrationEnabled()).toBe(false);
|
||||
expect(getOAuthEndpoints().registrationEndpoint).toBeUndefined();
|
||||
|
||||
process.env.OAUTH_DCR_ENABLED = '1';
|
||||
|
||||
expect(isDynamicRegistrationEnabled()).toBe(true);
|
||||
expect(getOAuthEndpoints().registrationEndpoint).toContain('/api/oauth/register');
|
||||
});
|
||||
|
||||
test('does not enable OAuth when login is disabled', () => {
|
||||
process.env.MCP_ENABLED = '1';
|
||||
process.env.DISABLE_LOGIN = '1';
|
||||
|
||||
expect(isOAuthEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -13,11 +13,11 @@ export const REFRESH_TOKEN_PREFIX = 'umami_rt_';
|
||||
export const MCP_RESOURCE_PATH = '/mcp';
|
||||
|
||||
export function isOAuthEnabled() {
|
||||
return process.env.OAUTH_DISABLED !== '1' && !process.env.DISABLE_LOGIN;
|
||||
return process.env.MCP_ENABLED === '1' && !process.env.DISABLE_LOGIN;
|
||||
}
|
||||
|
||||
export function isDynamicRegistrationEnabled() {
|
||||
return isOAuthEnabled() && process.env.OAUTH_DISABLE_DCR !== '1';
|
||||
return isOAuthEnabled() && process.env.OAUTH_DCR_ENABLED === '1';
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value: string) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { hash } from '@/lib/crypto';
|
||||
import { getUser } from '@/queries/prisma/user';
|
||||
import { createAccessToken } from './tokens';
|
||||
@@ -26,11 +26,15 @@ function token(scopes: string[], overrides: Record<string, unknown> = {}) {
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.APP_SECRET = 'test';
|
||||
delete process.env.OAUTH_DISABLED;
|
||||
process.env.MCP_ENABLED = '1';
|
||||
getUserMock.mockReset();
|
||||
getUserMock.mockResolvedValue({ ...USER } as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.MCP_ENABLED;
|
||||
});
|
||||
|
||||
describe('verifyOAuthRequest', () => {
|
||||
test('ignores non-OAuth tokens', async () => {
|
||||
await expect(verifyOAuthRequest('umami_key', 'GET', '/api/websites')).resolves.toEqual({
|
||||
@@ -95,8 +99,8 @@ describe('verifyOAuthRequest', () => {
|
||||
).resolves.toEqual({ status: 'invalid' });
|
||||
});
|
||||
|
||||
test('rejects everything when OAuth is disabled', async () => {
|
||||
process.env.OAUTH_DISABLED = '1';
|
||||
test('rejects everything when MCP is disabled', async () => {
|
||||
delete process.env.MCP_ENABLED;
|
||||
|
||||
await expect(
|
||||
verifyOAuthRequest(token(['websites:read']), 'GET', '/api/websites'),
|
||||
|
||||
Reference in new issue
Block a user