173 lines
6.6 KiB
JavaScript
173 lines
6.6 KiB
JavaScript
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: "<google-id-token>"
|
|
* 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' });
|
|
}
|
|
});
|
|
}
|