Files
starr-philproperties/apps/api/utils/google_oidc.util.js
T

95 lines
4.5 KiB
JavaScript

/***********************************************************************************************************************************************************************
* File Name: google_oidc.util.js
* Type of Program: Utility
* Description: Manual Google OIDC primitives — no Passport.
* Provides state/nonce generation, PKCE (S256), authorization URL construction,
* authorization code exchange, and ID token verification via google-auth-library.
*
* OIDC flow summary:
* 1. googleRedirect → generateState + generateNonce + generatePKCE → buildAuthUrl → redirect
* 2. googleCallback → verify state cookie → exchangeCode → verifyIdToken → create/find user
*
* Security properties:
* - state : anti-CSRF; verified against signed httpOnly cookie
* - nonce : anti-replay; embedded in ID token by Google and checked here
* - PKCE S256 : prevents auth code interception even if code leaks
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 21, 2026
***********************************************************************************************************************************************************************/
'use strict';
const crypto = require('crypto');
const { OAuth2Client } = require('google-auth-library');
const axios = require('axios');
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
// Lazily initialised so the module can be required before env is loaded.
let _client;
const getClient = () => {
if (!_client) _client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
return _client;
};
// ── Generators ─────────────────────────────────────────────────────────────────
const generateState = () => crypto.randomBytes(32).toString('hex');
const generateNonce = () => crypto.randomBytes(32).toString('hex');
const generatePKCE = () => {
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
return { codeVerifier, codeChallenge };
};
// ── Auth URL ───────────────────────────────────────────────────────────────────
const buildAuthUrl = (state, nonce, codeChallenge) => {
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
redirect_uri: process.env.GOOGLE_CALLBACK_URL,
response_type: 'code',
scope: 'openid email profile',
state,
nonce,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
access_type: 'offline',
prompt: 'select_account',
});
return `${GOOGLE_AUTH_URL}?${params.toString()}`;
};
// ── Code exchange ──────────────────────────────────────────────────────────────
const exchangeCode = async (code, codeVerifier) => {
const { data } = await axios.post(
GOOGLE_TOKEN_URL,
new URLSearchParams({
code,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
redirect_uri: process.env.GOOGLE_CALLBACK_URL,
grant_type: 'authorization_code',
code_verifier: codeVerifier,
}).toString(),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
);
return data; // { access_token, id_token, expires_in, token_type, ... }
};
// ── ID token verification ──────────────────────────────────────────────────────
const verifyIdToken = async (idToken, expectedNonce) => {
const ticket = await getClient().verifyIdToken({
idToken,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
// Verify nonce to prevent token replay attacks.
if (payload.nonce !== expectedNonce) throw new Error('Nonce mismatch');
return payload; // { sub, email, given_name, family_name, name, picture, ... }
};
module.exports = { generateState, generateNonce, generatePKCE, buildAuthUrl, exchangeCode, verifyIdToken };