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"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
meJson(res, 200, { user });
|
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 {
|
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 });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
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