Adding temporarty keycard escape
This commit is contained in:
Vendored
+1
@@ -47,6 +47,7 @@ export function createCaStudioApi(db) {
|
||||
const labAccess = requireLabAccess();
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use('/admin', labAccess);
|
||||
app.use('/view', labAccess);
|
||||
app.use('/api/ca', labAccess);
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+140
-1
@@ -17,6 +17,7 @@ export function labAuthConfig() {
|
||||
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`
|
||||
};
|
||||
}
|
||||
@@ -38,6 +39,112 @@ function loginUrl(request, config) {
|
||||
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.';
|
||||
@@ -95,6 +202,13 @@ function lockedPage(access, config) {
|
||||
<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>
|
||||
@@ -171,6 +285,31 @@ export function requireLabAccess(config = labAuthConfig()) {
|
||||
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;
|
||||
@@ -178,7 +317,7 @@ export function requireLabAccess(config = labAuthConfig()) {
|
||||
const token = request.cookies?.auth_token;
|
||||
if (!token) {
|
||||
if (wantsHtml(request)) {
|
||||
response.redirect(loginUrl(request, config));
|
||||
response.status(401).type('html').send(localLoginPage(config));
|
||||
return;
|
||||
}
|
||||
response.status(401).json({
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -89,6 +89,7 @@ export function createCaStudioApi(db: Queryable) {
|
||||
const labAccess = requireLabAccess()
|
||||
app.use(cookieParser())
|
||||
app.use(express.json({ limit: '1mb' }))
|
||||
app.use(express.urlencoded({ extended: false }))
|
||||
app.use('/admin', labAccess)
|
||||
app.use('/view', labAccess)
|
||||
app.use('/api/ca', labAccess)
|
||||
|
||||
+151
-1
@@ -26,6 +26,7 @@ interface LabAuthConfig {
|
||||
keycardMeritSlug: string
|
||||
keycardRequired: boolean
|
||||
signInUrl: string
|
||||
temporarySecretKey?: string
|
||||
userProfileUrl: string
|
||||
}
|
||||
|
||||
@@ -57,6 +58,7 @@ export function labAuthConfig(): LabAuthConfig {
|
||||
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`
|
||||
}
|
||||
}
|
||||
@@ -83,6 +85,116 @@ function loginUrl(request: Request, config: LabAuthConfig) {
|
||||
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.'
|
||||
@@ -140,6 +252,13 @@ function lockedPage(access: LabAccessResponse, config: LabAuthConfig) {
|
||||
<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>
|
||||
@@ -227,6 +346,37 @@ export function requireLabAccess(config: LabAuthConfig = labAuthConfig()): Reque
|
||||
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
|
||||
@@ -235,7 +385,7 @@ export function requireLabAccess(config: LabAuthConfig = labAuthConfig()): Reque
|
||||
const token = request.cookies?.auth_token
|
||||
if (!token) {
|
||||
if (wantsHtml(request)) {
|
||||
response.redirect(loginUrl(request, config))
|
||||
response.status(401).type('html').send(localLoginPage(config))
|
||||
return
|
||||
}
|
||||
response.status(401).json({
|
||||
|
||||
@@ -10,6 +10,7 @@ const secret = 'test-lab-secret'
|
||||
function testApp(config: Parameters<typeof requireLabAccess>[0]) {
|
||||
const app = express()
|
||||
app.use(cookieParser())
|
||||
app.use(express.urlencoded({ extended: false }))
|
||||
app.use(requireLabAccess(config))
|
||||
app.get('/admin', (_request, response) => response.send('ok'))
|
||||
app.get('/api/ca/decks', (_request, response) => response.json([{ id: 'deck' }]))
|
||||
@@ -39,7 +40,7 @@ describe('lab auth middleware', () => {
|
||||
await request(app).get('/admin').expect(200, 'ok')
|
||||
})
|
||||
|
||||
it('redirects browser requests without a shared auth cookie', async () => {
|
||||
it('shows the local lab login form for browser requests without a shared auth cookie', async () => {
|
||||
const app = testApp({
|
||||
enabled: true,
|
||||
gnommowebUrl: 'https://glitch.university',
|
||||
@@ -55,10 +56,59 @@ describe('lab auth middleware', () => {
|
||||
.set('accept', 'text/html')
|
||||
.set('host', 'lab.glitch.university')
|
||||
.set('x-forwarded-proto', 'https')
|
||||
.expect(401)
|
||||
|
||||
expect(response.text).toContain('Insert lab keycard')
|
||||
expect(response.text).toContain('Temporary secret key')
|
||||
})
|
||||
|
||||
it('allows browser requests with the temporary lab secret key', async () => {
|
||||
const app = testApp({
|
||||
enabled: true,
|
||||
gnommowebUrl: 'https://glitch.university',
|
||||
jwtSecret: '',
|
||||
keycardMeritSlug: 'lab-keycard',
|
||||
keycardRequired: true,
|
||||
signInUrl: 'https://glitch.university/auth/google',
|
||||
temporarySecretKey: 'supersecret',
|
||||
userProfileUrl: 'https://glitch.university/api/user/profile'
|
||||
})
|
||||
|
||||
const unlock = await request(app)
|
||||
.post('/admin/lab-login')
|
||||
.type('form')
|
||||
.send({ secret_key: 'supersecret' })
|
||||
.expect(302)
|
||||
|
||||
expect(response.headers.location).toContain('https://glitch.university/auth/google?returnTo=')
|
||||
expect(decodeURIComponent(response.headers.location)).toContain('https://lab.glitch.university/admin')
|
||||
const cookie = unlock.headers['set-cookie']
|
||||
expect(String(cookie)).toContain('lab_temp_key=supersecret')
|
||||
|
||||
await request(app)
|
||||
.get('/admin')
|
||||
.set('cookie', cookie)
|
||||
.expect(200, 'ok')
|
||||
})
|
||||
|
||||
it('rejects the temporary lab secret key when it is wrong', async () => {
|
||||
const app = testApp({
|
||||
enabled: true,
|
||||
gnommowebUrl: 'https://glitch.university',
|
||||
jwtSecret: secret,
|
||||
keycardMeritSlug: 'lab-keycard',
|
||||
keycardRequired: true,
|
||||
signInUrl: 'https://glitch.university/auth/google',
|
||||
temporarySecretKey: 'supersecret',
|
||||
userProfileUrl: 'https://glitch.university/api/user/profile'
|
||||
})
|
||||
|
||||
await request(app)
|
||||
.post('/admin/lab-login')
|
||||
.type('form')
|
||||
.send({ secret_key: 'not-it' })
|
||||
.expect(401)
|
||||
.expect((response) => {
|
||||
expect(response.text).toContain('Enter a JWT token or the temporary secret key')
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects API requests with an invalid shared auth cookie', async () => {
|
||||
|
||||
Reference in New Issue
Block a user