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.
This commit is contained in:
seb
2026-08-05 11:12:36 +02:00
parent c78237a1dd
commit fc2b136c46
12 changed files with 133 additions and 41 deletions

View File

@@ -4221,7 +4221,7 @@
"Auth" "Auth"
], ],
"summary": "Current user", "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": [ "security": [
{ {
"cookieAuth": [] "cookieAuth": []
@@ -4303,7 +4303,7 @@
"Auth" "Auth"
], ],
"summary": "Login with Google ID token", "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": [], "security": [],
"x-markdown": "auth/login.md", "x-markdown": "auth/login.md",
"requestBody": { "requestBody": {
@@ -4434,7 +4434,7 @@
}, },
"picture": { "picture": {
"type": "string", "type": "string",
"format": "uri" "description": "Profile image as base64 (from stored picturebinary); omitted when unset"
} }
} }
}, },

View File

@@ -4,7 +4,10 @@ Public. No cookie required.
Login with Google ID token 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) ## Body (JSON)

View File

@@ -4,7 +4,10 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Current user 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` ## Response `200`

View File

@@ -16,17 +16,17 @@ export function clearTokenCookie(res) {
res.clearCookie('token', TOKEN_COOKIE); res.clearCookie('token', TOKEN_COOKIE);
} }
export function getCachedUser(email) { export function getCachedUser(authId) {
const cached = userCache.get(email); const cached = userCache.get(authId);
if (cached && cached.expiresAt > Date.now()) { if (cached && cached.expiresAt > Date.now()) {
return cached.user; return cached.user;
} }
userCache.delete(email); userCache.delete(authId);
return null; return null;
} }
export function setCachedUser(email, user) { export function setCachedUser(authId, user) {
userCache.set(email, { user, expiresAt: Date.now() + USER_CACHE_TTL }); userCache.set(authId, { user, expiresAt: Date.now() + USER_CACHE_TTL });
if (userCache.size > 1000) { if (userCache.size > 1000) {
const now = Date.now(); const now = Date.now();
for (const [k, v] of userCache) { for (const [k, v] of userCache) {
@@ -35,8 +35,8 @@ export function setCachedUser(email, user) {
} }
} }
export function clearCachedUser(email) { export function clearCachedUser(authId) {
userCache.delete(email); userCache.delete(authId);
} }
function authMeJson(res, req, status, body) { function authMeJson(res, req, status, body) {
@@ -60,14 +60,24 @@ export const authMiddleware = async (req, res, next) => {
try { try {
const decoded = jwt.verify(token, env.jwtSecret); 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) { if (!user) {
user = await db.getAllowedEmail(decoded.email); const row = await db.getById(authId);
if (user) setCachedUser(decoded.email, user); 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 (!user) {
if (decoded.email) clearCachedUser(decoded.email); clearCachedUser(authId);
clearTokenCookie(res); clearTokenCookie(res);
return authMeJson(res, req, 403, { error: 'User is no longer authorized' }); 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, id: user.id,
email: user.email, email: user.email,
selectedCompanyId: user.selectedCompanyId, selectedCompanyId: user.selectedCompanyId,
...(user.picture && { picture: user.picture }),
}; };
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
if (decoded.exp - now < 2 * 60 * 60) { if (decoded.exp - now < 2 * 60 * 60) {
const { iat, exp, ...payload } = decoded; const newToken = jwt.sign({ id: user.id }, env.jwtSecret, { expiresIn: '1d' });
const newToken = jwt.sign(payload, env.jwtSecret, { expiresIn: '1d' });
res.cookie('token', newToken, { res.cookie('token', newToken, {
...TOKEN_COOKIE, ...TOKEN_COOKIE,
maxAge: 24 * 60 * 60 * 1000, maxAge: 24 * 60 * 60 * 1000,

View File

@@ -31,7 +31,7 @@
* nullable: true * nullable: true
* picture: * picture:
* type: string * type: string
* format: uri * description: Profile image as base64 (from stored picturebinary); omitted when unset
* Company: * Company:
* type: object * type: object
* properties: * properties:

View File

@@ -3,6 +3,7 @@ import { OAuth2Client } from 'google-auth-library';
import env from '../../../config/env.js'; import env from '../../../config/env.js';
import * as db from '../../../db/googleAuth.js'; import * as db from '../../../db/googleAuth.js';
import { clearCachedUser } from '../../middleware/auth.js'; import { clearCachedUser } from '../../middleware/auth.js';
import { downloadPicture, toPictureBuffer } from '../../../util/pictureBinary.js';
const googleClient = new OAuth2Client(env.googleClientId); const googleClient = new OAuth2Client(env.googleClientId);
@@ -18,7 +19,10 @@ export function register(app, basePath = '') {
* post: * post:
* tags: [Auth] * tags: [Auth]
* summary: Login with Google ID token * 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: [] * security: []
* x-markdown: auth/login.md * x-markdown: auth/login.md
* requestBody: * requestBody:
@@ -109,19 +113,38 @@ export function register(app, basePath = '') {
return res.status(403).json({ error: 'Email is not authorized' }); 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, { await db.setProfile(allowed.id, {
picture: payload.picture ?? null, picture: pictureUrl,
sub: payload.sub ?? null, pictureBinary,
sub,
}); });
clearCachedUser(email); clearCachedUser(allowed.id);
const user = { const user = {
id: allowed.id, id: allowed.id,
email: allowed.email, email: allowed.email,
selectedCompanyId: allowed.selectedCompanyId, 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, { res.cookie('token', token, {
httpOnly: true, httpOnly: true,

View File

@@ -34,8 +34,9 @@ export function register(app, basePath = '') {
if (token) { if (token) {
try { try {
const decoded = jwt.verify(token, env.jwtSecret, { ignoreExpiration: true }); const decoded = jwt.verify(token, env.jwtSecret, { ignoreExpiration: true });
if (decoded?.email) { const authId = Number(decoded?.id);
clearCachedUser(decoded.email); if (Number.isInteger(authId) && authId > 0) {
clearCachedUser(authId);
} }
} catch { } catch {
// Ignore errors during cache clearing on logout // Ignore errors during cache clearing on logout

View File

@@ -1,4 +1,6 @@
import { meJson } from '../../auth/meJson.js'; 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`). * Registers the current-user route (requires prior auth middleware populating `req.user`).
@@ -12,7 +14,10 @@ export function register(app, basePath = '') {
* get: * get:
* tags: [Auth] * tags: [Auth]
* summary: Current user * 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: * security:
* - cookieAuth: [] * - cookieAuth: []
* x-markdown: auth/me.md * x-markdown: auth/me.md
@@ -40,9 +45,24 @@ export function register(app, basePath = '') {
* googleClientId: * googleClientId:
* type: string * type: string
*/ */
app.get(`${basePath}/auth/me`, (req, res) => { app.get(`${basePath}/auth/me`, async (req, res) => {
const { id, email, selectedCompanyId, picture } = req.user; try {
const user = { id, email, selectedCompanyId, ...(picture && { picture }) }; 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 }); meJson(res, 200, { user });
} catch (error) {
console.error('Get current user failed:', error);
meJson(res, 500, { error: 'Failed to load user' });
}
}); });
} }

View File

@@ -67,14 +67,13 @@ export function register(app, basePath = '') {
try { try {
await db.setSelectedCompany(req.user.id, companyId); await db.setSelectedCompany(req.user.id, companyId);
clearCachedUser(req.user.email); clearCachedUser(req.user.id);
const updated = await db.getAllowedEmail(req.user.email); const updated = await db.getAllowedEmail(req.user.email);
const user = { const user = {
id: updated.id, id: updated.id,
email: updated.email, email: updated.email,
selectedCompanyId: updated.selectedCompanyId, selectedCompanyId: updated.selectedCompanyId,
...(req.user.picture && { picture: req.user.picture }),
}; };
res.status(200).json({ user }); res.status(200).json({ user });

View File

@@ -2,12 +2,11 @@ import * as db from '../../db/company.js';
import * as googleAuth from '../../db/googleAuth.js'; import * as googleAuth from '../../db/googleAuth.js';
import { clearCachedUser } from '../middleware/auth.js'; import { clearCachedUser } from '../middleware/auth.js';
function userPayload(req, authRow) { function userPayload(authRow) {
return { return {
id: authRow.id, id: authRow.id,
email: authRow.email, email: authRow.email,
selectedCompanyId: authRow.selectedCompanyId, selectedCompanyId: authRow.selectedCompanyId,
...(req.user.picture && { picture: req.user.picture }),
}; };
} }
@@ -151,9 +150,9 @@ export function register(app, basePath = '') {
try { try {
const company = await db.createCompany(req.user.id, body); const company = await db.createCompany(req.user.id, body);
await googleAuth.setSelectedCompany(req.user.id, company.id); await googleAuth.setSelectedCompany(req.user.id, company.id);
clearCachedUser(req.user.email); clearCachedUser(req.user.id);
const authRow = await googleAuth.getAllowedEmail(req.user.email); 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) { } catch (error) {
const status = companyErrorStatus(error); const status = companyErrorStatus(error);
if (status === 500) console.error('Create company failed:', error); if (status === 500) console.error('Create company failed:', error);

View File

@@ -11,6 +11,11 @@ export async function getAllowedEmail(email) {
return (await store.google_auth.getByEmail(normalized)) ?? null; 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) { export async function addAllowedEmail(email) {
const normalized = email.trim().toLowerCase(); const normalized = email.trim().toLowerCase();
const store = await getStore(); const store = await getStore();
@@ -32,7 +37,7 @@ export async function setSelectedCompany(authId, companyId) {
await store.google_auth.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(); const store = await getStore();
await store.google_auth.setProfile(authId, { picture, sub }); await store.google_auth.setProfile(authId, { picture, pictureBinary, sub });
} }

31
src/util/pictureBinary.js Normal file
View File

@@ -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<Buffer>}
*/
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;
}