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:
Garfield
2026-05-14 13:48:01 -04:00
parent 61dab40585
commit 02398258a5
13 changed files with 697 additions and 298 deletions

View File

@@ -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, '&quot;')}">
<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;