448 lines
13 KiB
TypeScript
448 lines
13 KiB
TypeScript
import type { NextFunction, Request, RequestHandler, Response } from 'express'
|
|
import jwt from 'jsonwebtoken'
|
|
|
|
interface LabJwtClaims extends jwt.JwtPayload {
|
|
email?: string
|
|
id?: number
|
|
isAdmin?: boolean
|
|
merit_slug?: unknown
|
|
merit_slugs?: unknown
|
|
name?: string
|
|
picture?: string | null
|
|
role?: string
|
|
sub?: string
|
|
}
|
|
|
|
interface LabAccessResponse {
|
|
allowed?: boolean
|
|
reason?: string
|
|
unlockUrl?: string
|
|
}
|
|
|
|
interface LabAuthConfig {
|
|
enabled: boolean
|
|
gnommowebUrl: string
|
|
jwtSecret: string
|
|
keycardMeritSlug: string
|
|
keycardRequired: boolean
|
|
signInUrl: string
|
|
temporarySecretKey?: string
|
|
userProfileUrl: string
|
|
}
|
|
|
|
declare global {
|
|
namespace Express {
|
|
interface Request {
|
|
labUser?: LabJwtClaims
|
|
labAccess?: LabAccessResponse
|
|
}
|
|
}
|
|
}
|
|
|
|
function envFlag(name: string, fallback: boolean) {
|
|
const value = process.env[name]
|
|
if (value === undefined) return fallback
|
|
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase())
|
|
}
|
|
|
|
function trimTrailingSlash(value: string) {
|
|
return value.replace(/\/+$/, '')
|
|
}
|
|
|
|
export function labAuthConfig(): 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: Request) {
|
|
const accept = request.get('accept') ?? ''
|
|
return accept.includes('text/html') || accept.includes('*/*')
|
|
}
|
|
|
|
function currentUrl(request: 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: Request, config: LabAuthConfig) {
|
|
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: Request) {
|
|
const secure = request.secure || request.get('x-forwarded-proto') === 'https'
|
|
return {
|
|
httpOnly: true,
|
|
maxAge: 1000 * 60 * 60 * 12,
|
|
sameSite: 'lax' as const,
|
|
secure
|
|
}
|
|
}
|
|
|
|
function hasTemporaryAccess(request: Request, config: LabAuthConfig) {
|
|
return Boolean(config.temporarySecretKey && request.cookies?.lab_temp_key === config.temporarySecretKey)
|
|
}
|
|
|
|
function isLocalLoginPath(request: Request) {
|
|
return request.path === '/lab-login' || request.path === '/admin/lab-login'
|
|
}
|
|
|
|
function localLoginPage(config: LabAuthConfig, 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: LabAccessResponse, config: LabAuthConfig) {
|
|
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: string) {
|
|
return value
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''')
|
|
}
|
|
|
|
function isMeritSlug(value: unknown, requiredSlug: string) {
|
|
return typeof value === 'string' && value.trim() === requiredSlug
|
|
}
|
|
|
|
function collectionHasMeritSlug(value: unknown, requiredSlug: string): boolean {
|
|
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 as Record<string, unknown>
|
|
return isMeritSlug(record.slug, requiredSlug)
|
|
})
|
|
}
|
|
|
|
function payloadHasMeritSlug(payload: unknown, requiredSlug: string) {
|
|
if (!payload || typeof payload !== 'object') return false
|
|
const record = payload as Record<string, unknown>
|
|
const user = record.user && typeof record.user === 'object'
|
|
? record.user as Record<string, unknown>
|
|
: 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: LabJwtClaims | undefined, config: LabAuthConfig) {
|
|
if (!user) return false
|
|
return isMeritSlug(user.merit_slug, config.keycardMeritSlug) ||
|
|
collectionHasMeritSlug(user.merit_slugs, config.keycardMeritSlug)
|
|
}
|
|
|
|
async function checkUserProfileForKeycard(token: string, config: LabAuthConfig): Promise<LabAccessResponse> {
|
|
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 = labAuthConfig()): RequestHandler {
|
|
return async (request: Request, response: Response, next: NextFunction) => {
|
|
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) as LabJwtClaims
|
|
} 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: LabAccessResponse
|
|
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`
|
|
})
|
|
}
|
|
}
|