BARON-SSO 로그인 기능 연동
This commit is contained in:
579
server.js
579
server.js
@@ -3,9 +3,25 @@ import mysql from 'mysql2/promise';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import session from 'express-session';
|
||||
import { getSigningKey, getPrivateKeyPem } from './src/utils/jwks.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const {
|
||||
CLIENT_ID,
|
||||
ISSUER,
|
||||
REDIRECT_URI,
|
||||
JWKS_URI,
|
||||
SESSION_SECRET,
|
||||
ERROR_LOCALE_PATH
|
||||
} = process.env;
|
||||
|
||||
const SESSION_SECRET_VALUE = SESSION_SECRET || 'itam-headless-session-secret';
|
||||
const DEFAULT_SCOPES = ['openid', 'profile', 'email'];
|
||||
const DEFAULT_ERROR_PATH = ERROR_LOCALE_PATH || '/ko/error';
|
||||
|
||||
const dbConfig = {
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
@@ -24,6 +40,17 @@ const getDbConnectionSummary = () => ({
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(session({
|
||||
secret: SESSION_SECRET_VALUE,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: false,
|
||||
maxAge: 1000 * 60 * 60 * 8
|
||||
}
|
||||
}));
|
||||
app.use('/uploads', express.static('uploads')); // 업로드 파일 정적 서빙
|
||||
|
||||
// uploads 폴더가 없으면 생성
|
||||
@@ -113,6 +140,401 @@ const ASSET_TABLES = [
|
||||
'asset_core'
|
||||
];
|
||||
|
||||
const ensureSsoConfig = () => {
|
||||
const missing = [];
|
||||
if (!CLIENT_ID) missing.push('CLIENT_ID');
|
||||
if (!ISSUER) missing.push('ISSUER');
|
||||
if (!REDIRECT_URI) missing.push('REDIRECT_URI');
|
||||
if (!JWKS_URI) missing.push('JWKS_URI');
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Missing SSO configuration: ${missing.join(', ')}`);
|
||||
}
|
||||
};
|
||||
|
||||
const base64Url = (input) => Buffer.from(input).toString('base64url');
|
||||
|
||||
const sha256Base64Url = (input) => crypto.createHash('sha256').update(input).digest('base64url');
|
||||
|
||||
const randomString = (size = 32) => crypto.randomBytes(size).toString('base64url');
|
||||
|
||||
const parseJsonSafely = async (response) => {
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { raw: text };
|
||||
}
|
||||
};
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const appendCookies = (currentCookies, response) => {
|
||||
const rawSetCookie = response.headers.get('set-cookie');
|
||||
if (!rawSetCookie) {
|
||||
return currentCookies;
|
||||
}
|
||||
|
||||
const cookieMap = new Map();
|
||||
(currentCookies || '').split(';').map((entry) => entry.trim()).filter(Boolean).forEach((entry) => {
|
||||
const [name, ...rest] = entry.split('=');
|
||||
cookieMap.set(name, `${name}=${rest.join('=')}`);
|
||||
});
|
||||
|
||||
rawSetCookie
|
||||
.split(/,(?=[^;]+=[^;]+)/)
|
||||
.map((entry) => entry.split(';')[0].trim())
|
||||
.filter(Boolean)
|
||||
.forEach((cookie) => {
|
||||
const [name] = cookie.split('=');
|
||||
cookieMap.set(name, cookie);
|
||||
});
|
||||
|
||||
return Array.from(cookieMap.values()).join('; ');
|
||||
};
|
||||
|
||||
const createClientAssertion = (audience) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = {
|
||||
alg: 'RS256',
|
||||
typ: 'JWT',
|
||||
kid: getSigningKey().kid
|
||||
};
|
||||
const payload = {
|
||||
iss: CLIENT_ID,
|
||||
sub: CLIENT_ID,
|
||||
aud: audience,
|
||||
jti: randomString(16),
|
||||
iat: now,
|
||||
exp: now + 300
|
||||
};
|
||||
|
||||
const encodedHeader = base64Url(JSON.stringify(header));
|
||||
const encodedPayload = base64Url(JSON.stringify(payload));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = crypto.sign('RSA-SHA256', Buffer.from(signingInput), getPrivateKeyPem()).toString('base64url');
|
||||
return `${signingInput}.${signature}`;
|
||||
};
|
||||
|
||||
const fetchDiscoveryDocument = async () => {
|
||||
ensureSsoConfig();
|
||||
const discoveryUrl = `${ISSUER.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
||||
const response = await fetch(discoveryUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`OIDC discovery failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const beginAuthorizationFlow = async () => {
|
||||
const discovery = await fetchDiscoveryDocument();
|
||||
const codeVerifier = randomString(48);
|
||||
const codeChallenge = sha256Base64Url(codeVerifier);
|
||||
const stateToken = randomString(16);
|
||||
const nonce = randomString(16);
|
||||
|
||||
const authUrl = new URL(discovery.authorization_endpoint);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('client_id', CLIENT_ID);
|
||||
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
|
||||
authUrl.searchParams.set('scope', DEFAULT_SCOPES.join(' '));
|
||||
authUrl.searchParams.set('state', stateToken);
|
||||
authUrl.searchParams.set('nonce', nonce);
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
const authRes = await fetch(authUrl.toString(), { redirect: 'manual' });
|
||||
const cookies = appendCookies('', authRes);
|
||||
const location = authRes.headers.get('location');
|
||||
if (!location) {
|
||||
throw new Error('Authorization redirect did not provide login_challenge');
|
||||
}
|
||||
|
||||
const authRedirectUrl = new URL(location, authUrl.toString());
|
||||
const loginChallenge = authRedirectUrl.searchParams.get('login_challenge');
|
||||
if (!loginChallenge) {
|
||||
throw new Error('login_challenge not found');
|
||||
}
|
||||
|
||||
return {
|
||||
discovery,
|
||||
cookies,
|
||||
loginChallenge,
|
||||
authState: {
|
||||
stateToken,
|
||||
nonce,
|
||||
codeVerifier
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const getCodeFromRedirect = (redirectTo) => {
|
||||
const url = new URL(redirectTo, ISSUER);
|
||||
return url.searchParams.get('code');
|
||||
};
|
||||
|
||||
const resolveRedirects = async (redirectTo, cookies, depth = 0) => {
|
||||
if (depth > 10) {
|
||||
throw new Error('Redirect resolution exceeded limit');
|
||||
}
|
||||
|
||||
const currentUrl = new URL(redirectTo, ISSUER).toString();
|
||||
|
||||
if (currentUrl.includes('/consent')) {
|
||||
const consentUrl = new URL(currentUrl);
|
||||
const consentChallenge = consentUrl.searchParams.get('consent_challenge');
|
||||
if (!consentChallenge) {
|
||||
throw new Error('Missing consent_challenge');
|
||||
}
|
||||
|
||||
const detailsUrl = new URL('/api/v1/auth/consent', ISSUER);
|
||||
detailsUrl.searchParams.set('consent_challenge', consentChallenge);
|
||||
const detailsRes = await fetch(detailsUrl.toString(), {
|
||||
headers: cookies ? { Cookie: cookies } : {}
|
||||
});
|
||||
|
||||
if (!detailsRes.ok) {
|
||||
const detailsBody = await parseJsonSafely(detailsRes);
|
||||
if (detailsRes.status === 403 && detailsBody?.code === 'tenant_not_allowed') {
|
||||
const errorUrl = new URL(DEFAULT_ERROR_PATH, ISSUER);
|
||||
errorUrl.searchParams.set('error', detailsBody.code);
|
||||
errorUrl.searchParams.set('error_description', detailsBody.message || 'Tenant not allowed');
|
||||
if (detailsBody.details) {
|
||||
errorUrl.searchParams.set('details', JSON.stringify(detailsBody.details));
|
||||
}
|
||||
return { finalUrl: errorUrl.toString(), cookies, code: null, isErrorRedirect: true };
|
||||
}
|
||||
throw new Error(`Consent details failed: ${detailsRes.status}`);
|
||||
}
|
||||
|
||||
const consentInfo = await detailsRes.json();
|
||||
const acceptUrl = new URL('/api/v1/auth/consent/accept', ISSUER);
|
||||
const acceptRes = await fetch(acceptUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(cookies ? { Cookie: cookies } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
consent_challenge: consentChallenge,
|
||||
grant_scope: consentInfo.requested_scope || DEFAULT_SCOPES,
|
||||
grant_access_token_audience: consentInfo.requested_access_token_audience || []
|
||||
})
|
||||
});
|
||||
|
||||
const nextCookies = appendCookies(cookies, acceptRes);
|
||||
const acceptBody = await parseJsonSafely(acceptRes);
|
||||
if (!acceptRes.ok || !acceptBody?.redirectTo) {
|
||||
throw new Error(`Consent accept failed: ${acceptRes.status}`);
|
||||
}
|
||||
return resolveRedirects(acceptBody.redirectTo, nextCookies, depth + 1);
|
||||
}
|
||||
|
||||
const response = await fetch(currentUrl, {
|
||||
redirect: 'manual',
|
||||
headers: cookies ? { Cookie: cookies } : {}
|
||||
});
|
||||
|
||||
const nextCookies = appendCookies(cookies, response);
|
||||
const location = response.headers.get('location');
|
||||
if (location) {
|
||||
const nextUrl = new URL(location, currentUrl).toString();
|
||||
if (nextUrl.startsWith(REDIRECT_URI)) {
|
||||
return {
|
||||
finalUrl: nextUrl,
|
||||
cookies: nextCookies,
|
||||
code: getCodeFromRedirect(nextUrl),
|
||||
isErrorRedirect: false
|
||||
};
|
||||
}
|
||||
return resolveRedirects(nextUrl, nextCookies, depth + 1);
|
||||
}
|
||||
|
||||
if (response.ok && currentUrl.startsWith(REDIRECT_URI)) {
|
||||
return {
|
||||
finalUrl: currentUrl,
|
||||
cookies: nextCookies,
|
||||
code: getCodeFromRedirect(currentUrl),
|
||||
isErrorRedirect: false
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Could not resolve authorization redirect');
|
||||
};
|
||||
|
||||
const exchangeAuthorizationCode = async (code, discovery) => {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
client_id: CLIENT_ID,
|
||||
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
||||
client_assertion: createClientAssertion(discovery.token_endpoint)
|
||||
});
|
||||
|
||||
const tokenRes = await fetch(discovery.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: body.toString()
|
||||
});
|
||||
|
||||
const tokenBody = await parseJsonSafely(tokenRes);
|
||||
if (!tokenRes.ok) {
|
||||
throw new Error(`Token exchange failed: ${tokenRes.status} ${JSON.stringify(tokenBody)}`);
|
||||
}
|
||||
|
||||
return tokenBody;
|
||||
};
|
||||
|
||||
const decodeJwtPayload = (jwt) => {
|
||||
const [, payload] = jwt.split('.');
|
||||
if (!payload) {
|
||||
throw new Error('Invalid JWT payload');
|
||||
}
|
||||
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||
};
|
||||
|
||||
const runHeadlessSsoLogin = async ({ loginId, password }) => {
|
||||
const { discovery, cookies, loginChallenge, authState } = await beginAuthorizationFlow();
|
||||
|
||||
const headlessEndpoint = new URL('/api/v1/auth/headless/password/login', ISSUER).toString();
|
||||
const headlessRes = await fetch(headlessEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(cookies ? { Cookie: cookies } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: CLIENT_ID,
|
||||
login_challenge: loginChallenge,
|
||||
loginId,
|
||||
password,
|
||||
client_assertion: createClientAssertion(headlessEndpoint)
|
||||
})
|
||||
});
|
||||
const nextCookies = appendCookies(cookies, headlessRes);
|
||||
const headlessBody = await parseJsonSafely(headlessRes);
|
||||
if (!headlessRes.ok || !headlessBody?.redirectTo) {
|
||||
throw new Error(`Headless login failed: ${headlessRes.status} ${JSON.stringify(headlessBody)}`);
|
||||
}
|
||||
|
||||
const resolution = await resolveRedirects(headlessBody.redirectTo, nextCookies);
|
||||
if (resolution.isErrorRedirect) {
|
||||
return { errorRedirect: resolution.finalUrl };
|
||||
}
|
||||
if (!resolution.code) {
|
||||
throw new Error('Authorization code not found after redirect resolution');
|
||||
}
|
||||
|
||||
const tokenResponse = await exchangeAuthorizationCode(resolution.code, discovery);
|
||||
const idTokenPayload = decodeJwtPayload(tokenResponse.id_token);
|
||||
|
||||
return {
|
||||
tokens: tokenResponse,
|
||||
profile: idTokenPayload,
|
||||
authState
|
||||
};
|
||||
};
|
||||
|
||||
const initHeadlessPhoneLogin = async ({ loginId }) => {
|
||||
const { discovery, cookies, loginChallenge, authState } = await beginAuthorizationFlow();
|
||||
const headlessEndpoint = new URL('/api/v1/auth/headless/link/init', ISSUER).toString();
|
||||
|
||||
const initRes = await fetch(headlessEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(cookies ? { Cookie: cookies } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: CLIENT_ID,
|
||||
login_challenge: loginChallenge,
|
||||
loginId,
|
||||
client_assertion: createClientAssertion(headlessEndpoint)
|
||||
})
|
||||
});
|
||||
|
||||
const nextCookies = appendCookies(cookies, initRes);
|
||||
const initBody = await parseJsonSafely(initRes);
|
||||
if (!initRes.ok || !initBody?.pendingRef) {
|
||||
throw new Error(`Phone link init failed: ${initRes.status} ${JSON.stringify(initBody)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
discovery,
|
||||
cookies: nextCookies,
|
||||
pendingRef: initBody.pendingRef,
|
||||
intervalMs: Math.max(2000, Number(initBody.interval || 3) * 1000),
|
||||
expiresInMs: Math.max(60000, Number(initBody.expiresIn || 180) * 1000),
|
||||
authState,
|
||||
loginId
|
||||
};
|
||||
};
|
||||
|
||||
const pollHeadlessPhoneLogin = async (pendingContext) => {
|
||||
const pollEndpoint = new URL('/api/v1/auth/headless/link/poll', ISSUER).toString();
|
||||
const pollRes = await fetch(pollEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(pendingContext.cookies ? { Cookie: pendingContext.cookies } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: CLIENT_ID,
|
||||
pendingRef: pendingContext.pendingRef,
|
||||
client_assertion: createClientAssertion(pollEndpoint)
|
||||
})
|
||||
});
|
||||
|
||||
const nextCookies = appendCookies(pendingContext.cookies, pollRes);
|
||||
const pollBody = await parseJsonSafely(pollRes);
|
||||
|
||||
if (pollRes.ok && pollBody?.redirectTo) {
|
||||
const resolution = await resolveRedirects(pollBody.redirectTo, nextCookies);
|
||||
if (resolution.isErrorRedirect) {
|
||||
return { status: 'error_redirect', redirectTo: resolution.finalUrl };
|
||||
}
|
||||
|
||||
if (!resolution.code) {
|
||||
throw new Error('Authorization code not found after phone redirect resolution');
|
||||
}
|
||||
|
||||
const tokenResponse = await exchangeAuthorizationCode(resolution.code, pendingContext.discovery);
|
||||
const idTokenPayload = decodeJwtPayload(tokenResponse.id_token);
|
||||
|
||||
return {
|
||||
status: 'authenticated',
|
||||
tokens: tokenResponse,
|
||||
profile: idTokenPayload
|
||||
};
|
||||
}
|
||||
|
||||
const statusCode = pollBody?.code || pollBody?.error;
|
||||
if (statusCode === 'authorization_pending') {
|
||||
return {
|
||||
status: 'pending',
|
||||
cookies: nextCookies,
|
||||
intervalMs: Math.max(2000, Number(pollBody?.interval || pendingContext.intervalMs / 1000 || 3) * 1000)
|
||||
};
|
||||
}
|
||||
|
||||
if (statusCode === 'slow_down') {
|
||||
return {
|
||||
status: 'pending',
|
||||
cookies: nextCookies,
|
||||
intervalMs: Math.max(pendingContext.intervalMs + 2000, Number(pollBody?.interval || 5) * 1000)
|
||||
};
|
||||
}
|
||||
|
||||
if (statusCode === 'expired_token') {
|
||||
return { status: 'expired' };
|
||||
}
|
||||
|
||||
throw new Error(`Phone poll failed: ${pollRes.status} ${JSON.stringify(pollBody)}`);
|
||||
};
|
||||
|
||||
// --- Helper Functions for Maps ---
|
||||
function getCleanMapKey(path) {
|
||||
let clean = path.replace('img/location_photo/', '').replace('.png', '');
|
||||
@@ -139,6 +561,149 @@ function getLocationDetail(path, idx) {
|
||||
|
||||
// --- API Endpoints ---
|
||||
|
||||
app.get('/api/auth/session', (req, res) => {
|
||||
res.json({
|
||||
authenticated: Boolean(req.session.user),
|
||||
user: req.session.user || null
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/auth/headless/login', async (req, res) => {
|
||||
const { loginId, password } = req.body;
|
||||
|
||||
if (!loginId || !password) {
|
||||
return res.status(400).json({ error: 'loginId and password are required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const loginResult = await runHeadlessSsoLogin({ loginId, password });
|
||||
if (loginResult.errorRedirect) {
|
||||
return res.status(403).json({ redirectTo: loginResult.errorRedirect, code: 'tenant_not_allowed' });
|
||||
}
|
||||
|
||||
req.session.user = {
|
||||
loginId,
|
||||
profile: loginResult.profile,
|
||||
tokens: {
|
||||
accessToken: loginResult.tokens.access_token,
|
||||
idToken: loginResult.tokens.id_token,
|
||||
expiresIn: loginResult.tokens.expires_in,
|
||||
scope: loginResult.tokens.scope,
|
||||
tokenType: loginResult.tokens.token_type
|
||||
}
|
||||
};
|
||||
|
||||
res.json({ success: true, user: req.session.user });
|
||||
} catch (error) {
|
||||
console.error('Headless SSO login failed:', error);
|
||||
res.status(500).json({ error: error.message || 'Headless SSO login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/headless/phone/init', async (req, res) => {
|
||||
const { loginId } = req.body;
|
||||
|
||||
if (!loginId) {
|
||||
return res.status(400).json({ error: 'loginId is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const pendingLogin = await initHeadlessPhoneLogin({ loginId });
|
||||
req.session.pendingPhoneLogin = pendingLogin;
|
||||
res.json({
|
||||
success: true,
|
||||
pendingRef: pendingLogin.pendingRef,
|
||||
intervalMs: pendingLogin.intervalMs,
|
||||
expiresInMs: pendingLogin.expiresInMs,
|
||||
message: '인증 링크를 발송했습니다. 모바일에서 승인 후 잠시만 기다려주세요.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Headless phone login init failed:', error);
|
||||
res.status(500).json({ error: error.message || 'Headless phone login init failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/headless/phone/poll', async (req, res) => {
|
||||
const pendingLogin = req.session.pendingPhoneLogin;
|
||||
const { pendingRef } = req.body || {};
|
||||
|
||||
if (!pendingLogin || !pendingLogin.pendingRef) {
|
||||
return res.status(400).json({ error: 'No pending phone login session found' });
|
||||
}
|
||||
|
||||
if (pendingRef && pendingRef !== pendingLogin.pendingRef) {
|
||||
return res.status(400).json({ error: 'Pending reference does not match current session' });
|
||||
}
|
||||
|
||||
if (pendingLogin.startedAt && Date.now() - pendingLogin.startedAt > pendingLogin.expiresInMs) {
|
||||
delete req.session.pendingPhoneLogin;
|
||||
return res.status(410).json({ code: 'expired_token', error: 'Phone login request expired' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pollHeadlessPhoneLogin(pendingLogin);
|
||||
|
||||
if (result.status === 'pending') {
|
||||
req.session.pendingPhoneLogin = {
|
||||
...pendingLogin,
|
||||
cookies: result.cookies,
|
||||
intervalMs: result.intervalMs,
|
||||
startedAt: pendingLogin.startedAt || Date.now()
|
||||
};
|
||||
return res.json({
|
||||
success: true,
|
||||
status: 'pending',
|
||||
pendingRef: pendingLogin.pendingRef,
|
||||
intervalMs: result.intervalMs
|
||||
});
|
||||
}
|
||||
|
||||
if (result.status === 'expired') {
|
||||
delete req.session.pendingPhoneLogin;
|
||||
return res.status(410).json({ code: 'expired_token', error: 'Phone login request expired' });
|
||||
}
|
||||
|
||||
if (result.status === 'error_redirect') {
|
||||
delete req.session.pendingPhoneLogin;
|
||||
return res.status(403).json({ redirectTo: result.redirectTo, code: 'tenant_not_allowed' });
|
||||
}
|
||||
|
||||
delete req.session.pendingPhoneLogin;
|
||||
req.session.user = {
|
||||
loginId: pendingLogin.loginId,
|
||||
profile: result.profile,
|
||||
tokens: {
|
||||
accessToken: result.tokens.access_token,
|
||||
idToken: result.tokens.id_token,
|
||||
expiresIn: result.tokens.expires_in,
|
||||
scope: result.tokens.scope,
|
||||
tokenType: result.tokens.token_type
|
||||
}
|
||||
};
|
||||
|
||||
return res.json({ success: true, status: 'authenticated', user: req.session.user });
|
||||
} catch (error) {
|
||||
console.error('Headless phone login poll failed:', error);
|
||||
res.status(500).json({ error: error.message || 'Headless phone login poll failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/callback', (req, res) => {
|
||||
if (req.session.user) {
|
||||
return res.redirect('/');
|
||||
}
|
||||
|
||||
const error = req.query.error || 'login_failed';
|
||||
const description = req.query.error_description || 'Authentication failed';
|
||||
return res.redirect(`/?auth_error=${encodeURIComponent(error)}&auth_error_description=${encodeURIComponent(description)}`);
|
||||
});
|
||||
|
||||
// 1. Generic Batch Save (Dynamic Table Detection)
|
||||
app.post('/api/:table/batch', async (req, res) => {
|
||||
const { table } = req.params;
|
||||
@@ -1207,3 +1772,17 @@ app.get('/ready', async (req, res) => {
|
||||
app.listen(process.env.PORT || 3000, '0.0.0.0', () => {
|
||||
console.log(`📡 ITAM BACKEND SERVER RUNNING ON PORT ${process.env.PORT || 3000} (V3 Normalized)`);
|
||||
});
|
||||
|
||||
// Ensure keys are generated on startup
|
||||
getSigningKey();
|
||||
|
||||
// JWKS Endpoint
|
||||
app.get('/.well-known/jwks.json', (req, res) => {
|
||||
try {
|
||||
const jwk = getSigningKey();
|
||||
res.json({ keys: [jwk] });
|
||||
} catch (error) {
|
||||
console.error('Error serving JWKS endpoint:', error);
|
||||
res.status(500).json({ error: 'Could not retrieve JWKS' });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user