import jwt from 'jsonwebtoken'; import { OAuth2Client } from 'google-auth-library'; import env from '../../../config/env.js'; import * as db from '../../../db/googleAuth.js'; import * as autoinviteDb from '../../../db/autoinvite.js'; import * as companyDb from '../../../db/company.js'; import { clearCachedUser } from '../../middleware/auth.js'; import { downloadPicture, toPictureBuffer } from '../../../util/pictureBinary.js'; const googleClient = new OAuth2Client(env.googleClientId); /** * Registers the login route (Google SSO → JWT cookie session). * @param {import('express').Application} app - Express app * @param {string} [basePath=''] - Optional URL prefix for all routes in this module */ export function register(app, basePath = '') { /** * @openapi * /auth/login: * post: * tags: [Auth] * summary: Login with Google ID token * description: | * Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true` * or when the email has a pending auto-invitation), * 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: * required: true * content: * application/json: * schema: * type: object * required: [idToken] * properties: * idToken: * type: string * description: Google ID token from Google Identity Services * example: * idToken: "" * responses: * 200: * description: Login successful; `token` cookie set * content: * application/json: * schema: * type: object * properties: * message: * type: string * example: Login successful * user: * $ref: '#/components/schemas/User' * 400: * description: Missing token or Google client not configured * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 401: * description: Invalid Google token or account * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 403: * description: Email not on allowlist or invited * content: * application/json: * schema: * $ref: '#/components/schemas/Error' * 500: * description: Server error * content: * application/json: * schema: * $ref: '#/components/schemas/Error' */ app.post(`${basePath}/auth/login`, async (req, res) => { try { const { idToken } = req.body; if (!idToken) { return res.status(400).json({ error: 'idToken is required' }); } if (!env.googleClientId) { return res.status(400).json({ error: 'GOOGLE_CLIENT_ID is not configured' }); } let ticket; try { ticket = await googleClient.verifyIdToken({ idToken, audience: env.googleClientId, }); } catch { return res.status(401).json({ error: 'Invalid Google token' }); } const payload = ticket.getPayload(); const email = payload?.email?.toLowerCase(); if (!email || !payload.email_verified) { return res.status(401).json({ error: 'Invalid Google account' }); } let allowed = await db.getAllowedEmail(email); if (!allowed && (env.implicitCreate || (await autoinviteDb.hasPendingInvite(email)))) { await db.addAllowedEmail(email); allowed = await db.getAllowedEmail(email); if (allowed && allowed.selectedCompanyId == null) { const companies = await companyDb.listCompanies(allowed.id); if (companies.length > 0) { await db.setSelectedCompany(allowed.id, companies[0].id); allowed = await db.getAllowedEmail(email); } } } if (!allowed) { console.warn(`Login rejected: email not authorized (${email})`); 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: pictureUrl, pictureBinary, sub, }); clearCachedUser(allowed.id); const user = { id: allowed.id, email: allowed.email, selectedCompanyId: allowed.selectedCompanyId, ...(pictureBinary && { picture: pictureBinary.toString('base64') }), }; const token = jwt.sign({ id: user.id }, env.jwtSecret, { expiresIn: '1d' }); res.cookie('token', token, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 24 * 60 * 60 * 1000, }); res.status(200).json({ message: 'Login successful', user }); } catch (error) { console.error('Login failed:', error); res.status(500).json({ error: 'Login failed' }); } }); }