Files

375 lines
13 KiB
JavaScript

import jwt from 'jsonwebtoken';
function envFlag(name, fallback) {
const value = process.env[name];
if (value === undefined)
return fallback;
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
}
function trimTrailingSlash(value) {
return value.replace(/\/+$/, '');
}
export function labAuthConfig() {
const gnommowebUrl = trimTrailingSlash(process.env.GNOMMOWEB_URL ?? 'https://glitch.university');
return {
enabled: envFlag('LAB_AUTH_ENABLED', process.env.NODE_ENV === 'production'),
gnommowebUrl,
jwtSecret: process.env.JWT_SECRET ?? '',
keycardMeritSlug: process.env.LAB_KEYCARD_MERIT_SLUG ?? 'lab-keycard',
keycardRequired: envFlag('LAB_KEYCARD_REQUIRED', true),
signInUrl: process.env.LAB_SIGN_IN_URL ?? `${gnommowebUrl}/auth/google`,
temporarySecretKey: process.env.LAB_TEMP_SECRET_KEY ?? 'supersecret',
userProfileUrl: process.env.LAB_USER_PROFILE_URL ?? `${gnommowebUrl}/api/user/profile`
};
}
function wantsHtml(request) {
const accept = request.get('accept') ?? '';
return accept.includes('text/html') || accept.includes('*/*');
}
function currentUrl(request) {
const proto = request.get('x-forwarded-proto') ?? request.protocol;
const host = request.get('x-forwarded-host') ?? request.get('host') ?? 'localhost';
return `${proto}://${host}${request.originalUrl}`;
}
function loginUrl(request, config) {
const returnTo = currentUrl(request);
if (config.signInUrl.includes('{returnTo}')) {
return config.signInUrl.replace('{returnTo}', encodeURIComponent(returnTo));
}
const url = new URL(config.signInUrl);
url.searchParams.set('returnTo', returnTo);
return url.toString();
}
function temporaryCookieOptions(request) {
const secure = request.secure || request.get('x-forwarded-proto') === 'https';
return {
httpOnly: true,
maxAge: 1000 * 60 * 60 * 12,
sameSite: 'lax',
secure
};
}
function hasTemporaryAccess(request, config) {
return Boolean(config.temporarySecretKey && request.cookies?.lab_temp_key === config.temporarySecretKey);
}
function isLocalLoginPath(request) {
return request.path === '/lab-login' || request.path === '/admin/lab-login';
}
function localLoginPage(config, error = '') {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CA Lab Login</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #008080;
color: #000;
font-family: "MS Sans Serif", Tahoma, Arial, sans-serif;
}
main {
width: min(560px, calc(100vw - 32px));
border: 2px solid;
border-color: #fff #404040 #404040 #fff;
background: #c0c0c0;
box-shadow: 4px 4px 0 rgb(0 0 0 / 0.35);
}
header {
padding: 6px 8px;
background: linear-gradient(90deg, #000080, #1084d0);
color: #fff;
font-weight: 700;
}
section { padding: 18px; }
h1 { margin: 0 0 10px; font-size: 1.2rem; }
p { line-height: 1.45; }
label { display: block; margin-top: 12px; font-weight: 700; }
input {
width: 100%;
min-height: 34px;
margin-top: 6px;
border: 2px solid;
border-color: #404040 #fff #fff #404040;
background: #fff;
color: #000;
padding: 6px 8px;
box-sizing: border-box;
font: inherit;
}
button, a {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
margin-top: 14px;
padding: 0 14px;
border: 2px solid;
border-color: #fff #404040 #404040 #fff;
background: #c0c0c0;
color: #000;
text-decoration: none;
font: inherit;
cursor: pointer;
}
.error { color: #800000; font-weight: 700; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; }
</style>
</head>
<body>
<main>
<header>CA Lab Computer</header>
<section>
<h1>Insert lab keycard</h1>
<p>Sign in with your Glitch University JWT, or use the temporary lab key while access is being repaired.</p>
${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
<form method="post" action="/admin/lab-login">
<label>
JWT token
<input name="jwt_token" autocomplete="off" spellcheck="false">
</label>
<label>
Temporary secret key
<input name="secret_key" type="password" autocomplete="current-password">
</label>
<div class="actions">
<button type="submit">Unlock lab</button>
<a href="${escapeHtml(config.signInUrl)}">Glitch University sign in</a>
</div>
</form>
</section>
</main>
</body>
</html>`;
}
function lockedPage(access, config) {
const unlockUrl = access.unlockUrl ?? `${config.gnommowebUrl}/tech-tree`;
const reason = access.reason ?? 'Your Glitch University account does not have the CA Lab Keycard yet.';
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CA Lab Locked</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #008080;
color: #000;
font-family: "MS Sans Serif", Tahoma, Arial, sans-serif;
}
main {
width: min(520px, calc(100vw - 32px));
border: 2px solid;
border-color: #fff #404040 #404040 #fff;
background: #c0c0c0;
box-shadow: 4px 4px 0 rgb(0 0 0 / 0.35);
}
header {
padding: 6px 8px;
background: linear-gradient(90deg, #000080, #1084d0);
color: #fff;
font-weight: 700;
}
section { padding: 18px; }
h1 { margin: 0 0 10px; font-size: 1.2rem; }
p { line-height: 1.45; }
a {
display: inline-flex;
align-items: center;
min-height: 34px;
margin-top: 10px;
padding: 0 14px;
border: 2px solid;
border-color: #fff #404040 #404040 #fff;
background: #c0c0c0;
color: #000;
text-decoration: none;
}
</style>
</head>
<body>
<main>
<header>CA Lab Computer</header>
<section>
<h1>Lab keycard required</h1>
<p>${escapeHtml(reason)}</p>
<p>Earn the Cellular Automata lab merit on glitch.university, then return here.</p>
<a href="${escapeHtml(unlockUrl)}">Go to Glitch University</a>
<form method="post" action="/admin/lab-login">
<label>
Temporary secret key
<input name="secret_key" type="password" autocomplete="current-password">
</label>
<button type="submit">Unlock temporarily</button>
</form>
</section>
</main>
</body>
</html>`;
}
function escapeHtml(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function isMeritSlug(value, requiredSlug) {
return typeof value === 'string' && value.trim() === requiredSlug;
}
function collectionHasMeritSlug(value, requiredSlug) {
if (!Array.isArray(value))
return false;
return value.some((entry) => {
if (isMeritSlug(entry, requiredSlug))
return true;
if (!entry || typeof entry !== 'object')
return false;
const record = entry;
return isMeritSlug(record.slug, requiredSlug);
});
}
function payloadHasMeritSlug(payload, requiredSlug) {
if (!payload || typeof payload !== 'object')
return false;
const record = payload;
const user = record.user && typeof record.user === 'object'
? record.user
: undefined;
return (isMeritSlug(record.merit_slug, requiredSlug) ||
collectionHasMeritSlug(record.merit_slugs, requiredSlug) ||
collectionHasMeritSlug(record.merits, requiredSlug) ||
(user ? collectionHasMeritSlug(user.merits, requiredSlug) : false));
}
function jwtClaimsHaveKeycard(user, config) {
if (!user)
return false;
return isMeritSlug(user.merit_slug, config.keycardMeritSlug) ||
collectionHasMeritSlug(user.merit_slugs, config.keycardMeritSlug);
}
async function checkUserProfileForKeycard(token, config) {
const response = await fetch(config.userProfileUrl, {
headers: {
accept: 'application/json',
cookie: `auth_token=${encodeURIComponent(token)}`
}
});
if (!response.ok) {
return {
allowed: false,
reason: response.status === 401
? 'Sign in to glitch.university to access the CA lab.'
: `The CA Lab Keycard could not be verified. The user profile endpoint returned HTTP ${response.status}.`
};
}
const profile = await response.json();
if (payloadHasMeritSlug(profile, config.keycardMeritSlug)) {
return { allowed: true, reason: 'CA Lab Keycard found in gnommoweb user profile.' };
}
return {
allowed: false,
reason: 'Your Glitch University account does not have the CA Lab Keycard yet.'
};
}
export function requireLabAccess(config = labAuthConfig()) {
return async (request, response, next) => {
if (!config.enabled) {
next();
return;
}
if (request.method === 'GET' && isLocalLoginPath(request)) {
response.type('html').send(localLoginPage(config));
return;
}
if (request.method === 'POST' && isLocalLoginPath(request)) {
const jwtToken = typeof request.body?.jwt_token === 'string' ? request.body.jwt_token.trim() : '';
const secretKey = typeof request.body?.secret_key === 'string' ? request.body.secret_key.trim() : '';
if (config.temporarySecretKey && secretKey === config.temporarySecretKey) {
response.cookie('lab_temp_key', config.temporarySecretKey, temporaryCookieOptions(request));
response.redirect('/admin');
return;
}
if (jwtToken) {
response.cookie('auth_token', jwtToken, temporaryCookieOptions(request));
response.redirect('/admin');
return;
}
response.status(401).type('html').send(localLoginPage(config, 'Enter a JWT token or the temporary secret key.'));
return;
}
if (hasTemporaryAccess(request, config)) {
request.labAccess = { allowed: true, reason: 'Temporary CA Lab secret key accepted.' };
next();
return;
}
if (!config.jwtSecret) {
response.status(500).json({ error: 'Lab authentication is enabled but JWT_SECRET is not configured' });
return;
}
const token = request.cookies?.auth_token;
if (!token) {
if (wantsHtml(request)) {
response.status(401).type('html').send(localLoginPage(config));
return;
}
response.status(401).json({
error: 'Authentication required',
signInUrl: loginUrl(request, config)
});
return;
}
try {
request.labUser = jwt.verify(token, config.jwtSecret);
}
catch {
if (wantsHtml(request)) {
response.redirect(loginUrl(request, config));
return;
}
response.status(401).json({
error: 'Invalid or expired token',
signInUrl: loginUrl(request, config)
});
return;
}
if (!config.keycardRequired) {
next();
return;
}
if (jwtClaimsHaveKeycard(request.labUser, config)) {
request.labAccess = { allowed: true, reason: 'CA Lab Keycard found in JWT claims.' };
next();
return;
}
let access;
try {
access = await checkUserProfileForKeycard(token, config);
}
catch {
access = { allowed: false, reason: 'The CA Lab Keycard service is unavailable.' };
}
request.labAccess = access;
if (access.allowed === true) {
next();
return;
}
if (wantsHtml(request)) {
response.status(403).type('html').send(lockedPage(access, config));
return;
}
response.status(403).json({
error: 'CA Lab Keycard required',
reason: access.reason,
unlockUrl: access.unlockUrl ?? `${config.gnommowebUrl}/tech-tree`
});
};
}
//# sourceMappingURL=labAuth.js.map