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
11 changes: 6 additions & 5 deletions mcp-server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions mcp-server/server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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": [
Expand Down
3 changes: 3 additions & 0 deletions mcp-server/src/__tests__/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
...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();
Expand All @@ -43,24 +46,24 @@
});

it('redirects with ?code= on successful Nextcloud auth', async () => {
(global.fetch as any).mockResolvedValueOnce({ ok: true });

Check warning on line 49 in mcp-server/src/__tests__/auth/login.test.ts

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

Unexpected any. Specify a different type
const res = makeRes();
await loginHandler(provider)({ body: BASE_BODY } as any, res as any);

Check warning on line 51 in mcp-server/src/__tests__/auth/login.test.ts

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

Unexpected any. Specify a different type

Check warning on line 51 in mcp-server/src/__tests__/auth/login.test.ts

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

Unexpected any. Specify a different type
expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining('code='));
});

it('includes state in the redirect URL', async () => {
(global.fetch as any).mockResolvedValueOnce({ ok: true });

Check warning on line 56 in mcp-server/src/__tests__/auth/login.test.ts

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

Unexpected any. Specify a different type
const res = makeRes();
await loginHandler(provider)({ body: BASE_BODY } as any, res as any);

Check warning on line 58 in mcp-server/src/__tests__/auth/login.test.ts

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

Unexpected any. Specify a different type

Check warning on line 58 in mcp-server/src/__tests__/auth/login.test.ts

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

Unexpected any. Specify a different type
const url = (res.redirect as any).mock.calls[0][0] as string;

Check warning on line 59 in mcp-server/src/__tests__/auth/login.test.ts

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

Unexpected any. Specify a different type
expect(new URL(url).searchParams.get('state')).toBe('xyz');
});

it('omits state when state is empty', async () => {
(global.fetch as any).mockResolvedValueOnce({ ok: true });

Check warning on line 64 in mcp-server/src/__tests__/auth/login.test.ts

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

Unexpected any. Specify a different type
const res = makeRes();
await loginHandler(provider)({ body: { ...BASE_BODY, state: '' } } as any, res as any);

Check warning on line 66 in mcp-server/src/__tests__/auth/login.test.ts

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

Unexpected any. Specify a different type

Check warning on line 66 in mcp-server/src/__tests__/auth/login.test.ts

View workflow job for this annotation

GitHub Actions / ESLint & Prettier

Unexpected any. Specify a different type
const url = (res.redirect as any).mock.calls[0][0] as string;
expect(new URL(url).searchParams.has('state')).toBe(false);
});
Expand Down
27 changes: 26 additions & 1 deletion mcp-server/src/__tests__/auth/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -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 ----
Expand Down
4 changes: 2 additions & 2 deletions mcp-server/src/__tests__/transports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});

Expand Down Expand Up @@ -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 () => {
Expand Down
34 changes: 34 additions & 0 deletions mcp-server/src/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
92 changes: 57 additions & 35 deletions mcp-server/src/auth/provider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
import type {
OAuthServerProvider,
AuthorizationParams,
Expand All @@ -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<string, unknown>, secret: string, expiresInSecs: number): string {
async function signJwt(
payload: Record<string, unknown>,
secret: string,
expiresInSecs: number
): Promise<string> {
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<string, unknown> | 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<JWTPayload | null> {
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<string, unknown>;
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;
}
Expand Down Expand Up @@ -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
);
Expand Down Expand Up @@ -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
);
Expand All @@ -258,7 +265,12 @@ export class NextcloudOAuthProvider implements OAuthServerProvider {
}

async verifyAccessToken(token: string): Promise<AuthInfo> {
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');
Expand Down Expand Up @@ -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();
}
}
33 changes: 32 additions & 1 deletion mcp-server/src/transports/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ export async function startHttp(): Promise<void> {
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
Expand Down Expand Up @@ -207,7 +217,28 @@ export async function startHttp(): Promise<void> {
app.use(express.urlencoded({ extended: false }));

// Nextcloud credential validation → auth code issuance
app.post('/auth/login', loginHandler(provider));
const loginRateLimit = (() => {
const attempts = new Map<string, { count: number; resetAt: number }>();
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(
Expand Down