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"
],
"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"
}
}
},

View File

@@ -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)

View File

@@ -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`

View File

@@ -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,

View File

@@ -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:

View File

@@ -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,

View File

@@ -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

View File

@@ -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' });
}
});
}

View File

@@ -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 });

View File

@@ -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);

View File

@@ -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 });
}

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;
}