aiquila-mcp 0.4.3 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/auth/login-page-css.js +23 -0
- package/dist/auth/login.js +6 -6
- package/dist/auth/provider.js +19 -18
- package/dist/transports/http.js +64 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,6 +92,8 @@ See the [Docker setup guide](https://github.com/elgorro/aiquila/blob/main/docs/m
|
|
|
92
92
|
| `MCP_AUTH_ENABLED` | No | `true` to enable OAuth for remote clients |
|
|
93
93
|
| `MCP_AUTH_SECRET` | If auth | `openssl rand -hex 32` |
|
|
94
94
|
| `MCP_AUTH_ISSUER` | If auth | public HTTPS URL of this server |
|
|
95
|
+
| `MCP_ALLOWED_HOSTS` | No | extra hostnames for DNS rebinding protection |
|
|
96
|
+
| `MCP_CORS_ORIGINS` | No | extra browser origins allowed via CORS |
|
|
95
97
|
| `LOG_LEVEL` | No | `trace`/`debug`/`info`/`warn`/`error`/`fatal` |
|
|
96
98
|
|
|
97
99
|
## Requirements
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
/**
|
|
3
|
+
* Stylesheet for the OAuth login page.
|
|
4
|
+
*
|
|
5
|
+
* Served as a standalone document at LOGIN_STYLESHEET_PATH rather than inlined
|
|
6
|
+
* in a <style> block, so the login page's CSP can drop 'unsafe-inline' entirely.
|
|
7
|
+
* Keep this free of any interpolated/user-supplied value — it is served verbatim.
|
|
8
|
+
*/
|
|
9
|
+
export const LOGIN_STYLESHEET = `body { font-family: sans-serif; background: #f4f6f8; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
|
|
10
|
+
.card { background: #fff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.12); padding: 2rem; width: 100%; max-width: 360px; }
|
|
11
|
+
h1 { font-size: 1.3rem; margin: 0 0 .25rem; }
|
|
12
|
+
.consent-banner { background: #eef6fc; border: 1px solid #b3d7f0; border-radius: 4px; padding: .75rem 1rem; margin-bottom: 1.25rem; font-size: .85rem; color: #333; line-height: 1.6; }
|
|
13
|
+
.consent-banner strong { color: #0082c9; }
|
|
14
|
+
.consent-banner .detail { display: block; margin-top: .25rem; }
|
|
15
|
+
.consent-banner code { background: #d6ecf9; border-radius: 3px; padding: 0 .3em; font-size: .85em; word-break: break-all; }
|
|
16
|
+
label { display: block; font-size: .85rem; font-weight: 600; margin-bottom: .25rem; }
|
|
17
|
+
input[type=text], input[type=password] { width: 100%; box-sizing: border-box; padding: .55rem .75rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; margin-bottom: 1rem; }
|
|
18
|
+
button { width: 100%; padding: .65rem; background: #0082c9; color: #fff; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; }
|
|
19
|
+
button:hover { background: #006fa3; }
|
|
20
|
+
.error { background: #fdecea; color: #c0392b; border-radius: 4px; padding: .6rem .9rem; margin-bottom: 1rem; font-size: .9rem; }
|
|
21
|
+
`;
|
|
22
|
+
/** Public path the login page links its stylesheet from. */
|
|
23
|
+
export const LOGIN_STYLESHEET_PATH = '/auth/login.css';
|
package/dist/auth/login.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
|
-
import { renderLoginForm } from './provider.js';
|
|
2
|
+
import { renderLoginForm, applySecurityHeaders } from './provider.js';
|
|
3
3
|
import { logger } from '../logger.js';
|
|
4
4
|
export function loginHandler(provider) {
|
|
5
5
|
return async (req, res) => {
|
|
@@ -11,7 +11,7 @@ export function loginHandler(provider) {
|
|
|
11
11
|
return;
|
|
12
12
|
}
|
|
13
13
|
if (!username || !password || !client_id || !redirect_uri || !code_challenge) {
|
|
14
|
-
res
|
|
14
|
+
applySecurityHeaders(res)
|
|
15
15
|
.status(400)
|
|
16
16
|
.type('html')
|
|
17
17
|
.send(renderLoginForm({
|
|
@@ -26,7 +26,7 @@ export function loginHandler(provider) {
|
|
|
26
26
|
}
|
|
27
27
|
const client = await provider.clientsStore.getClient(client_id);
|
|
28
28
|
if (!client) {
|
|
29
|
-
res
|
|
29
|
+
applySecurityHeaders(res)
|
|
30
30
|
.status(400)
|
|
31
31
|
.type('html')
|
|
32
32
|
.send(renderLoginForm({
|
|
@@ -40,7 +40,7 @@ export function loginHandler(provider) {
|
|
|
40
40
|
return;
|
|
41
41
|
}
|
|
42
42
|
if (!client.redirect_uris.map(String).includes(redirect_uri)) {
|
|
43
|
-
res
|
|
43
|
+
applySecurityHeaders(res)
|
|
44
44
|
.status(400)
|
|
45
45
|
.type('html')
|
|
46
46
|
.send(renderLoginForm({
|
|
@@ -64,7 +64,7 @@ export function loginHandler(provider) {
|
|
|
64
64
|
});
|
|
65
65
|
if (!ncResp.ok) {
|
|
66
66
|
logger.warn({ user: username, status: ncResp.status }, '[auth] Login failed');
|
|
67
|
-
res
|
|
67
|
+
applySecurityHeaders(res)
|
|
68
68
|
.status(200)
|
|
69
69
|
.type('html')
|
|
70
70
|
.send(renderLoginForm({
|
|
@@ -95,7 +95,7 @@ export function loginHandler(provider) {
|
|
|
95
95
|
}
|
|
96
96
|
catch (err) {
|
|
97
97
|
logger.error({ user: username, err }, '[auth] Login error');
|
|
98
|
-
res
|
|
98
|
+
applySecurityHeaders(res)
|
|
99
99
|
.status(200)
|
|
100
100
|
.type('html')
|
|
101
101
|
.send(renderLoginForm({
|
package/dist/auth/provider.js
CHANGED
|
@@ -3,6 +3,7 @@ import { createHash, timingSafeEqual } from 'node:crypto';
|
|
|
3
3
|
import { SignJWT, jwtVerify } from 'jose';
|
|
4
4
|
import { InvalidGrantError, InvalidTokenError, } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
5
5
|
import { ClientsStore, CodeStore, RefreshStore } from './store.js';
|
|
6
|
+
import { LOGIN_STYLESHEET_PATH } from './login-page-css.js';
|
|
6
7
|
import { logger } from '../logger.js';
|
|
7
8
|
// --- JWT helpers (HMAC-SHA256 via jose) ---
|
|
8
9
|
async function signJwt(payload, secret, expiresInSecs) {
|
|
@@ -37,6 +38,22 @@ function escapeHtml(s) {
|
|
|
37
38
|
.replace(/"/g, '"')
|
|
38
39
|
.replace(/'/g, ''');
|
|
39
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Security headers for every HTML response served by the OAuth login flow.
|
|
43
|
+
*
|
|
44
|
+
* The page has no inline <script> and no inline styles (the stylesheet is served
|
|
45
|
+
* separately from LOGIN_STYLESHEET_PATH), so the policy can deny everything by
|
|
46
|
+
* default and allow only same-origin styles.
|
|
47
|
+
*/
|
|
48
|
+
export const LOGIN_PAGE_CSP = "frame-ancestors 'none'; default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'";
|
|
49
|
+
/** Applies the login-flow security headers to a response. Returns the response for chaining. */
|
|
50
|
+
export function applySecurityHeaders(res) {
|
|
51
|
+
return res
|
|
52
|
+
.set('X-Frame-Options', 'DENY')
|
|
53
|
+
.set('X-Content-Type-Options', 'nosniff')
|
|
54
|
+
.set('Referrer-Policy', 'no-referrer')
|
|
55
|
+
.set('Content-Security-Policy', LOGIN_PAGE_CSP);
|
|
56
|
+
}
|
|
40
57
|
export function renderLoginForm(opts) {
|
|
41
58
|
return `<!DOCTYPE html>
|
|
42
59
|
<html lang="en">
|
|
@@ -44,21 +61,7 @@ export function renderLoginForm(opts) {
|
|
|
44
61
|
<meta charset="UTF-8">
|
|
45
62
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
46
63
|
<title>AIquila – Sign in with Nextcloud</title>
|
|
47
|
-
<
|
|
48
|
-
body { font-family: sans-serif; background: #f4f6f8; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
|
|
49
|
-
.card { background: #fff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.12); padding: 2rem; width: 100%; max-width: 360px; }
|
|
50
|
-
h1 { font-size: 1.3rem; margin: 0 0 .25rem; }
|
|
51
|
-
p.sub { color: #555; font-size: .9rem; margin: 0 0 1.5rem; }
|
|
52
|
-
.consent-banner { background: #eef6fc; border: 1px solid #b3d7f0; border-radius: 4px; padding: .75rem 1rem; margin-bottom: 1.25rem; font-size: .85rem; color: #333; line-height: 1.6; }
|
|
53
|
-
.consent-banner strong { color: #0082c9; }
|
|
54
|
-
.consent-banner .detail { display: block; margin-top: .25rem; }
|
|
55
|
-
.consent-banner code { background: #d6ecf9; border-radius: 3px; padding: 0 .3em; font-size: .85em; word-break: break-all; }
|
|
56
|
-
label { display: block; font-size: .85rem; font-weight: 600; margin-bottom: .25rem; }
|
|
57
|
-
input[type=text], input[type=password] { width: 100%; box-sizing: border-box; padding: .55rem .75rem; border: 1px solid #ccc; border-radius: 4px; font-size: 1rem; margin-bottom: 1rem; }
|
|
58
|
-
button { width: 100%; padding: .65rem; background: #0082c9; color: #fff; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; }
|
|
59
|
-
button:hover { background: #006fa3; }
|
|
60
|
-
.error { background: #fdecea; color: #c0392b; border-radius: 4px; padding: .6rem .9rem; margin-bottom: 1rem; font-size: .9rem; }
|
|
61
|
-
</style>
|
|
64
|
+
<link rel="stylesheet" href="${LOGIN_STYLESHEET_PATH}">
|
|
62
65
|
</head>
|
|
63
66
|
<body>
|
|
64
67
|
<div class="card">
|
|
@@ -96,9 +99,7 @@ export class NextcloudOAuthProvider {
|
|
|
96
99
|
return this._clientsStore;
|
|
97
100
|
}
|
|
98
101
|
async authorize(client, params, res) {
|
|
99
|
-
res
|
|
100
|
-
.set('X-Frame-Options', 'DENY')
|
|
101
|
-
.set('Content-Security-Policy', "frame-ancestors 'none'; default-src 'self'; style-src 'unsafe-inline'")
|
|
102
|
+
applySecurityHeaders(res)
|
|
102
103
|
.status(200)
|
|
103
104
|
.type('html')
|
|
104
105
|
.send(renderLoginForm({
|
package/dist/transports/http.js
CHANGED
|
@@ -9,6 +9,7 @@ import { createServer, SERVER_VERSION } from '../server.js';
|
|
|
9
9
|
import { NextcloudOAuthProvider } from '../auth/provider.js';
|
|
10
10
|
import { probeStateDir, StateDirNotWritableError, stateUnwritableMessage, markStateUnwritableWarned, } from '../auth/store.js';
|
|
11
11
|
import { loginHandler } from '../auth/login.js';
|
|
12
|
+
import { LOGIN_STYLESHEET, LOGIN_STYLESHEET_PATH } from '../auth/login-page-css.js';
|
|
12
13
|
import { isPublicRequest } from './lazy-auth.js';
|
|
13
14
|
import { logger } from '../logger.js';
|
|
14
15
|
import { fetchStatus } from '../client/ocs.js';
|
|
@@ -133,7 +134,70 @@ export async function startHttp() {
|
|
|
133
134
|
allowedHosts = [...new Set(['localhost', '127.0.0.1', ...extras])];
|
|
134
135
|
}
|
|
135
136
|
}
|
|
137
|
+
// Explicit CORS. Browser-based MCP clients need these headers; previously the
|
|
138
|
+
// server sent none and relied entirely on the reverse proxy. The issuer origin
|
|
139
|
+
// is trusted by default (it serves the login page), and MCP_CORS_ORIGINS adds
|
|
140
|
+
// extra origins — comma-separated, whitespace-trimmed, same idiom as
|
|
141
|
+
// MCP_ALLOWED_HOSTS. Origins are matched exactly and echoed back; the wildcard
|
|
142
|
+
// '*' is never sent, since these endpoints are credentialed.
|
|
143
|
+
const allowedOrigins = new Set();
|
|
144
|
+
const issuerForCors = process.env.MCP_AUTH_ISSUER;
|
|
145
|
+
if (authEnabled && issuerForCors) {
|
|
146
|
+
try {
|
|
147
|
+
allowedOrigins.add(new URL(issuerForCors).origin);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Malformed issuer URL — the validation below will throw a clear error.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const extraOrigins = process.env.MCP_CORS_ORIGINS;
|
|
154
|
+
if (extraOrigins) {
|
|
155
|
+
for (const raw of extraOrigins.split(',')) {
|
|
156
|
+
const trimmed = raw.trim();
|
|
157
|
+
if (!trimmed)
|
|
158
|
+
continue;
|
|
159
|
+
try {
|
|
160
|
+
allowedOrigins.add(new URL(trimmed).origin);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
logger.warn({ origin: trimmed }, '[startup] Ignoring malformed MCP_CORS_ORIGINS entry');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
136
167
|
const app = createMcpExpressApp({ host, allowedHosts });
|
|
168
|
+
// Mounted ahead of every other route so that preflights are answered before
|
|
169
|
+
// the auth chain runs — an OPTIONS request carries no Authorization header
|
|
170
|
+
// and would otherwise be rejected with 401 by requireBearerAuth.
|
|
171
|
+
app.use((req, res, next) => {
|
|
172
|
+
const origin = req.headers?.origin;
|
|
173
|
+
if (typeof origin === 'string' && allowedOrigins.has(origin)) {
|
|
174
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
175
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
176
|
+
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID');
|
|
177
|
+
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id, WWW-Authenticate');
|
|
178
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
179
|
+
res.setHeader('Access-Control-Max-Age', '86400');
|
|
180
|
+
}
|
|
181
|
+
// Always vary on Origin: the response differs per origin even when no
|
|
182
|
+
// headers are added, so a shared cache must not reuse one for another.
|
|
183
|
+
res.setHeader('Vary', 'Origin');
|
|
184
|
+
if (req.method === 'OPTIONS') {
|
|
185
|
+
res.status(204).end();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
next();
|
|
189
|
+
});
|
|
190
|
+
// Stylesheet for the OAuth login page. Served as its own document so the page
|
|
191
|
+
// needs no inline <style>, which lets its CSP drop 'unsafe-inline'. Must stay
|
|
192
|
+
// ahead of the auth middleware — the login page is shown to anonymous users.
|
|
193
|
+
app.get(LOGIN_STYLESHEET_PATH, (_req, res) => {
|
|
194
|
+
res
|
|
195
|
+
.set('Content-Type', 'text/css; charset=utf-8')
|
|
196
|
+
.set('Cache-Control', 'public, max-age=86400')
|
|
197
|
+
.set('X-Content-Type-Options', 'nosniff')
|
|
198
|
+
.status(200)
|
|
199
|
+
.send(LOGIN_STYLESHEET);
|
|
200
|
+
});
|
|
137
201
|
// Simple health check — bypasses all auth middleware so Docker health checks
|
|
138
202
|
// work even before OAuth is fully configured or TLS is verified.
|
|
139
203
|
app.get('/health', (_req, res) => {
|