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 `
CA Lab Login
Insert lab keycard
Sign in with your Glitch University JWT, or use the temporary lab key while access is being repaired.
${error ? `${escapeHtml(error)}
` : ''}
`
}
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 `
CA Lab Locked
Lab keycard required
${escapeHtml(reason)}
Earn the Cellular Automata lab merit on glitch.university, then return here.
Go to Glitch University
`
}
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
return isMeritSlug(record.slug, requiredSlug)
})
}
function payloadHasMeritSlug(payload: unknown, requiredSlug: string) {
if (!payload || typeof payload !== 'object') return false
const record = payload as Record
const user = record.user && typeof record.user === 'object'
? record.user as Record
: 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 {
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`
})
}
}