'use strict'; const crypto = require('crypto'); const database = require('../config/database'); const { getResolvedUserWithPageAccess } = require('./pageAccess'); const { SUPPORTED_SCOPES, TOKEN_ISSUER, ThirdPartySsoError, authenticateClient, buildUserInfo, createSsoClient, hashValue, normalizeRedirectUri, rotateSsoClientSecret, updateSsoClient, } = require('./thirdPartySsoService'); const DIRECT_LOGIN_FLOW = 'third_party_sso_direct'; const DIRECT_LOGIN_SESSION_TTL_SECONDS = Math.min( Math.max(Number.parseInt(process.env.THIRD_PARTY_SSO_DIRECT_TTL_SECONDS || '300', 10), 120), 900 ); const DIRECT_CLIENT_REDIRECT_URI = `${TOKEN_ISSUER}/direct/complete`; function normalizeDirectReturnUrl(value) { const input = String(value || '').trim(); if (!input || input.length > 1024) { throw new ThirdPartySsoError('return_url 无效', 400, 'invalid_request'); } let parsed; try { parsed = new URL(input); } catch (_error) { throw new ThirdPartySsoError('return_url 格式无效', 400, 'invalid_request'); } if (parsed.username || parsed.password || parsed.hash) { throw new ThirdPartySsoError('return_url 不能包含用户信息或锚点', 400, 'invalid_request'); } // Hosted login only returns an opaque request id. Exact allowlisting keeps // legacy HTTP partner sites usable without weakening the standard OAuth flow. if (parsed.protocol === 'http:') return parsed.toString(); return normalizeRedirectUri(input); } function normalizeDirectReturnUrls(value) { const rawUrls = Array.isArray(value) ? value : (value ? [value] : []); const returnUrls = [...new Set(rawUrls.map(normalizeDirectReturnUrl))] .filter((url) => url !== DIRECT_CLIENT_REDIRECT_URI); if (returnUrls.length > 19) { throw new ThirdPartySsoError('最多配置 19 个第三方回跳地址', 400, 'invalid_request'); } return returnUrls; } function getDirectReturnUrls(client) { return (client.redirectUris || []).filter((url) => url !== DIRECT_CLIENT_REDIRECT_URI); } function validateDirectReturnUrl(value, client) { if (!value) return ''; const returnUrl = normalizeDirectReturnUrl(value); if (!getDirectReturnUrls(client).includes(returnUrl)) { throw new ThirdPartySsoError('return_url 未在该应用白名单中', 400, 'invalid_request'); } return returnUrl; } function buildDirectCompletionRedirect(returnUrl, requestId) { if (!returnUrl) return ''; const redirect = new URL(returnUrl); redirect.searchParams.set('sso_request_id', requestId); redirect.searchParams.set('sso_status', 'completed'); return redirect.toString(); } function normalizeDirectScopes(value, allowedScopes = ['profile']) { const requested = Array.isArray(value) ? value : String(value || 'profile').split(/\s+/u).filter(Boolean); const scopes = [...new Set(requested.map((scope) => String(scope).trim()).filter(Boolean))]; const unsupported = scopes.filter((scope) => !SUPPORTED_SCOPES.includes(scope)); if (unsupported.length) { throw new ThirdPartySsoError(`不支持的 scope: ${unsupported.join(', ')}`, 400, 'invalid_scope'); } const disallowed = scopes.filter((scope) => !allowedScopes.includes(scope)); if (disallowed.length) { throw new ThirdPartySsoError(`应用未授权 scope: ${disallowed.join(', ')}`, 400, 'invalid_scope'); } return scopes.length ? scopes : ['profile']; } function formatDirectApiKey(clientId, clientSecret) { return `ddak_${clientId}.${clientSecret}`; } function parseDirectApiKey(value) { const match = String(value || '').trim().match( /^ddak_(sso_[A-Za-z0-9_-]{20,76})\.([A-Za-z0-9_-]{40,100})$/u ); if (!match) { throw new ThirdPartySsoError('API Key 无效', 401, 'invalid_client'); } return { clientId: match[1], clientSecret: match[2] }; } function normalizeRequestId(value) { const requestId = String(value || '').trim(); if (!/^dls_[A-Za-z0-9_-]{40,100}$/u.test(requestId)) { throw new ThirdPartySsoError('登录任务无效', 400, 'invalid_request'); } return requestId; } function serializeDirectSession(row) { if (!row) return null; return { sessionHash: row.session_hash || row.sessionHash, clientId: row.client_id || row.clientId, userId: row.user_id || row.userId || null, scope: row.scope || 'profile', status: row.status || 'pending', returnUrl: row.return_url || row.returnUrl || '', expiresAt: row.expires_at || row.expiresAt, authorizedAt: row.authorized_at || row.authorizedAt || null, consumedAt: row.consumed_at || row.consumedAt || null, }; } const defaultStore = { async insertDirectSession(record) { await database.local.insert( `INSERT INTO third_party_sso_direct_sessions (session_hash, client_id, scope, return_url, status, expires_at) VALUES (?, ?, ?, ?, 'pending', ?)`, [record.sessionHash, record.clientId, record.scope, record.returnUrl || null, new Date(record.expiresAt)] ); }, async findDirectSession(sessionHash, clientId = '') { const clientClause = clientId ? ' AND client_id = ?' : ''; const params = clientId ? [sessionHash, clientId] : [sessionHash]; const rows = await database.local.query( `SELECT session_hash AS sessionHash, client_id AS clientId, user_id AS userId, scope, return_url AS returnUrl, status, expires_at AS expiresAt, authorized_at AS authorizedAt, consumed_at AS consumedAt FROM third_party_sso_direct_sessions WHERE session_hash = ?${clientClause} LIMIT 1`, params ); return serializeDirectSession(rows[0]); }, async completeDirectSession(sessionHash, userId) { return database.local.update( `UPDATE third_party_sso_direct_sessions SET status = 'completed', user_id = ?, authorized_at = NOW() WHERE session_hash = ? AND status = 'pending' AND expires_at > NOW()`, [userId, sessionHash] ); }, async consumeDirectSession(sessionHash, clientId) { return database.local.update( `UPDATE third_party_sso_direct_sessions SET status = 'consumed', consumed_at = NOW() WHERE session_hash = ? AND client_id = ? AND status = 'completed'`, [sessionHash, clientId] ); }, async cleanupExpiredDirectSessions() { await database.local.query( `DELETE FROM third_party_sso_direct_sessions WHERE expires_at < DATE_SUB(NOW(), INTERVAL 1 DAY)` ); }, }; async function authenticateDirectApiKey(apiKey, options = {}) { const credentials = parseDirectApiKey(apiKey); return authenticateClient(credentials, options); } async function createDirectLoginSession(input = {}, apiKey = '', options = {}) { const client = await authenticateDirectApiKey(apiKey, options); const scopes = normalizeDirectScopes(input.scope || input.scopes, client.allowedScopes); const returnUrl = validateDirectReturnUrl(input.returnUrl || input.return_url, client); const requestId = `dls_${crypto.randomBytes(32).toString('base64url')}`; const expiresAt = Date.now() + DIRECT_LOGIN_SESSION_TTL_SECONDS * 1000; const store = options.directStore || defaultStore; await store.insertDirectSession({ sessionHash: hashValue(requestId), clientId: client.clientId, scope: scopes.join(' '), returnUrl, expiresAt, }); void store.cleanupExpiredDirectSessions?.().catch(() => {}); return { request_id: requestId, status: 'pending', login_url: `${TOKEN_ISSUER}/direct/login?request_id=${encodeURIComponent(requestId)}`, return_url: returnUrl || undefined, expires_in: DIRECT_LOGIN_SESSION_TTL_SECONDS, }; } async function beginDirectLogin(requestId, options = {}) { const normalized = normalizeRequestId(requestId); const store = options.directStore || defaultStore; const session = await store.findDirectSession(hashValue(normalized)); if (!session || session.status !== 'pending' || new Date(session.expiresAt).getTime() <= Date.now()) { throw new ThirdPartySsoError('登录任务不存在或已过期', 410, 'invalid_request'); } return { session, stateClaims: { flow: DIRECT_LOGIN_FLOW, directRequestId: normalized, }, }; } async function completeDirectLogin({ stateClaims, userId }, options = {}) { if (!stateClaims || stateClaims.flow !== DIRECT_LOGIN_FLOW) { throw new ThirdPartySsoError('登录任务无效或已过期', 400, 'invalid_request'); } const requestId = normalizeRequestId(stateClaims.directRequestId); const store = options.directStore || defaultStore; const session = await store.findDirectSession(hashValue(requestId)); if (!session || session.status !== 'pending' || new Date(session.expiresAt).getTime() <= Date.now()) { throw new ThirdPartySsoError('登录任务已完成、过期或不存在', 410, 'invalid_request'); } const updated = await store.completeDirectSession(hashValue(requestId), Number(userId)); if (Number(updated) !== 1) { throw new ThirdPartySsoError('登录任务已完成、过期或不存在', 410, 'invalid_request'); } return { requestId, completionRedirect: buildDirectCompletionRedirect(session.returnUrl, requestId), }; } async function pollDirectLoginSession(requestId, apiKey = '', options = {}) { const normalized = normalizeRequestId(requestId); const client = await authenticateDirectApiKey(apiKey, options); const store = options.directStore || defaultStore; const session = await store.findDirectSession(hashValue(normalized), client.clientId); if (!session) { throw new ThirdPartySsoError('登录任务不存在', 404, 'invalid_request'); } if (new Date(session.expiresAt).getTime() <= Date.now() && session.status === 'pending') { return { request_id: normalized, status: 'expired' }; } if (session.status !== 'completed') { return { request_id: normalized, status: session.status }; } const resolveUser = options.resolveUser || getResolvedUserWithPageAccess; const user = await resolveUser(session.userId); if (!user || (user.status && user.status !== 'active')) { throw new ThirdPartySsoError('用户不存在或已停用', 403, 'access_denied'); } const consumed = await store.consumeDirectSession(hashValue(normalized), client.clientId); if (Number(consumed) !== 1) { return { request_id: normalized, status: 'consumed' }; } return { request_id: normalized, status: 'completed', user: buildUserInfo({ user, scopes: normalizeDirectScopes(session.scope, client.allowedScopes) }), }; } async function createDirectApiKey(input = {}, actor = {}, options = {}) { const allowedReturnUrls = normalizeDirectReturnUrls( input.allowedReturnUrls || input.returnUrls || input.redirectUris ); const created = await createSsoClient({ clientName: input.clientName || input.name, clientType: 'confidential', redirectUris: [DIRECT_CLIENT_REDIRECT_URI, ...allowedReturnUrls], allowedScopes: input.allowedScopes || ['profile', 'email', 'department'], }, actor, { ...options, redirectUriNormalizer: normalizeDirectReturnUrl }); return { clientId: created.clientId, clientName: created.clientName, allowedScopes: created.allowedScopes, allowedReturnUrls, status: created.status, apiKey: formatDirectApiKey(created.clientId, created.clientSecret), }; } async function rotateDirectApiKey(clientId, actor = {}, options = {}) { const rotated = await rotateSsoClientSecret(clientId, actor, options); return { clientId: rotated.clientId, clientName: rotated.clientName, allowedScopes: rotated.allowedScopes, allowedReturnUrls: getDirectReturnUrls(rotated), status: rotated.status, apiKey: formatDirectApiKey(rotated.clientId, rotated.clientSecret), }; } async function updateDirectReturnUrls(clientId, input = {}, actor = {}, options = {}) { const allowedReturnUrls = normalizeDirectReturnUrls( input.allowedReturnUrls || input.returnUrls || input.redirectUris ); const updated = await updateSsoClient(clientId, { redirectUris: [DIRECT_CLIENT_REDIRECT_URI, ...allowedReturnUrls], }, actor, { ...options, redirectUriNormalizer: normalizeDirectReturnUrl }); return { clientId: updated.clientId, clientName: updated.clientName, allowedReturnUrls, status: updated.status, }; } async function ensureThirdPartySsoDirectSchema() { await database.local.query( `CREATE TABLE IF NOT EXISTS third_party_sso_direct_sessions ( session_hash CHAR(64) PRIMARY KEY, client_id VARCHAR(80) NOT NULL, user_id BIGINT NULL, scope VARCHAR(500) NOT NULL DEFAULT 'profile', return_url VARCHAR(1024) NULL, status ENUM('pending', 'completed', 'consumed') NOT NULL DEFAULT 'pending', expires_at DATETIME NOT NULL, authorized_at DATETIME NULL, consumed_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_third_party_sso_direct_client_status (client_id, status), KEY idx_third_party_sso_direct_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方 API Key 托管登录任务'` ); const columns = await database.local.query( `SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'third_party_sso_direct_sessions' AND column_name = 'return_url' LIMIT 1` ); if (!columns.length) { await database.local.query( `ALTER TABLE third_party_sso_direct_sessions ADD COLUMN return_url VARCHAR(1024) NULL AFTER scope` ); } } module.exports = { DIRECT_LOGIN_FLOW, DIRECT_LOGIN_SESSION_TTL_SECONDS, authenticateDirectApiKey, beginDirectLogin, completeDirectLogin, createDirectApiKey, createDirectLoginSession, ensureThirdPartySsoDirectSchema, formatDirectApiKey, parseDirectApiKey, pollDirectLoginSession, rotateDirectApiKey, updateDirectReturnUrls, };