From fc2b136c46b6602586b8797c983484feeefcb5ef Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 5 Aug 2026 11:12:36 +0200 Subject: [PATCH] Store Google avatar bytes and slim the session JWT to id only. Download profile images on login into picturebinary, return them as base64 from /auth/me, and resolve sessions via getById. --- docs/api/openapi.json | 6 ++--- docs/api/routes/auth/login.md | 5 +++- docs/api/routes/auth/me.md | 5 +++- src/api/middleware/auth.js | 36 ++++++++++++++++---------- src/api/openapi.js | 2 +- src/api/routes/auth/login.js | 35 ++++++++++++++++++++----- src/api/routes/auth/logout.js | 5 ++-- src/api/routes/auth/me.js | 30 +++++++++++++++++---- src/api/routes/auth/selectedCompany.js | 3 +-- src/api/routes/company.js | 7 +++-- src/db/googleAuth.js | 9 +++++-- src/util/pictureBinary.js | 31 ++++++++++++++++++++++ 12 files changed, 133 insertions(+), 41 deletions(-) create mode 100644 src/util/pictureBinary.js diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 4620839..e6bdb05 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -4221,7 +4221,7 @@ "Auth" ], "summary": "Current user", - "description": "Returns the authenticated user from the session cookie and the Google client ID. Unauthenticated calls still receive `googleClientId` with 401.", + "description": "Returns the authenticated user from the session cookie and the Google client ID.\n`user.picture` is the stored profile image as a base64 string when present.\nUnauthenticated calls still receive `googleClientId` with 401.\n", "security": [ { "cookieAuth": [] @@ -4303,7 +4303,7 @@ "Auth" ], "summary": "Login with Google ID token", - "description": "Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`), and sets an HTTP-only `token` cookie.", + "description": "Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`),\nstores Google `sub` and profile image bytes, and sets an HTTP-only `token` cookie.\n`user.picture` is the profile image as base64 (same as GET /auth/me).\n", "security": [], "x-markdown": "auth/login.md", "requestBody": { @@ -4434,7 +4434,7 @@ }, "picture": { "type": "string", - "format": "uri" + "description": "Profile image as base64 (from stored picturebinary); omitted when unset" } } }, diff --git a/docs/api/routes/auth/login.md b/docs/api/routes/auth/login.md index df5d12d..4b88b2d 100644 --- a/docs/api/routes/auth/login.md +++ b/docs/api/routes/auth/login.md @@ -4,7 +4,10 @@ Public. No cookie required. Login with Google ID token -Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`), and sets an HTTP-only `token` cookie. +Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`), +stores Google `sub` and profile image bytes, and sets an HTTP-only `token` cookie. +`user.picture` is the profile image as base64 (same as GET /auth/me). + ## Body (JSON) diff --git a/docs/api/routes/auth/me.md b/docs/api/routes/auth/me.md index a104563..e0d808e 100644 --- a/docs/api/routes/auth/me.md +++ b/docs/api/routes/auth/me.md @@ -4,7 +4,10 @@ Protected. Requires a valid session cookie (`credentials: 'include'`). Current user -Returns the authenticated user from the session cookie and the Google client ID. Unauthenticated calls still receive `googleClientId` with 401. +Returns the authenticated user from the session cookie and the Google client ID. +`user.picture` is the stored profile image as a base64 string when present. +Unauthenticated calls still receive `googleClientId` with 401. + ## Response `200` diff --git a/src/api/middleware/auth.js b/src/api/middleware/auth.js index fd9d405..f1df9e0 100644 --- a/src/api/middleware/auth.js +++ b/src/api/middleware/auth.js @@ -16,17 +16,17 @@ export function clearTokenCookie(res) { res.clearCookie('token', TOKEN_COOKIE); } -export function getCachedUser(email) { - const cached = userCache.get(email); +export function getCachedUser(authId) { + const cached = userCache.get(authId); if (cached && cached.expiresAt > Date.now()) { return cached.user; } - userCache.delete(email); + userCache.delete(authId); return null; } -export function setCachedUser(email, user) { - userCache.set(email, { user, expiresAt: Date.now() + USER_CACHE_TTL }); +export function setCachedUser(authId, user) { + userCache.set(authId, { user, expiresAt: Date.now() + USER_CACHE_TTL }); if (userCache.size > 1000) { const now = Date.now(); for (const [k, v] of userCache) { @@ -35,8 +35,8 @@ export function setCachedUser(email, user) { } } -export function clearCachedUser(email) { - userCache.delete(email); +export function clearCachedUser(authId) { + userCache.delete(authId); } function authMeJson(res, req, status, body) { @@ -60,14 +60,24 @@ export const authMiddleware = async (req, res, next) => { try { const decoded = jwt.verify(token, env.jwtSecret); + const authId = Number(decoded.id); + if (!Number.isInteger(authId) || authId <= 0) { + clearTokenCookie(res); + return authMeJson(res, req, 401, { error: 'Invalid token' }); + } - let user = getCachedUser(decoded.email); + let user = getCachedUser(authId); if (!user) { - user = await db.getAllowedEmail(decoded.email); - if (user) setCachedUser(decoded.email, user); + const row = await db.getById(authId); + if (row) { + // Do not cache pictureBinary — large and only needed for /auth/me. + const { pictureBinary: _pictureBinary, ...rest } = row; + user = rest; + setCachedUser(authId, user); + } } if (!user) { - if (decoded.email) clearCachedUser(decoded.email); + clearCachedUser(authId); clearTokenCookie(res); return authMeJson(res, req, 403, { error: 'User is no longer authorized' }); } @@ -76,13 +86,11 @@ export const authMiddleware = async (req, res, next) => { id: user.id, email: user.email, selectedCompanyId: user.selectedCompanyId, - ...(user.picture && { picture: user.picture }), }; const now = Math.floor(Date.now() / 1000); if (decoded.exp - now < 2 * 60 * 60) { - const { iat, exp, ...payload } = decoded; - const newToken = jwt.sign(payload, env.jwtSecret, { expiresIn: '1d' }); + const newToken = jwt.sign({ id: user.id }, env.jwtSecret, { expiresIn: '1d' }); res.cookie('token', newToken, { ...TOKEN_COOKIE, maxAge: 24 * 60 * 60 * 1000, diff --git a/src/api/openapi.js b/src/api/openapi.js index fad562a..45cf55c 100644 --- a/src/api/openapi.js +++ b/src/api/openapi.js @@ -31,7 +31,7 @@ * nullable: true * picture: * type: string - * format: uri + * description: Profile image as base64 (from stored picturebinary); omitted when unset * Company: * type: object * properties: diff --git a/src/api/routes/auth/login.js b/src/api/routes/auth/login.js index e488efe..6f46743 100644 --- a/src/api/routes/auth/login.js +++ b/src/api/routes/auth/login.js @@ -3,6 +3,7 @@ import { OAuth2Client } from 'google-auth-library'; import env from '../../../config/env.js'; import * as db from '../../../db/googleAuth.js'; import { clearCachedUser } from '../../middleware/auth.js'; +import { downloadPicture, toPictureBuffer } from '../../../util/pictureBinary.js'; const googleClient = new OAuth2Client(env.googleClientId); @@ -18,7 +19,10 @@ export function register(app, basePath = '') { * post: * tags: [Auth] * summary: Login with Google ID token - * description: Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`), and sets an HTTP-only `token` cookie. + * description: | + * Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`), + * stores Google `sub` and profile image bytes, and sets an HTTP-only `token` cookie. + * `user.picture` is the profile image as base64 (same as GET /auth/me). * security: [] * x-markdown: auth/login.md * requestBody: @@ -109,19 +113,38 @@ export function register(app, basePath = '') { return res.status(403).json({ error: 'Email is not authorized' }); } + const pictureUrl = typeof payload.picture === 'string' ? payload.picture : null; + const sub = typeof payload.sub === 'string' ? payload.sub : null; + const existingBinary = toPictureBuffer(allowed.pictureBinary); + let pictureBinary = null; + + if (pictureUrl) { + if (pictureUrl === allowed.picture && existingBinary) { + pictureBinary = existingBinary; + } else { + try { + pictureBinary = await downloadPicture(pictureUrl); + } catch (err) { + console.warn(`Profile picture download failed (${email}):`, err.message); + pictureBinary = pictureUrl === allowed.picture ? existingBinary : null; + } + } + } + await db.setProfile(allowed.id, { - picture: payload.picture ?? null, - sub: payload.sub ?? null, + picture: pictureUrl, + pictureBinary, + sub, }); - clearCachedUser(email); + clearCachedUser(allowed.id); const user = { id: allowed.id, email: allowed.email, selectedCompanyId: allowed.selectedCompanyId, - ...(payload.picture && { picture: payload.picture }), + ...(pictureBinary && { picture: pictureBinary.toString('base64') }), }; - const token = jwt.sign(user, env.jwtSecret, { expiresIn: '1d' }); + const token = jwt.sign({ id: user.id }, env.jwtSecret, { expiresIn: '1d' }); res.cookie('token', token, { httpOnly: true, diff --git a/src/api/routes/auth/logout.js b/src/api/routes/auth/logout.js index 5934fc9..4dcb085 100644 --- a/src/api/routes/auth/logout.js +++ b/src/api/routes/auth/logout.js @@ -34,8 +34,9 @@ export function register(app, basePath = '') { if (token) { try { const decoded = jwt.verify(token, env.jwtSecret, { ignoreExpiration: true }); - if (decoded?.email) { - clearCachedUser(decoded.email); + const authId = Number(decoded?.id); + if (Number.isInteger(authId) && authId > 0) { + clearCachedUser(authId); } } catch { // Ignore errors during cache clearing on logout diff --git a/src/api/routes/auth/me.js b/src/api/routes/auth/me.js index 525a634..f227b17 100644 --- a/src/api/routes/auth/me.js +++ b/src/api/routes/auth/me.js @@ -1,4 +1,6 @@ import { meJson } from '../../auth/meJson.js'; +import * as db from '../../../db/googleAuth.js'; +import { toPictureBuffer } from '../../../util/pictureBinary.js'; /** * Registers the current-user route (requires prior auth middleware populating `req.user`). @@ -12,7 +14,10 @@ export function register(app, basePath = '') { * get: * tags: [Auth] * summary: Current user - * description: Returns the authenticated user from the session cookie and the Google client ID. Unauthenticated calls still receive `googleClientId` with 401. + * description: | + * Returns the authenticated user from the session cookie and the Google client ID. + * `user.picture` is the stored profile image as a base64 string when present. + * Unauthenticated calls still receive `googleClientId` with 401. * security: * - cookieAuth: [] * x-markdown: auth/me.md @@ -40,9 +45,24 @@ export function register(app, basePath = '') { * googleClientId: * type: string */ - app.get(`${basePath}/auth/me`, (req, res) => { - const { id, email, selectedCompanyId, picture } = req.user; - const user = { id, email, selectedCompanyId, ...(picture && { picture }) }; - meJson(res, 200, { user }); + app.get(`${basePath}/auth/me`, async (req, res) => { + try { + const row = await db.getById(req.user.id); + if (!row) { + return meJson(res, 403, { error: 'User is no longer authorized' }); + } + + const pictureBinary = toPictureBuffer(row.pictureBinary); + const user = { + id: row.id, + email: row.email, + selectedCompanyId: row.selectedCompanyId, + ...(pictureBinary && { picture: pictureBinary.toString('base64') }), + }; + meJson(res, 200, { user }); + } catch (error) { + console.error('Get current user failed:', error); + meJson(res, 500, { error: 'Failed to load user' }); + } }); } diff --git a/src/api/routes/auth/selectedCompany.js b/src/api/routes/auth/selectedCompany.js index d6a0cad..0a3b9be 100644 --- a/src/api/routes/auth/selectedCompany.js +++ b/src/api/routes/auth/selectedCompany.js @@ -67,14 +67,13 @@ export function register(app, basePath = '') { try { await db.setSelectedCompany(req.user.id, companyId); - clearCachedUser(req.user.email); + clearCachedUser(req.user.id); const updated = await db.getAllowedEmail(req.user.email); const user = { id: updated.id, email: updated.email, selectedCompanyId: updated.selectedCompanyId, - ...(req.user.picture && { picture: req.user.picture }), }; res.status(200).json({ user }); diff --git a/src/api/routes/company.js b/src/api/routes/company.js index ffeaafa..dc9ccd2 100644 --- a/src/api/routes/company.js +++ b/src/api/routes/company.js @@ -2,12 +2,11 @@ import * as db from '../../db/company.js'; import * as googleAuth from '../../db/googleAuth.js'; import { clearCachedUser } from '../middleware/auth.js'; -function userPayload(req, authRow) { +function userPayload(authRow) { return { id: authRow.id, email: authRow.email, selectedCompanyId: authRow.selectedCompanyId, - ...(req.user.picture && { picture: req.user.picture }), }; } @@ -151,9 +150,9 @@ export function register(app, basePath = '') { try { const company = await db.createCompany(req.user.id, body); await googleAuth.setSelectedCompany(req.user.id, company.id); - clearCachedUser(req.user.email); + clearCachedUser(req.user.id); const authRow = await googleAuth.getAllowedEmail(req.user.email); - res.status(201).json({ company, user: userPayload(req, authRow) }); + res.status(201).json({ company, user: userPayload(authRow) }); } catch (error) { const status = companyErrorStatus(error); if (status === 500) console.error('Create company failed:', error); diff --git a/src/db/googleAuth.js b/src/db/googleAuth.js index cc9221b..7db86c0 100644 --- a/src/db/googleAuth.js +++ b/src/db/googleAuth.js @@ -11,6 +11,11 @@ export async function getAllowedEmail(email) { return (await store.google_auth.getByEmail(normalized)) ?? null; } +export async function getById(authId) { + const store = await getStore(); + return (await store.google_auth.getById(authId)) ?? null; +} + export async function addAllowedEmail(email) { const normalized = email.trim().toLowerCase(); const store = await getStore(); @@ -32,7 +37,7 @@ export async function setSelectedCompany(authId, companyId) { await store.google_auth.setSelectedCompany(authId, companyId); } -export async function setProfile(authId, { picture = null, sub = null } = {}) { +export async function setProfile(authId, { picture = null, pictureBinary = null, sub = null } = {}) { const store = await getStore(); - await store.google_auth.setProfile(authId, { picture, sub }); + await store.google_auth.setProfile(authId, { picture, pictureBinary, sub }); } diff --git a/src/util/pictureBinary.js b/src/util/pictureBinary.js new file mode 100644 index 0000000..961c477 --- /dev/null +++ b/src/util/pictureBinary.js @@ -0,0 +1,31 @@ +/** + * Normalize mssql / Node binary values to a non-empty Buffer, or null. + * @param {unknown} value + * @returns {Buffer | null} + */ +export function toPictureBuffer(value) { + if (value == null) return null; + if (Buffer.isBuffer(value)) return value.length ? value : null; + if (value instanceof Uint8Array) return value.length ? Buffer.from(value) : null; + return null; +} + +/** + * Download a Google profile image URL to a Buffer. + * @param {string} url + * @returns {Promise} + */ +export async function downloadPicture(url) { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`profile picture download failed: ${res.status}`); + } + const buf = Buffer.from(await res.arrayBuffer()); + if (!buf.length) { + throw new Error('profile picture download failed: empty body'); + } + if (buf.length > 1_000_000) { + throw new Error('profile picture too large'); + } + return buf; +}