133 lines
3.2 KiB
JavaScript
133 lines
3.2 KiB
JavaScript
const crypto = require('crypto')
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
const jwt = require('jsonwebtoken')
|
|
const axios = require('axios')
|
|
|
|
const JWKS_URLS = [
|
|
'https://www.googleapis.com/oauth2/v3/certs',
|
|
'https://www.googleapis.com/oauth2/v1/certs',
|
|
]
|
|
|
|
const CACHE_FILE = path.join(__dirname, 'googleJwksCache.json')
|
|
const CACHE_TTL_MS = 6 * 60 * 60 * 1000
|
|
|
|
let memoryCache = { keys: [], fetchedAt: 0 }
|
|
|
|
function jwkToPem(jwk) {
|
|
return crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({
|
|
type: 'spki',
|
|
format: 'pem',
|
|
})
|
|
}
|
|
|
|
function readDiskCache() {
|
|
try {
|
|
if (!fs.existsSync(CACHE_FILE)) return null
|
|
const parsed = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'))
|
|
if (!Array.isArray(parsed?.keys) || parsed.keys.length === 0) return null
|
|
return parsed
|
|
} catch (err) {
|
|
console.warn('[googleTokenVerify] cache read failed:', err.message)
|
|
return null
|
|
}
|
|
}
|
|
|
|
function writeDiskCache(keys) {
|
|
try {
|
|
const payload = { fetchedAt: Date.now(), keys }
|
|
fs.writeFileSync(CACHE_FILE, JSON.stringify(payload, null, 2))
|
|
memoryCache = payload
|
|
} catch (err) {
|
|
console.warn('[googleTokenVerify] cache write failed:', err.message)
|
|
}
|
|
}
|
|
|
|
async function fetchJwksFromGoogle() {
|
|
for (const url of JWKS_URLS) {
|
|
try {
|
|
const res = await axios.get(url, { timeout: 12000 })
|
|
if (Array.isArray(res.data?.keys) && res.data.keys.length > 0) {
|
|
return res.data.keys
|
|
}
|
|
} catch (err) {
|
|
console.warn('[googleTokenVerify] JWKS fetch failed:', url, err.message)
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
async function getGoogleJwksKeys(forceRefresh = false) {
|
|
if (
|
|
!forceRefresh &&
|
|
memoryCache.keys.length > 0 &&
|
|
Date.now() - memoryCache.fetchedAt < CACHE_TTL_MS
|
|
) {
|
|
return memoryCache.keys
|
|
}
|
|
|
|
const liveKeys = await fetchJwksFromGoogle()
|
|
if (liveKeys?.length) {
|
|
writeDiskCache(liveKeys)
|
|
return liveKeys
|
|
}
|
|
|
|
const disk = readDiskCache()
|
|
if (disk?.keys?.length) {
|
|
memoryCache = disk
|
|
return disk.keys
|
|
}
|
|
|
|
throw new Error(
|
|
'Google JWKS unavailable (server cannot reach googleapis.com). Cached keys missing.'
|
|
)
|
|
}
|
|
|
|
function verifyWithJwk(idToken, jwk, audiences) {
|
|
const pem = jwkToPem(jwk)
|
|
return jwt.verify(idToken, pem, {
|
|
algorithms: ['RS256'],
|
|
audience: audiences,
|
|
issuer: ['https://accounts.google.com', 'accounts.google.com'],
|
|
})
|
|
}
|
|
|
|
async function verifyGoogleIdToken(idToken, audiences) {
|
|
if (!idToken || typeof idToken !== 'string') {
|
|
throw new Error('Invalid token')
|
|
}
|
|
|
|
const allowedAudiences = (
|
|
Array.isArray(audiences) ? audiences : [audiences]
|
|
).filter(Boolean)
|
|
|
|
if (allowedAudiences.length === 0) {
|
|
throw new Error('Google client ID is not configured')
|
|
}
|
|
|
|
const decoded = jwt.decode(idToken, { complete: true })
|
|
const kid = decoded?.header?.kid
|
|
if (!kid) {
|
|
throw new Error('Invalid token header')
|
|
}
|
|
|
|
let keys = await getGoogleJwksKeys(false)
|
|
let jwk = keys.find((key) => key.kid === kid)
|
|
|
|
if (!jwk) {
|
|
keys = await getGoogleJwksKeys(true)
|
|
jwk = keys.find((key) => key.kid === kid)
|
|
}
|
|
|
|
if (!jwk) {
|
|
throw new Error('Signing key not found for Google token')
|
|
}
|
|
|
|
return verifyWithJwk(idToken, jwk, allowedAudiences)
|
|
}
|
|
|
|
module.exports = {
|
|
verifyGoogleIdToken,
|
|
getGoogleJwksKeys,
|
|
}
|