feat: oauth refresh-token grant + per-runner subdomain TLS plumbing
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
All checks were successful
Deploy to Production / deploy (push) Successful in 52s
OAUTH REFRESH-TOKEN
- oauth_tokens.subject column added (migration applied to prod DB): stores
the JWT sub claim from the original authorization so refreshes can
re-mint with the same identity without re-walking the (consumed) code.
- Authorization-code branch now writes subject AND uses a 30-day
expires_at for the row (was 1h — same as access token, which killed
refresh after 1h).
- New refresh_token grant branch:
* looks up token by refresh-hash + expiry
* client_id must match, client_secret verified if confidential
* RFC 8707: requested resource must equal stored resource
* OAuth 2.1 rotation: atomic UPDATE WHERE old_hash → new access JWT,
new refresh token, extended expiry; loser of a race sees invalid_grant
- Access TTL (1h) and refresh TTL (30d) extracted as constants.
Clients no longer have to re-authorize hourly. Closes Zb-001.
PER-RUNNER SUBDOMAIN TLS (Z1-002)
Code path:
- New MCP_DOMAIN env (e.g. "mcp.buildmymcpserver.com") + RUNNER_MAP_DIR
(default /var/runner-map) in generator config.
- deployContainer: writes /var/runner-map/<slug>.conf with content
"slug.MCP_DOMAIN port;" and computes publicUrl as
https://<slug>.<MCP_DOMAIN>. Falls back to http://host:port when
MCP_DOMAIN is unset (zero behaviour change until host is configured).
- stopContainer (both api/lib/docker.ts and generator/lib/deploy.ts) now
accepts an optional slug arg and removes the map fragment. Callers
(DELETE /v1/servers/:id, admin template takedown) updated.
Infra path (one-time host setup — Marco runs as root):
- scripts/setup-runner-tls.sh:
1. nginx vhost matching *.mcp.buildmymcpserver.com via regex →
reads slug→port from /opt/buildmymcpserver/runner-map.combined
2. systemd inotify service watches the map dir, combines fragments
on any change, reloads nginx
3. installs inotify-tools if missing, idempotent
- Prereqs documented at top: Cloudflare wildcard DNS proxied, Origin CA
cert for *.mcp.buildmymcpserver.com, SSL mode Full (strict).
- After running: edit docker-compose.prod.yml to mount the map dir into
api + generator, set MCP_DOMAIN in env, recreate containers.
Closes Zb-001 fully. Closes Z1-002 on the code side; one Marco-on-host
action away from closing it on the infra side.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,43 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Per-runner nginx map fragment cleanup. Mirrors the generator-side helper
|
||||
* (apps/generator/src/lib/deploy.ts) — when MCP_DOMAIN is set, the host
|
||||
* runs an inotify watcher over the map dir that reloads nginx on any
|
||||
* change. We remove the fragment here so the slug stops serving 502 the
|
||||
* moment the user deletes their server.
|
||||
*
|
||||
* No-op if MCP_DOMAIN isn't configured (legacy http://host:port URLs are
|
||||
* still in use). Idempotent — missing files are fine.
|
||||
*/
|
||||
const MCP_DOMAIN = process.env.MCP_DOMAIN ?? '';
|
||||
const RUNNER_MAP_DIR = process.env.RUNNER_MAP_DIR ?? '/var/runner-map';
|
||||
|
||||
async function removeRunnerMapEntry(slug: string): Promise<void> {
|
||||
if (!MCP_DOMAIN || !slug) return;
|
||||
try {
|
||||
await fs.rm(path.join(RUNNER_MAP_DIR, `${slug}.conf`), { force: true });
|
||||
} catch {
|
||||
/* ignore — not critical */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and remove a generated MCP container by container id.
|
||||
* Resolves regardless of outcome — failures are logged but never blocking.
|
||||
* Production: should be moved to a Coolify HTTP-API call.
|
||||
* Also drops the slug's nginx map fragment so the public URL stops 502'ing
|
||||
* the moment the container goes away.
|
||||
*/
|
||||
export async function stopContainer(containerId: string): Promise<{ ok: boolean; detail: string }> {
|
||||
export async function stopContainer(
|
||||
containerId: string,
|
||||
slug?: string,
|
||||
): Promise<{ ok: boolean; detail: string }> {
|
||||
if (!containerId || containerId.length < 4) {
|
||||
return { ok: false, detail: 'invalid_container_id' };
|
||||
}
|
||||
if (slug) await removeRunnerMapEntry(slug);
|
||||
return await new Promise<{ ok: boolean; detail: string }>((resolve) => {
|
||||
const child = spawn('docker', ['rm', '-f', containerId], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
|
||||
@@ -19,6 +19,12 @@ import { config } from '../config.js';
|
||||
|
||||
const db = createDb();
|
||||
|
||||
// Access-token lifetime is short so revocation propagates within the hour.
|
||||
// Refresh-token lifetime is long so legitimate clients don't have to
|
||||
// re-authorize daily; rotation on every refresh limits exposure if one leaks.
|
||||
const ACCESS_TOKEN_TTL_S = 3600; // 1 hour
|
||||
const REFRESH_TOKEN_TTL_MS = 30 * 24 * 3600 * 1000; // 30 days
|
||||
|
||||
function sha256(input: string): string {
|
||||
return crypto.createHash('sha256').update(input).digest('hex');
|
||||
}
|
||||
@@ -236,12 +242,13 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(400).send({ error: 'invalid_grant' });
|
||||
}
|
||||
|
||||
const subject = row.code.userId ?? row.client.clientId;
|
||||
const accessToken = await signAccessToken({
|
||||
subject: row.code.userId ?? row.client.clientId,
|
||||
subject,
|
||||
audience: resource,
|
||||
issuer: `${config.CONTROL_PLANE_PUBLIC_URL}/oauth`,
|
||||
scope: row.code.scope ?? '',
|
||||
ttlSeconds: 3600,
|
||||
ttlSeconds: ACCESS_TOKEN_TTL_S,
|
||||
});
|
||||
const refreshToken = crypto.randomBytes(32).toString('base64url');
|
||||
await db.insert(oauthTokens).values({
|
||||
@@ -250,18 +257,98 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
|
||||
refreshTokenHash: sha256(refreshToken),
|
||||
scope: row.code.scope ?? null,
|
||||
resource,
|
||||
expiresAt: new Date(Date.now() + 3600 * 1000),
|
||||
subject,
|
||||
// expiresAt is the REFRESH-token lifetime — 30 days. Access-token
|
||||
// expiry lives inside the JWT's `exp` claim (1h, set above).
|
||||
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
expires_in: ACCESS_TOKEN_TTL_S,
|
||||
refresh_token: refreshToken,
|
||||
scope: row.code.scope ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── grant_type: refresh_token ─────────────────────────────────────
|
||||
// OAuth 2.1 with rotation: every successful refresh issues a NEW refresh
|
||||
// token and atomically invalidates the old one. If a stolen refresh token
|
||||
// gets used after the legitimate client refreshed, the second use sees
|
||||
// invalid_grant — that's how rotation surfaces token theft.
|
||||
if (parsed.data.grant_type === 'refresh_token') {
|
||||
const { refresh_token, client_id, client_secret, resource: requestedResource } =
|
||||
parsed.data;
|
||||
if (!refresh_token || !client_id) {
|
||||
return reply.code(400).send({ error: 'invalid_request' });
|
||||
}
|
||||
|
||||
const refreshHash = sha256(refresh_token);
|
||||
const [row] = await db
|
||||
.select({ token: oauthTokens, client: oauthClients })
|
||||
.from(oauthTokens)
|
||||
.innerJoin(oauthClients, eq(oauthClients.id, oauthTokens.clientDbId))
|
||||
.where(
|
||||
and(
|
||||
eq(oauthTokens.refreshTokenHash, refreshHash),
|
||||
gt(oauthTokens.expiresAt, new Date()),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!row) return reply.code(400).send({ error: 'invalid_grant' });
|
||||
if (row.client.clientId !== client_id) {
|
||||
return reply.code(401).send({ error: 'invalid_client' });
|
||||
}
|
||||
if (row.client.clientSecretHash) {
|
||||
if (!client_secret || sha256(client_secret) !== row.client.clientSecretHash) {
|
||||
return reply.code(401).send({ error: 'invalid_client' });
|
||||
}
|
||||
}
|
||||
// RFC 8707: requested resource must equal the stored one — refreshes
|
||||
// don't allow audience changes (would be a downgrade/escalation vector).
|
||||
if (requestedResource && requestedResource !== row.token.resource) {
|
||||
return reply.code(400).send({ error: 'invalid_resource' });
|
||||
}
|
||||
|
||||
const subject = row.token.subject ?? row.client.clientId;
|
||||
const newAccessToken = await signAccessToken({
|
||||
subject,
|
||||
audience: row.token.resource ?? '',
|
||||
issuer: `${config.CONTROL_PLANE_PUBLIC_URL}/oauth`,
|
||||
scope: row.token.scope ?? '',
|
||||
ttlSeconds: ACCESS_TOKEN_TTL_S,
|
||||
});
|
||||
const newRefreshToken = crypto.randomBytes(32).toString('base64url');
|
||||
const newRefreshHash = sha256(newRefreshToken);
|
||||
|
||||
// Atomic rotation: UPDATE only succeeds if the row still has the OLD
|
||||
// refresh-hash. Two parallel refreshes with the same token can't both
|
||||
// win — the loser sees zero rows and gets invalid_grant.
|
||||
const rotated = await db
|
||||
.update(oauthTokens)
|
||||
.set({
|
||||
accessTokenHash: sha256(newAccessToken),
|
||||
refreshTokenHash: newRefreshHash,
|
||||
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
|
||||
})
|
||||
.where(
|
||||
and(eq(oauthTokens.id, row.token.id), eq(oauthTokens.refreshTokenHash, refreshHash)),
|
||||
)
|
||||
.returning({ id: oauthTokens.id });
|
||||
if (rotated.length === 0) {
|
||||
return reply.code(400).send({ error: 'invalid_grant' });
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
access_token: newAccessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: ACCESS_TOKEN_TTL_S,
|
||||
refresh_token: newRefreshToken,
|
||||
scope: row.token.scope ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.code(400).send({ error: 'unsupported_grant_type' });
|
||||
});
|
||||
|
||||
|
||||
@@ -521,7 +521,7 @@ export async function serverRoutes(app: FastifyInstance): Promise<void> {
|
||||
// otherwise it keeps serving traffic with the user's secrets baked in.
|
||||
let containerStopped = false;
|
||||
if (server.containerId) {
|
||||
const result = await stopContainer(server.containerId);
|
||||
const result = await stopContainer(server.containerId, server.slug);
|
||||
containerStopped = result.ok;
|
||||
if (!result.ok) {
|
||||
app.log.warn(
|
||||
|
||||
@@ -559,12 +559,12 @@ export async function templateRoutes(app: FastifyInstance): Promise<void> {
|
||||
let stoppedContainers = 0;
|
||||
if (b.data.status === 'takedown') {
|
||||
const forkedServers = await db
|
||||
.select({ id: mcpServers.id, containerId: mcpServers.containerId })
|
||||
.select({ id: mcpServers.id, containerId: mcpServers.containerId, slug: mcpServers.slug })
|
||||
.from(mcpServers)
|
||||
.where(eq(mcpServers.templateId, p.data.id));
|
||||
for (const fork of forkedServers) {
|
||||
if (fork.containerId) {
|
||||
const result = await stopContainer(fork.containerId);
|
||||
const result = await stopContainer(fork.containerId, fork.slug);
|
||||
if (result.ok) stoppedContainers++;
|
||||
else
|
||||
app.log.warn(
|
||||
|
||||
Reference in New Issue
Block a user