diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json index 28a3c52..8ea0623 100644 --- a/mcp-server/package-lock.json +++ b/mcp-server/package-lock.json @@ -1,15 +1,16 @@ { "name": "aiquila-mcp", - "version": "0.1.40", + "version": "0.1.42", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aiquila-mcp", - "version": "0.1.40", + "version": "0.1.42", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.27.1", + "jose": "^6.2.0", "pino": "^9.0.0", "typescript": "^5.8.3", "webdav": "^5.9.0", @@ -2875,9 +2876,9 @@ "license": "ISC" }, "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz", + "integrity": "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" diff --git a/mcp-server/package.json b/mcp-server/package.json index 2361628..091e459 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "aiquila-mcp", - "version": "0.1.41", + "version": "0.1.42", "description": "AIquila - MCP server for Nextcloud integration with Claude AI", "type": "module", "main": "dist/index.js", @@ -33,6 +33,7 @@ ], "dependencies": { "@modelcontextprotocol/sdk": "^1.27.1", + "jose": "^6.2.0", "pino": "^9.0.0", "typescript": "^5.8.3", "webdav": "^5.9.0", diff --git a/mcp-server/server.json b/mcp-server/server.json index 3901c07..36bdd24 100644 --- a/mcp-server/server.json +++ b/mcp-server/server.json @@ -7,13 +7,13 @@ "url": "https://github.com/elgorro/aiquila.git", "source": "github" }, - "version": "0.1.41", + "version": "0.1.42", "websiteUrl": "https://github.com/elgorro/aiquila", "packages": [ { "registryType": "npm", "identifier": "aiquila-mcp", - "version": "0.1.41", + "version": "0.1.42", "runtimeHint": "npx", "transport": { "type": "stdio" }, "environmentVariables": [ @@ -39,7 +39,7 @@ }, { "registryType": "oci", - "identifier": "ghcr.io/elgorro/aiquila-mcp:0.1.41", + "identifier": "ghcr.io/elgorro/aiquila-mcp:0.1.42", "runtimeHint": "docker", "transport": { "type": "stdio" }, "environmentVariables": [ diff --git a/mcp-server/src/__tests__/auth/login.test.ts b/mcp-server/src/__tests__/auth/login.test.ts index f7118a4..c1f364c 100644 --- a/mcp-server/src/__tests__/auth/login.test.ts +++ b/mcp-server/src/__tests__/auth/login.test.ts @@ -33,6 +33,9 @@ describe('loginHandler', () => { ...savedEnv, NEXTCLOUD_URL: 'https://cloud.example.com', MCP_AUTH_SECRET: 'test-secret-at-least-32-chars-long!!', + MCP_AUTH_ISSUER: 'https://test.example.com', + MCP_CLIENT_ID: 'c1', + MCP_CLIENT_REDIRECT_URIS: 'https://example.com/callback', }; provider = new NextcloudOAuthProvider(); vi.clearAllMocks(); diff --git a/mcp-server/src/__tests__/auth/provider.test.ts b/mcp-server/src/__tests__/auth/provider.test.ts index d52f2bb..2315074 100644 --- a/mcp-server/src/__tests__/auth/provider.test.ts +++ b/mcp-server/src/__tests__/auth/provider.test.ts @@ -88,7 +88,11 @@ describe('NextcloudOAuthProvider', () => { const savedEnv = process.env; beforeEach(() => { - process.env = { ...savedEnv, MCP_AUTH_SECRET: TEST_SECRET }; + process.env = { + ...savedEnv, + MCP_AUTH_SECRET: TEST_SECRET, + MCP_AUTH_ISSUER: 'https://test.example.com', + }; provider = new NextcloudOAuthProvider(); }); @@ -300,6 +304,27 @@ describe('NextcloudOAuthProvider', () => { process.env.MCP_AUTH_SECRET = 'completely-different-secret-value!!'; await expect(provider.verifyAccessToken(token)).rejects.toThrow(); }); + + it('embeds iss and aud claims in the issued token', async () => { + const token = await issueToken(); + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()); + expect(payload.iss).toBe('https://test.example.com'); + expect(payload.aud).toBe('https://test.example.com/mcp'); + }); + + it('throws when verified against a different issuer', async () => { + const token = await issueToken(); + process.env.MCP_AUTH_ISSUER = 'https://other.example.com'; + await expect(provider.verifyAccessToken(token)).rejects.toThrow( + 'Invalid or expired access token' + ); + }); + + it('embeds correct aud claim (resource server URL)', async () => { + const token = await issueToken(); + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()); + expect(payload.aud).toBe('https://test.example.com/mcp'); + }); }); // ---- exchangeRefreshToken ---- diff --git a/mcp-server/src/__tests__/transports.test.ts b/mcp-server/src/__tests__/transports.test.ts index 85b6f00..2d8422d 100644 --- a/mcp-server/src/__tests__/transports.test.ts +++ b/mcp-server/src/__tests__/transports.test.ts @@ -95,7 +95,7 @@ describe('http transport', () => { beforeEach(() => { vi.clearAllMocks(); - process.env = { ...savedEnv }; + process.env = { ...savedEnv, MCP_ALLOW_UNAUTHENTICATED: 'true' }; delete process.env.MCP_AUTH_ENABLED; }); @@ -203,7 +203,7 @@ describe('http transport with auth enabled', () => { it('registers POST /auth/login handler', async () => { const { startHttp } = await import('../transports/http.js'); await startHttp(); - expect(mockPost).toHaveBeenCalledWith('/auth/login', mockLoginHandlerFn); + expect(mockPost).toHaveBeenCalledWith('/auth/login', expect.any(Function), mockLoginHandlerFn); }); it('mounts /mcp with bearer middleware and handler', async () => { diff --git a/mcp-server/src/auth/login.ts b/mcp-server/src/auth/login.ts index 1d418af..3a5d2fc 100644 --- a/mcp-server/src/auth/login.ts +++ b/mcp-server/src/auth/login.ts @@ -29,6 +29,40 @@ export function loginHandler(provider: NextcloudOAuthProvider) { return; } + const client = await provider.clientsStore.getClient(client_id); + if (!client) { + res + .status(400) + .type('html') + .send( + renderLoginForm({ + clientId: client_id, + redirectUri: redirect_uri, + codeChallenge: code_challenge, + state, + scope, + error: 'Unknown client', + }) + ); + return; + } + if (!client.redirect_uris.map(String).includes(redirect_uri)) { + res + .status(400) + .type('html') + .send( + renderLoginForm({ + clientId: client_id, + redirectUri: '', + codeChallenge: code_challenge, + state, + scope, + error: 'Invalid redirect URI', + }) + ); + return; + } + logger.info({ user: username, client: client_id, nc: ncUrl }, '[auth] Login attempt'); try { diff --git a/mcp-server/src/auth/provider.ts b/mcp-server/src/auth/provider.ts index 64e6533..beacabc 100644 --- a/mcp-server/src/auth/provider.ts +++ b/mcp-server/src/auth/provider.ts @@ -1,4 +1,4 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; +import { SignJWT, jwtVerify, type JWTPayload } from 'jose'; import type { OAuthServerProvider, AuthorizationParams, @@ -14,40 +14,35 @@ import { InvalidGrantError } from '@modelcontextprotocol/sdk/server/auth/errors. import { ClientsStore, CodeStore, RefreshStore } from './store.js'; import { logger } from '../logger.js'; -// --- JWT helpers (HMAC-SHA256 via node:crypto — no extra deps) --- +// --- JWT helpers (HMAC-SHA256 via jose) --- -function base64url(buf: Buffer): string { - return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); -} - -function base64urlStr(s: string): string { - return base64url(Buffer.from(s, 'utf8')); -} - -function signJwt(payload: Record, secret: string, expiresInSecs: number): string { +async function signJwt( + payload: Record, + secret: string, + expiresInSecs: number +): Promise { + const key = new TextEncoder().encode(secret); const now = Math.floor(Date.now() / 1000); - const claims = { ...payload, iat: now, exp: now + expiresInSecs }; - const header = base64urlStr(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); - const body = base64urlStr(JSON.stringify(claims)); - const signing = `${header}.${body}`; - const sig = base64url(createHmac('sha256', secret).update(signing).digest()); - return `${signing}.${sig}`; + return new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt(now) + .setExpirationTime(now + expiresInSecs) + .sign(key); } -function verifyJwt(token: string, secret: string): Record | null { - const parts = token.split('.'); - if (parts.length !== 3) return null; - const [header, body, sig] = parts; - const expected = base64url(createHmac('sha256', secret).update(`${header}.${body}`).digest()); - const sigBuf = Buffer.from(sig); - const expBuf = Buffer.from(expected); - // HMAC-SHA256 base64url is always 43 chars — same length guaranteed - if (sigBuf.length !== expBuf.length) return null; - if (!timingSafeEqual(sigBuf, expBuf)) return null; +async function verifyJwt( + token: string, + secret: string, + opts?: { issuer?: string; audience?: string } +): Promise { try { - const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')); - if (typeof claims.exp === 'number' && Date.now() / 1000 > claims.exp) return null; - return claims as Record; + const key = new TextEncoder().encode(secret); + const { payload } = await jwtVerify(token, key, { + algorithms: ['HS256'], + ...(opts?.issuer && { issuer: opts.issuer }), + ...(opts?.audience && { audience: opts.audience }), + }); + return payload; } catch { return null; } @@ -195,8 +190,14 @@ export class NextcloudOAuthProvider implements OAuthServerProvider { } this.codeStore.delete(authorizationCode); - const accessToken = signJwt( - { sub: entry.userId, client_id: entry.clientId, scopes: entry.scopes }, + const accessToken = await signJwt( + { + sub: entry.userId, + client_id: entry.clientId, + scopes: entry.scopes, + iss: this.getIssuer(), + aud: this.getResourceUrl(), + }, this.getSecret(), ACCESS_TOKEN_TTL_SECS ); @@ -236,8 +237,14 @@ export class NextcloudOAuthProvider implements OAuthServerProvider { const effectiveScopes = scopes ?? entry.scopes; this.refreshStore.delete(refreshToken); - const accessToken = signJwt( - { sub: entry.userId, client_id: entry.clientId, scopes: effectiveScopes }, + const accessToken = await signJwt( + { + sub: entry.userId, + client_id: entry.clientId, + scopes: effectiveScopes, + iss: this.getIssuer(), + aud: this.getResourceUrl(), + }, this.getSecret(), ACCESS_TOKEN_TTL_SECS ); @@ -258,7 +265,12 @@ export class NextcloudOAuthProvider implements OAuthServerProvider { } async verifyAccessToken(token: string): Promise { - const claims = verifyJwt(token, this.getSecret()); + const issuer = process.env.MCP_AUTH_ISSUER; + const claims = await verifyJwt( + token, + this.getSecret(), + issuer ? { issuer, audience: new URL('/mcp', issuer).toString() } : undefined + ); if (!claims) { logger.warn('[auth] Access token verification failed: invalid or expired token'); throw new Error('Invalid or expired access token'); @@ -295,4 +307,14 @@ export class NextcloudOAuthProvider implements OAuthServerProvider { if (!secret) throw new Error('MCP_AUTH_SECRET is not set'); return secret; } + + private getIssuer(): string { + const issuer = process.env.MCP_AUTH_ISSUER; + if (!issuer) throw new Error('MCP_AUTH_ISSUER is not set'); + return issuer; + } + + private getResourceUrl(): string { + return new URL('/mcp', this.getIssuer()).toString(); + } } diff --git a/mcp-server/src/transports/http.ts b/mcp-server/src/transports/http.ts index 6b1eb9d..92d788c 100644 --- a/mcp-server/src/transports/http.ts +++ b/mcp-server/src/transports/http.ts @@ -99,6 +99,16 @@ export async function startHttp(): Promise { const host = process.env.MCP_HOST || '0.0.0.0'; const authEnabled = process.env.MCP_AUTH_ENABLED === 'true'; + if (!authEnabled) { + if (process.env.MCP_ALLOW_UNAUTHENTICATED !== 'true') { + throw new Error( + 'HTTP transport requires MCP_AUTH_ENABLED=true. ' + + 'Set MCP_ALLOW_UNAUTHENTICATED=true to override (development only).' + ); + } + logger.warn('[startup] Running in UNAUTHENTICATED mode — do not use in production'); + } + // When auth is enabled, derive allowedHosts from the public issuer URL. // This suppresses the MCP SDK's "binding to 0.0.0.0 without DNS rebinding // protection" console.warn (which breaks structured-log parsers like jq) and @@ -207,7 +217,28 @@ export async function startHttp(): Promise { app.use(express.urlencoded({ extended: false })); // Nextcloud credential validation → auth code issuance - app.post('/auth/login', loginHandler(provider)); + const loginRateLimit = (() => { + const attempts = new Map(); + const MAX = 10, + WINDOW_MS = 15 * 60 * 1000; + return (req: any, res: any, next: any) => { + const ip = req.ip ?? 'unknown'; + const now = Date.now(); + const entry = attempts.get(ip); + if (!entry || now > entry.resetAt) { + attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS }); + return next(); + } + entry.count++; + if (entry.count > MAX) { + logger.warn({ ip }, '[auth] Login rate limit exceeded'); + res.status(429).json({ error: 'too_many_requests' }); + return; + } + next(); + }; + })(); + app.post('/auth/login', loginRateLimit, loginHandler(provider)); // Protect /mcp with Bearer token auth app.all(