Add Authentik OIDC auth (issue #10) + client portal /my-bookings (issue #13)

- api/auth.js: zero-dep OIDC authorization-code flow with PKCE against
  Authentik; HMAC-signed HttpOnly session cookies (SESSION_SECRET)
- requireAdmin now accepts an OIDC session in the admin group; legacy
  ADMIN_SECRET bearer kept for n8n and scripts
- New client endpoints: GET /api/my-bookings, owner-gated contract download
- Bookings created while signed in are bound to the client's OIDC sub
- Dashboard: "Sign in with SSO" alongside passphrase fallback
- New /my-bookings portal page (nginx route + themed page)
- Fix booking modal: content area now scrolls; nav no longer overlaps the
  calendar on short viewports (was swallowing clicks on date cells)
- 10 new Playwright tests; suite green at 67

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 00:24:57 -06:00
parent bfe374206c
commit 84fdfdfdb8
9 changed files with 647 additions and 21 deletions
+4
View File
@@ -19,8 +19,12 @@ CONTRACT_SIG_Y=700
CONTRACT_SIG_PAGE=1
# OIDC / Authentik (issue #10)
# Issuer is the Authentik provider URL, e.g. https://auth.jerodrigged.com/application/o/lisilou/
# Redirect URI must be registered on the provider, e.g. https://lisilou.jerodrigged.com/api/auth/callback
SESSION_SECRET=
OIDC_ISSUER=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=
# Authentik group whose members get admin access (default: lisilou-admin)
OIDC_ADMIN_GROUP=lisilou-admin
+238
View File
@@ -0,0 +1,238 @@
/**
* Authentik OIDC authentication (issue #10).
*
* Authorization-code flow with PKCE for a confidential client, implemented with
* Node.js built-ins only (fetch + crypto) — same zero-dependency approach as
* google-calendar.js. Sessions are stateless HMAC-signed cookies.
*
* Env vars (all required for OIDC to activate; otherwise routes return 503 and
* the legacy ADMIN_SECRET bearer check in server.js keeps working):
* OIDC_ISSUER — e.g. "https://auth.jerodrigged.com/application/o/lisilou/"
* OIDC_CLIENT_ID
* OIDC_CLIENT_SECRET
* OIDC_REDIRECT_URI — e.g. "https://lisilou.jerodrigged.com/api/auth/callback"
* SESSION_SECRET — HMAC key for session cookies (any long random string)
* OIDC_ADMIN_GROUP — optional, Authentik group that grants admin (default "lisilou-admin")
*
* Authentik setup (one-time, in the Authentik admin UI):
* 1. Create an OAuth2/OpenID Provider (confidential client, redirect URI above).
* 2. Create an Application "LisiLou Portfolio" bound to that provider.
* 3. Create a group (default name "lisilou-admin") and add the photographer.
* 4. Copy client ID/secret into api/.env on the server.
*/
'use strict';
const crypto = require('crypto');
const express = require('express');
const SESSION_COOKIE = 'lisilou_sess';
const TXN_COOKIE = 'lisilou_oidc_txn';
const SESSION_TTL_S = 8 * 60 * 60; // 8 hours
const TXN_TTL_S = 10 * 60; // 10 minutes to complete the login round-trip
let _discoveryCache = null; // { config, fetchedAt }
function configured() {
return Boolean(
process.env.OIDC_ISSUER &&
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_REDIRECT_URI &&
process.env.SESSION_SECRET
);
}
// ── Cookie signing ────────────────────────────────────────────────────────────
function b64url(buf) {
return Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function b64urlDecode(str) {
return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
}
function sign(payloadObj) {
const payload = b64url(JSON.stringify(payloadObj));
const mac = crypto.createHmac('sha256', process.env.SESSION_SECRET).update(payload).digest();
return `${payload}.${b64url(mac)}`;
}
function verify(token) {
if (!token || !process.env.SESSION_SECRET) return null;
const dot = token.lastIndexOf('.');
if (dot < 1) return null;
const payload = token.slice(0, dot);
const mac = crypto.createHmac('sha256', process.env.SESSION_SECRET).update(payload).digest();
const expected = b64url(mac);
const given = token.slice(dot + 1);
if (expected.length !== given.length ||
!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))) return null;
try {
const obj = JSON.parse(b64urlDecode(payload));
if (!obj.exp || obj.exp < Math.floor(Date.now() / 1000)) return null;
return obj;
} catch {
return null;
}
}
function parseCookies(req) {
const out = {};
const header = req.headers.cookie;
if (!header) return out;
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq > 0) out[part.slice(0, eq).trim()] = decodeURIComponent(part.slice(eq + 1).trim());
}
return out;
}
function isSecureDeployment() {
return (process.env.OIDC_REDIRECT_URI || '').startsWith('https://');
}
function cookieAttrs(maxAgeS) {
const secure = isSecureDeployment() ? '; Secure' : '';
return `; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeS}${secure}`;
}
function setCookie(res, name, value, maxAgeS) {
const prev = res.getHeader('Set-Cookie');
const cookie = `${name}=${encodeURIComponent(value)}${cookieAttrs(maxAgeS)}`;
res.setHeader('Set-Cookie', prev ? [].concat(prev, cookie) : cookie);
}
function clearCookie(res, name) {
setCookie(res, name, '', 0);
}
// ── Session access (used by server.js middleware) ─────────────────────────────
function getSession(req) {
return verify(parseCookies(req)[SESSION_COOKIE]);
}
// ── OIDC provider discovery ───────────────────────────────────────────────────
async function discover() {
if (_discoveryCache && Date.now() - _discoveryCache.fetchedAt < 60 * 60 * 1000) {
return _discoveryCache.config;
}
const issuer = process.env.OIDC_ISSUER.replace(/\/$/, '');
const res = await fetch(`${issuer}/.well-known/openid-configuration`);
if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status}`);
const config = await res.json();
_discoveryCache = { config, fetchedAt: Date.now() };
return config;
}
// ── Routes ────────────────────────────────────────────────────────────────────
const router = express.Router();
// Begin login. ?redirect=/dashboard controls where the user lands afterwards.
router.get('/api/auth/login', async (req, res) => {
if (!configured()) return res.status(503).json({ error: 'SSO not configured' });
try {
const config = await discover();
const state = b64url(crypto.randomBytes(24));
const verifier = b64url(crypto.randomBytes(48));
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
// Only allow same-site relative redirect targets
const redirect = (req.query.redirect || '/').startsWith('/') && !String(req.query.redirect || '/').startsWith('//')
? (req.query.redirect || '/') : '/';
setCookie(res, TXN_COOKIE, sign({
state, verifier, redirect,
exp: Math.floor(Date.now() / 1000) + TXN_TTL_S,
}), TXN_TTL_S);
const url = new URL(config.authorization_endpoint);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', process.env.OIDC_CLIENT_ID);
url.searchParams.set('redirect_uri', process.env.OIDC_REDIRECT_URI);
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
res.redirect(url.toString());
} catch (err) {
console.error('[auth] login failed:', err.message);
res.status(502).json({ error: 'SSO provider unavailable' });
}
});
router.get('/api/auth/callback', async (req, res) => {
if (!configured()) return res.status(503).json({ error: 'SSO not configured' });
const txn = verify(parseCookies(req)[TXN_COOKIE]);
clearCookie(res, TXN_COOKIE);
if (!txn || !req.query.code || req.query.state !== txn.state) {
return res.status(400).send('Login session expired or invalid. <a href="/api/auth/login">Try again</a>.');
}
try {
const config = await discover();
const tokenRes = await fetch(config.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: req.query.code,
redirect_uri: process.env.OIDC_REDIRECT_URI,
client_id: process.env.OIDC_CLIENT_ID,
client_secret: process.env.OIDC_CLIENT_SECRET,
code_verifier: txn.verifier,
}),
});
if (!tokenRes.ok) {
const body = await tokenRes.text();
throw new Error(`token exchange failed: ${tokenRes.status} ${body.slice(0, 200)}`);
}
const tokens = await tokenRes.json();
// Claims come from the userinfo endpoint over TLS directly from the issuer,
// so a local JWT signature check is not required for this trust model.
const uiRes = await fetch(config.userinfo_endpoint, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
if (!uiRes.ok) throw new Error(`userinfo failed: ${uiRes.status}`);
const claims = await uiRes.json();
const adminGroup = process.env.OIDC_ADMIN_GROUP || 'lisilou-admin';
const groups = Array.isArray(claims.groups) ? claims.groups : [];
setCookie(res, SESSION_COOKIE, sign({
sub: claims.sub,
email: claims.email || null,
name: claims.name || claims.preferred_username || null,
admin: groups.includes(adminGroup),
exp: Math.floor(Date.now() / 1000) + SESSION_TTL_S,
}), SESSION_TTL_S);
res.redirect(txn.redirect || '/');
} catch (err) {
console.error('[auth] callback failed:', err.message);
res.status(502).send('Login failed. <a href="/api/auth/login">Try again</a>.');
}
});
router.post('/api/auth/logout', (req, res) => {
clearCookie(res, SESSION_COOKIE);
res.json({ ok: true });
});
// Session probe for the frontend
router.get('/api/auth/me', (req, res) => {
const session = getSession(req);
if (!session) return res.json({ authenticated: false, ssoConfigured: configured() });
res.json({
authenticated: true,
ssoConfigured: true,
sub: session.sub,
email: session.email,
name: session.name,
admin: Boolean(session.admin),
});
});
module.exports = { router, getSession, configured };
+66 -8
View File
@@ -5,6 +5,7 @@ const fs = require('fs');
const path = require('path');
const db = require('./db');
const { getBusyDates } = require('./google-calendar');
const auth = require('./auth');
const app = express();
const PORT = process.env.PORT || 3001;
@@ -32,14 +33,33 @@ function notify(event, booking, extra = {}) {
}).catch(e => console.error(`[notify] ${event} failed:`, e.message));
}
// ── Admin auth ────────────────────────────────────────────────────────────────
// TODO (#10): swap this bearer-token check for Authentik OIDC session validation
// when issue #10 lands. The middleware signature stays the same; only the check changes.
// ── Auth (issue #10) ──────────────────────────────────────────────────────────
// Admin access is granted by either:
// 1. an Authentik OIDC session whose user is in the admin group (see auth.js), or
// 2. the legacy ADMIN_SECRET bearer token — kept for n8n workflows and tests.
app.use(auth.router);
function requireAdmin(req, res, next) {
const session = auth.getSession(req);
if (session && session.admin) {
req.session = session;
return next();
}
const secret = process.env.ADMIN_SECRET;
if (!secret) return res.status(503).json({ error: 'Admin access not configured (set ADMIN_SECRET)' });
const auth = req.headers.authorization || '';
if (auth !== `Bearer ${secret}`) return res.status(401).json({ error: 'Unauthorized' });
const header = req.headers.authorization || '';
if (secret && header === `Bearer ${secret}`) return next();
if (session) return res.status(403).json({ error: 'Admin access required' });
if (!secret && !auth.configured()) {
return res.status(503).json({ error: 'Admin access not configured (set ADMIN_SECRET or OIDC_* vars)' });
}
return res.status(401).json({ error: 'Unauthorized' });
}
// Any signed-in user (client portal)
function requireUser(req, res, next) {
const session = auth.getSession(req);
if (!session) return res.status(401).json({ error: 'Sign in required' });
req.session = session;
next();
}
@@ -106,6 +126,11 @@ app.post('/api/bookings', (req, res) => {
return res.status(400).json({ error: 'Missing required fields' });
}
// If the client is signed in, bind the booking to their OIDC identity so it
// shows up in the /my-bookings portal regardless of what email they typed.
const session = auth.getSession(req);
const sub = session ? session.sub : client_sub;
const ps = payment_status || 'pending';
// Record when the client indicated they sent payment
const paymentNotifiedAt = ps === 'pending_confirmation' ? new Date().toISOString() : null;
@@ -116,7 +141,7 @@ app.post('/api/bookings', (req, res) => {
session_length, location, payment_status, payment_notified_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
client_name, client_email, client_phone, client_sub,
client_name, client_email, client_phone, sub,
session_date, session_type, session_length, location,
ps, paymentNotifiedAt,
);
@@ -210,7 +235,40 @@ app.post('/api/bookings/:id/sign', async (req, res) => {
res.json({ ok: true, signed_at: now, pdf_path: pdfPath });
});
// ── Admin routes (require ADMIN_SECRET bearer token) ──────────────────────────
// ── Client portal (issue #13) ─────────────────────────────────────────────────
// Bookings belonging to the signed-in client, matched by OIDC subject or email.
app.get('/api/my-bookings', requireUser, (req, res) => {
const { sub, email } = req.session;
const bookings = db.prepare(`
SELECT id, created_at, session_date, session_type, session_length, location,
contract_signed_at, payment_status, status
FROM bookings
WHERE (client_sub = ? AND client_sub IS NOT NULL)
OR (client_email = ? AND client_email IS NOT NULL)
ORDER BY session_date DESC
`).all(sub, email || '');
res.json(bookings);
});
// Signed contract download for the booking's owner (admin route also exists)
app.get('/api/my-bookings/:id/contract', requireUser, (req, res) => {
const id = parseInt(req.params.id, 10);
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id);
if (!booking) return res.status(404).json({ error: 'Not found' });
const { sub, email } = req.session;
const owns = (booking.client_sub && booking.client_sub === sub) ||
(booking.client_email && email && booking.client_email === email);
if (!owns) return res.status(403).json({ error: 'Not your booking' });
if (!booking.contract_pdf_path) return res.status(404).json({ error: 'No signed contract' });
const pdfPath = path.join(__dirname, booking.contract_pdf_path);
if (!fs.existsSync(pdfPath)) return res.status(404).json({ error: 'Contract file missing on disk' });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="contract-booking-${id}.pdf"`);
res.sendFile(pdfPath);
});
// ── Admin routes (OIDC admin session or ADMIN_SECRET bearer token) ────────────
// Stats: counts + next upcoming bookings for the Overview panel
app.get('/api/admin/stats', requireAdmin, (req, res) => {