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:
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
31
src/util/pictureBinary.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user