← back to NationalPaperHangers
routes/auth-linkedin.js
182 lines
// Buyer-side LinkedIn OAuth sign-in.
//
// Parallels routes/auth-google.js — same consumer_accounts table, same
// session shape (req.session.consumer / req.session.consumerAccountId).
// Useful for trade buyers (designers, architects, hospitality FF&E) who
// live in LinkedIn more than they live in Gmail.
//
// LinkedIn supports OIDC since 2023:
// authorize: https://www.linkedin.com/oauth/v2/authorization
// token: https://www.linkedin.com/oauth/v2/accessToken
// userinfo: https://api.linkedin.com/v2/userinfo
// scopes: openid profile email
//
// The DW LinkedIn Developer App ("For Claude - 5-6-26", 4eeb836b-…) was
// verified to the Designer Wallcoverings Company Page on 2026-05-06.
const express = require('express');
const crypto = require('crypto');
const https = require('https');
const querystring = require('querystring');
const db = require('../lib/db');
const router = express.Router();
// Session-fixation defense — match claim.js + auth-google.js pattern.
function regenerateSession(req) {
return new Promise((resolve, reject) => {
req.session.regenerate(err => err ? reject(err) : resolve());
});
}
const CLIENT_ID = process.env.LINKEDIN_CLIENT_ID || '';
const CLIENT_SECRET = process.env.LINKEDIN_CLIENT_SECRET || '';
const SCOPE = 'openid profile email';
function publicBase(req) {
return process.env.PUBLIC_URL || (req.protocol + '://' + req.get('host'));
}
function callbackUrl(req) {
return publicBase(req).replace(/\/+$/, '') + '/auth/linkedin/callback';
}
function safeNext(n) {
if (!n || typeof n !== 'string') return '/';
if (!n.startsWith('/') || n.startsWith('//')) return '/';
return n.slice(0, 500);
}
function postForm(host, path, formObj) {
const body = querystring.stringify(formObj);
return new Promise((resolve, reject) => {
const req = https.request({
method: 'POST', host, path,
headers: {
'content-type': 'application/x-www-form-urlencoded',
'content-length': Buffer.byteLength(body),
'accept': 'application/json'
}
}, res => {
let chunks = '';
res.on('data', c => chunks += c);
res.on('end', () => {
try { resolve({ status: res.statusCode, json: JSON.parse(chunks) }); }
catch (e) { reject(new Error('LINKEDIN_TOKEN_BAD_JSON')); }
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
function getJson(host, path, accessToken) {
return new Promise((resolve, reject) => {
const req = https.request({
method: 'GET', host, path,
headers: { 'authorization': 'Bearer ' + accessToken, 'accept': 'application/json' }
}, res => {
let chunks = '';
res.on('data', c => chunks += c);
res.on('end', () => {
try { resolve({ status: res.statusCode, json: JSON.parse(chunks) }); }
catch (e) { reject(new Error('LINKEDIN_USERINFO_BAD_JSON')); }
});
});
req.on('error', reject);
req.end();
});
}
router.get('/auth/linkedin/start', (req, res) => {
if (!CLIENT_ID) {
return res.status(503).render('public/error', {
title: 'LinkedIn sign-in unavailable',
message: 'LinkedIn sign-in is not yet configured.',
path: req.path
});
}
const state = crypto.randomBytes(24).toString('base64url');
req.session.liOAuth = { state, next: safeNext(req.query.next) };
const params = querystring.stringify({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: callbackUrl(req),
state,
scope: SCOPE
});
res.redirect('https://www.linkedin.com/oauth/v2/authorization?' + params);
});
router.get('/auth/linkedin/callback', async (req, res, next) => {
try {
if (!CLIENT_ID || !CLIENT_SECRET) {
return res.status(503).render('public/error', {
title: 'LinkedIn sign-in unavailable', message: 'Not configured.', path: req.path
});
}
const sessionState = req.session.liOAuth && req.session.liOAuth.state;
const sessionNext = (req.session.liOAuth && req.session.liOAuth.next) || '/';
if (!sessionState || sessionState !== req.query.state) {
return res.status(400).render('public/error', { title: 'Sign-in error', message: 'Invalid state.', path: req.path });
}
if (req.query.error) {
return res.status(400).render('public/error', {
title: 'Sign-in canceled',
message: 'LinkedIn sign-in was canceled. Try again or continue without an account.',
path: req.path
});
}
const code = String(req.query.code || '');
if (!code) {
return res.status(400).render('public/error', { title: 'Sign-in error', message: 'No code returned.', path: req.path });
}
const tokenRes = await postForm('www.linkedin.com', '/oauth/v2/accessToken', {
grant_type: 'authorization_code',
code,
redirect_uri: callbackUrl(req),
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
});
if (tokenRes.status !== 200 || !tokenRes.json.access_token) {
return res.status(502).render('public/error', { title: 'Sign-in failed', message: 'Could not exchange LinkedIn code.', path: req.path });
}
const ui = await getJson('api.linkedin.com', '/v2/userinfo', tokenRes.json.access_token);
if (ui.status !== 200 || !ui.json.sub) {
return res.status(502).render('public/error', { title: 'Sign-in failed', message: 'Could not read LinkedIn profile.', path: req.path });
}
const profile = ui.json;
// We share the consumer_accounts table with Google — distinguish providers
// by prefixing the sub with "li:" so a LinkedIn sub never collides with a
// Google sub. (Google subs are decimal strings; LinkedIn subs are short
// alphanumeric. Prefixing is defensive.)
const sub = 'li:' + profile.sub;
const existing = await db.one('SELECT id FROM consumer_accounts WHERE google_sub = $1', [sub]);
let id;
if (existing) {
await db.query(
`UPDATE consumer_accounts
SET email = $2, email_verified = $3, name = $4, picture_url = $5, last_login_at = now()
WHERE id = $1`,
[existing.id, profile.email, !!profile.email_verified, profile.name || null, profile.picture || null]
);
id = existing.id;
} else {
const ins = await db.one(
`INSERT INTO consumer_accounts (google_sub, email, email_verified, name, picture_url, last_login_at)
VALUES ($1, $2, $3, $4, $5, now()) RETURNING id`,
[sub, profile.email, !!profile.email_verified, profile.name || null, profile.picture || null]
);
id = ins.id;
}
await regenerateSession(req);
req.session.consumerAccountId = id;
req.session.consumer = { id, email: profile.email, name: profile.name || null, picture_url: profile.picture || null, provider: 'linkedin' };
req.session.save(err => err ? next(err) : res.redirect(sessionNext));
} catch (err) { next(err); }
});
module.exports = router;