feat: native OAuth login page, architecture docs, docs site update
- Add GET/POST /login to hermes for first-party cookie during OAuth popup (fixes browser CHIPS cookie partitioning that broke claude.ai connection) - Add role column to all findCustomer* SQL queries in src/auth.ts - Add claude.ai tab to docs/getting-started.html with OAuth flow steps - Add ARCHITECTURE.md with system diagrams, data flow, and key invariants - Rewrite README.md and DEPLOY.md to reflect actual MicroK8s deployment - Deploy updated docs site (squaremcp-docs sha256 updated) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
132
src/index.ts
132
src/index.ts
@@ -39,10 +39,17 @@ import redis from './redis.js';
|
||||
const app = express();
|
||||
app.use(cookieParser());
|
||||
app.use(cors({
|
||||
origin: '*',
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (curl, server-to-server, MCP clients)
|
||||
if (!origin) return callback(null, true);
|
||||
if (SQUAREMCP_ALLOWED_ORIGINS.has(origin)) return callback(null, origin);
|
||||
// Allow localhost for dev/testing
|
||||
if (/^https?:\/\/localhost(:\d+)?$/.test(origin)) return callback(null, origin);
|
||||
callback(null, false);
|
||||
},
|
||||
methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'mcp-session-id', 'Accept', 'x-api-key', 'Authorization'],
|
||||
credentials: true
|
||||
credentials: true,
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
@@ -58,6 +65,7 @@ const PROTECTED_RESOURCE_METADATA_URL = `${SERVER_URL}/.well-known/oauth-protect
|
||||
const SQUAREMCP_ALLOWED_ORIGINS = new Set([
|
||||
'https://squaremcp.com',
|
||||
'https://www.squaremcp.com',
|
||||
'https://app.squaremcp.com',
|
||||
'https://tiktok.squaremcp.com',
|
||||
]);
|
||||
|
||||
@@ -349,11 +357,12 @@ async function requireAuth(req: express.Request, res: express.Response, next: ex
|
||||
const payload = verifyJWT(jwtCookie);
|
||||
const customer = await resolveCustomerById(payload.sub);
|
||||
if (customer && customer.active) {
|
||||
(req as express.Request & { customer?: Customer; jwtUser?: { id: string; email: string; plan: string } }).customer = customer;
|
||||
(req as express.Request & { jwtUser?: { id: string; email: string; plan: string } }).jwtUser = {
|
||||
(req as express.Request & { customer?: Customer; jwtUser?: { id: string; email: string; plan: string; role: string } }).customer = customer;
|
||||
(req as express.Request & { jwtUser?: { id: string; email: string; plan: string; role: string } }).jwtUser = {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
plan: payload.plan,
|
||||
role: customer.role,
|
||||
};
|
||||
return next();
|
||||
}
|
||||
@@ -416,6 +425,98 @@ app.post('/oauth/register', async (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Native login page (first-party cookie for OAuth flow) ──────────────────
|
||||
app.get('/login', (req, res) => {
|
||||
const returnTo = req.query.return_to as string | undefined;
|
||||
const error = req.query.error as string | undefined;
|
||||
const safeReturnTo = returnTo && returnTo.startsWith('https://hermes.squaremcp.com/')
|
||||
? returnTo
|
||||
: '/';
|
||||
const errMsg = error === 'invalid' ? 'Incorrect email or password.'
|
||||
: error === 'missing' ? 'Email and password are required.'
|
||||
: '';
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Sign in — SquareMCP</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f8fafc;display:flex;align-items:center;justify-content:center;min-height:100vh}
|
||||
.card{background:#fff;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.08);padding:40px;width:100%;max-width:400px}
|
||||
.logo{text-align:center;margin-bottom:28px;font-size:22px;font-weight:700;color:#1a1a2e}
|
||||
.logo span{color:#6366f1}
|
||||
h1{font-size:20px;font-weight:600;color:#1a1a2e;margin-bottom:6px;text-align:center}
|
||||
.sub{font-size:14px;color:#64748b;text-align:center;margin-bottom:28px}
|
||||
label{font-size:14px;font-weight:500;color:#374151;display:block;margin-bottom:6px}
|
||||
input{width:100%;padding:10px 14px;border:1.5px solid #e2e8f0;border-radius:8px;font-size:15px;outline:none;transition:border-color .2s}
|
||||
input:focus{border-color:#6366f1}
|
||||
.field{margin-bottom:18px}
|
||||
button{width:100%;padding:12px;background:#6366f1;color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:background .2s}
|
||||
button:hover{background:#4f46e5}
|
||||
.error{color:#dc2626;font-size:13px;text-align:center;margin-bottom:16px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">Square<span>MCP</span></div>
|
||||
<h1>Sign in to continue</h1>
|
||||
<p class="sub">Connect your SquareMCP account to authorize access.</p>
|
||||
${errMsg ? `<p class="error">${errMsg}</p>` : ''}
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="return_to" value="${safeReturnTo.replace(/"/g, '"')}">
|
||||
<div class="field">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required autocomplete="email" placeholder="you@example.com">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="••••••••">
|
||||
</div>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>`);
|
||||
});
|
||||
|
||||
app.post('/login', express.urlencoded({ extended: false }), async (req, res) => {
|
||||
const { email, password, return_to } = req.body as Record<string, string>;
|
||||
const safeReturnTo = return_to && return_to.startsWith('https://hermes.squaremcp.com/')
|
||||
? return_to
|
||||
: '/';
|
||||
|
||||
if (!email || !password) {
|
||||
res.redirect(`/login?return_to=${encodeURIComponent(safeReturnTo)}&error=missing`);
|
||||
return;
|
||||
}
|
||||
|
||||
const customer = await findCustomerByEmail(email);
|
||||
if (!customer || !customer.password_hash) {
|
||||
res.redirect(`/login?return_to=${encodeURIComponent(safeReturnTo)}&error=invalid`);
|
||||
return;
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, customer.password_hash);
|
||||
if (!valid) {
|
||||
res.redirect(`/login?return_to=${encodeURIComponent(safeReturnTo)}&error=invalid`);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = signJWT({ sub: customer.id, email: customer.email, plan: customer.plan, role: customer.role });
|
||||
res.cookie('session', token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
domain: '.squaremcp.com',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
res.redirect(safeReturnTo);
|
||||
});
|
||||
|
||||
// Authorization endpoint: GET shows consent form, POST handles approval
|
||||
app.get('/oauth/authorize', async (req, res) => {
|
||||
const clientId = req.query.client_id as string | undefined;
|
||||
@@ -449,15 +550,13 @@ app.get('/oauth/authorize', async (req, res) => {
|
||||
// Require authenticated SquareMCP session to show the consent page
|
||||
const jwtCookie = req.cookies?.session;
|
||||
if (!jwtCookie) {
|
||||
const returnTo = encodeURIComponent(req.originalUrl);
|
||||
res.redirect(`/login?return_to=${returnTo}`);
|
||||
res.redirect(`/login?return_to=${encodeURIComponent(`https://hermes.squaremcp.com${req.originalUrl}`)}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
verifyJWT(jwtCookie);
|
||||
} catch {
|
||||
const returnTo = encodeURIComponent(req.originalUrl);
|
||||
res.redirect(`/login?return_to=${returnTo}`);
|
||||
res.redirect(`/login?return_to=${encodeURIComponent(`https://hermes.squaremcp.com${req.originalUrl}`)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1164,14 +1263,17 @@ app.post('/api/auth/signup', express.json(), async (req, res) => {
|
||||
if (isFirstUser) {
|
||||
await getPool().query("UPDATE customers SET role = 'admin', plan = 'enterprise' WHERE id = ?", [id]);
|
||||
}
|
||||
const token = signJWT({ sub: id, email, plan: isFirstUser ? 'enterprise' : 'free' });
|
||||
const role = isFirstUser ? 'admin' : 'user';
|
||||
const plan = isFirstUser ? 'enterprise' : 'free';
|
||||
const token = signJWT({ sub: id, email, plan, role });
|
||||
res.cookie('session', token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
sameSite: 'lax',
|
||||
domain: '.squaremcp.com',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
res.status(201).json({ id, email, plan: 'free', api_key: apiKey });
|
||||
res.status(201).json({ id, email, plan, role, api_key: apiKey });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to create account' });
|
||||
}
|
||||
@@ -1196,23 +1298,23 @@ app.post('/api/auth/login', express.json(), async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = signJWT({ sub: customer.id, email: customer.email, plan: customer.plan });
|
||||
const token = signJWT({ sub: customer.id, email: customer.email, plan: customer.plan, role: customer.role });
|
||||
res.cookie('session', token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
res.json({ id: customer.id, email: customer.email, plan: customer.plan, api_key: customer.api_key });
|
||||
res.json({ id: customer.id, email: customer.email, plan: customer.plan, role: customer.role, api_key: customer.api_key });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (_req, res) => {
|
||||
res.clearCookie('session');
|
||||
res.clearCookie('session', { domain: '.squaremcp.com' });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', requireAuth, async (req, res) => {
|
||||
const jwtUser = (req as express.Request & { jwtUser?: { id: string; email: string; plan: string } }).jwtUser;
|
||||
const jwtUser = (req as express.Request & { jwtUser?: { id: string; email: string; plan: string; role?: string } }).jwtUser;
|
||||
if (jwtUser) {
|
||||
res.json(jwtUser);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user