'use strict'; const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const database = require('../config/database'); const { getResolvedUserWithPageAccess } = require('./pageAccess'); const THIRD_PARTY_FLOW = 'third_party_sso'; const TOKEN_TYPE = 'third-party-sso'; const TOKEN_ISSUER = String( process.env.THIRD_PARTY_SSO_ISSUER || 'https://pddg.jiansuntuzhi.cn/api/sso' ).replace(/\/$/u, ''); const AUTHORIZATION_CODE_TTL_SECONDS = Math.min( Math.max(Number.parseInt(process.env.THIRD_PARTY_SSO_CODE_TTL_SECONDS || '60', 10), 30), 300 ); const ACCESS_TOKEN_TTL_SECONDS = Math.min( Math.max(Number.parseInt(process.env.THIRD_PARTY_SSO_ACCESS_TOKEN_TTL_SECONDS || '900', 10), 300), 3600 ); const SUPPORTED_SCOPES = Object.freeze(['profile', 'email', 'department', 'permissions']); function getThirdPartySsoCallbackBaseUrl() { try { return new URL(TOKEN_ISSUER).origin; } catch (_error) { throw new ThirdPartySsoError('统一认证服务地址配置无效', 503, 'server_error'); } } class ThirdPartySsoError extends Error { constructor(message, status = 400, code = 'invalid_request') { super(message); this.name = 'ThirdPartySsoError'; this.status = status; this.code = code; } } function hashValue(value) { return crypto.createHash('sha256').update(String(value || '')).digest('hex'); } function timingSafeEqualText(left, right) { const leftBuffer = Buffer.from(String(left || '')); const rightBuffer = Buffer.from(String(right || '')); return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); } function getSigningSecret() { const dedicatedSecret = String(process.env.THIRD_PARTY_SSO_SIGNING_SECRET || '').trim(); if (dedicatedSecret) return dedicatedSecret; const applicationSecret = String(process.env.CUBEJS_SECRET || '').trim(); if (!applicationSecret) { throw new ThirdPartySsoError('统一认证签名密钥未配置', 503, 'server_error'); } return crypto .createHmac('sha256', applicationSecret) .update('third-party-sso-signing-key-v1') .digest('hex'); } function parseJsonList(value) { if (Array.isArray(value)) return value; if (!value) return []; try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch (_error) { return String(value).split(/[\s,]+/u).filter(Boolean); } } function normalizeClientId(value) { const clientId = String(value || '').trim(); if (!/^[A-Za-z0-9._-]{8,80}$/u.test(clientId)) { throw new ThirdPartySsoError('client_id 无效', 400, 'invalid_client'); } return clientId; } function normalizeRedirectUri(value) { const input = String(value || '').trim(); if (!input || input.length > 1024) { throw new ThirdPartySsoError('redirect_uri 无效', 400, 'invalid_request'); } let parsed; try { parsed = new URL(input); } catch (_error) { throw new ThirdPartySsoError('redirect_uri 格式无效', 400, 'invalid_request'); } if (parsed.username || parsed.password || parsed.hash) { throw new ThirdPartySsoError('redirect_uri 不能包含用户信息或锚点', 400, 'invalid_request'); } const protocol = parsed.protocol.toLowerCase(); const isHttps = protocol === 'https:'; const isLoopbackHttp = protocol === 'http:' && ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname); const isCustomScheme = /^[a-z][a-z0-9+.-]*:$/u.test(protocol) && !['javascript:', 'data:', 'file:', 'http:', 'https:'].includes(protocol); if (!isHttps && !isLoopbackHttp && !isCustomScheme) { throw new ThirdPartySsoError('redirect_uri 必须使用 HTTPS、本机 HTTP 或已登记的 App Scheme', 400, 'invalid_request'); } return parsed.toString(); } function normalizeStoredRedirectUri(value) { const input = String(value || '').trim(); if (!input || input.length > 1024) { throw new ThirdPartySsoError('redirect_uri 无效', 400, 'invalid_request'); } let parsed; try { parsed = new URL(input); } catch (_error) { throw new ThirdPartySsoError('redirect_uri 格式无效', 400, 'invalid_request'); } if (parsed.username || parsed.password || parsed.hash) { throw new ThirdPartySsoError('redirect_uri 不能包含用户信息或锚点', 400, 'invalid_request'); } return parsed.toString(); } function normalizeRedirectUris(values, normalizeUri = normalizeRedirectUri) { const uris = [...new Set(parseJsonList(values).map(normalizeUri))]; if (uris.length === 0 || uris.length > 20) { throw new ThirdPartySsoError('请配置 1 至 20 个回调地址', 400, 'invalid_request'); } return uris; } function normalizeScopes(value, fallback = ['profile']) { const rawScopes = Array.isArray(value) ? value : String(value || '').split(/\s+/u).filter(Boolean); const scopes = [...new Set(rawScopes.length ? rawScopes : fallback)] .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'); } return scopes; } function normalizeCodeChallenge(value) { const challenge = String(value || '').trim(); if (!/^[A-Za-z0-9_-]{43,128}$/u.test(challenge)) { throw new ThirdPartySsoError('缺少有效的 PKCE code_challenge', 400, 'invalid_request'); } return challenge; } function normalizeCodeVerifier(value) { const verifier = String(value || '').trim(); if (!/^[A-Za-z0-9._~-]{43,128}$/u.test(verifier)) { throw new ThirdPartySsoError('PKCE code_verifier 无效', 400, 'invalid_grant'); } return verifier; } function buildCodeChallenge(verifier) { return crypto.createHash('sha256').update(verifier).digest('base64url'); } function serializeClient(row) { if (!row) return null; return { id: Number(row.id), clientId: row.client_id || row.clientId, clientName: row.client_name || row.clientName, clientType: row.client_type || row.clientType || 'confidential', clientSecretHash: row.client_secret_hash || row.clientSecretHash || '', // Stored direct-login return URLs may be explicitly allowlisted HTTP URLs. // Authorization requests still pass through normalizeRedirectUri below. redirectUris: normalizeRedirectUris( row.redirect_uris || row.redirectUris, normalizeStoredRedirectUri ), allowedScopes: normalizeScopes(parseJsonList(row.allowed_scopes || row.allowedScopes)), status: row.status || 'active', createdBy: row.created_by || row.createdBy || null, lastUsedAt: row.last_used_at || row.lastUsedAt || null, createdAt: row.created_at || row.createdAt || null, updatedAt: row.updated_at || row.updatedAt || null, }; } const defaultStore = { async findClient(clientId) { const rows = await database.local.query( `SELECT * FROM third_party_sso_clients WHERE client_id = ? LIMIT 1`, [clientId] ); return serializeClient(rows[0]); }, async listClients() { const rows = await database.local.query( `SELECT * FROM third_party_sso_clients ORDER BY created_at DESC, id DESC` ); return rows.map(serializeClient); }, async createClient(record) { await database.local.insert( `INSERT INTO third_party_sso_clients (client_id, client_name, client_type, client_secret_hash, redirect_uris, allowed_scopes, status, created_by) VALUES (?, ?, ?, ?, ?, ?, 'active', ?)`, [ record.clientId, record.clientName, record.clientType, record.clientSecretHash || null, JSON.stringify(record.redirectUris), JSON.stringify(record.allowedScopes), record.createdBy || null, ] ); return this.findClient(record.clientId); }, async updateClient(clientId, changes) { const columns = []; const params = []; const mapping = { clientName: 'client_name', clientSecretHash: 'client_secret_hash', redirectUris: 'redirect_uris', allowedScopes: 'allowed_scopes', status: 'status', }; for (const [key, column] of Object.entries(mapping)) { if (changes[key] === undefined) continue; columns.push(`${column} = ?`); params.push(['redirectUris', 'allowedScopes'].includes(key) ? JSON.stringify(changes[key]) : changes[key]); } if (!columns.length) return this.findClient(clientId); params.push(clientId); await database.local.update( `UPDATE third_party_sso_clients SET ${columns.join(', ')} WHERE client_id = ?`, params ); return this.findClient(clientId); }, async insertAuthorizationCode(record) { await database.local.insert( `INSERT INTO third_party_sso_authorization_codes (code_hash, client_id, user_id, redirect_uri, scope, code_challenge, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ record.codeHash, record.clientId, record.userId, record.redirectUri, record.scope, record.codeChallenge, new Date(record.expiresAt), ] ); }, async findAuthorizationCode(codeHash, clientId) { const rows = await database.local.query( `SELECT code_hash AS codeHash, client_id AS clientId, user_id AS userId, redirect_uri AS redirectUri, scope, code_challenge AS codeChallenge, expires_at AS expiresAt, consumed_at AS consumedAt FROM third_party_sso_authorization_codes WHERE code_hash = ? AND client_id = ? LIMIT 1`, [codeHash, clientId] ); return rows[0] || null; }, async consumeAuthorizationCode(codeHash, clientId) { return database.local.update( `UPDATE third_party_sso_authorization_codes SET consumed_at = NOW() WHERE code_hash = ? AND client_id = ? AND consumed_at IS NULL`, [codeHash, clientId] ); }, async touchClient(clientId) { await database.local.update( `UPDATE third_party_sso_clients SET last_used_at = NOW() WHERE client_id = ?`, [clientId] ); }, async isTokenRevoked(jti) { const rows = await database.local.query( `SELECT jti_hash FROM third_party_sso_revoked_tokens WHERE jti_hash = ? AND expires_at > NOW() LIMIT 1`, [hashValue(jti)] ); return Boolean(rows[0]); }, async revokeToken(record) { await database.local.query( `INSERT INTO third_party_sso_revoked_tokens (jti_hash, client_id, user_id, expires_at) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at)`, [hashValue(record.jti), record.clientId, record.userId, new Date(record.expiresAt)] ); }, async audit(record) { await database.local.insert( `INSERT INTO third_party_sso_audit_logs (event_type, client_id, user_id, result, ip_address, user_agent, detail) VALUES (?, ?, ?, ?, ?, ?, ?)`, [ record.eventType, record.clientId || null, record.userId || null, record.result || 'success', String(record.ipAddress || '').slice(0, 64) || null, String(record.userAgent || '').slice(0, 500) || null, record.detail ? JSON.stringify(record.detail) : null, ] ); }, async cleanupExpired() { await database.local.query( `DELETE FROM third_party_sso_authorization_codes WHERE expires_at < DATE_SUB(NOW(), INTERVAL 1 DAY)` ); await database.local.query( `DELETE FROM third_party_sso_revoked_tokens WHERE expires_at < NOW()` ); }, }; async function auditSafely(store, record) { try { await store.audit(record); } catch (error) { console.warn('[ThirdPartySSO] 写入审计日志失败:', error.message); } } async function requireActiveClient(clientId, options = {}) { const normalizedClientId = normalizeClientId(clientId); const store = options.store || defaultStore; const client = await store.findClient(normalizedClientId); if (!client || client.status !== 'active') { throw new ThirdPartySsoError('第三方应用不存在或已停用', 401, 'invalid_client'); } return client; } async function validateAuthorizationRequest(input = {}, options = {}) { if (String(input.responseType || input.response_type || '') !== 'code') { throw new ThirdPartySsoError('仅支持 response_type=code', 400, 'unsupported_response_type'); } const client = await requireActiveClient(input.clientId || input.client_id, options); const redirectUri = normalizeRedirectUri(input.redirectUri || input.redirect_uri); if (!client.redirectUris.includes(redirectUri)) { throw new ThirdPartySsoError('redirect_uri 未在该应用白名单中', 400, 'invalid_request'); } const requestedScopes = normalizeScopes(input.scope); const disallowedScopes = requestedScopes.filter((scope) => !client.allowedScopes.includes(scope)); if (disallowedScopes.length) { throw new ThirdPartySsoError(`应用未授权 scope: ${disallowedScopes.join(', ')}`, 400, 'invalid_scope'); } const clientState = String(input.state || '').trim(); if (!clientState || clientState.length > 512) { throw new ThirdPartySsoError('缺少有效的 state', 400, 'invalid_request'); } if (String(input.codeChallengeMethod || input.code_challenge_method || '') !== 'S256') { throw new ThirdPartySsoError('仅支持 PKCE S256', 400, 'invalid_request'); } const codeChallenge = normalizeCodeChallenge(input.codeChallenge || input.code_challenge); return { client, redirectUri, scopes: requestedScopes, clientState, codeChallenge, stateClaims: { flow: THIRD_PARTY_FLOW, ssoClientId: client.clientId, ssoRedirectUri: redirectUri, ssoClientState: clientState, ssoScope: requestedScopes.join(' '), ssoCodeChallenge: codeChallenge, }, }; } async function completeThirdPartyAuthorization({ stateClaims, userId, requestContext = {} }, options = {}) { if (!stateClaims || stateClaims.flow !== THIRD_PARTY_FLOW) { throw new ThirdPartySsoError('授权事务无效或已过期', 400, 'invalid_request'); } const store = options.store || defaultStore; const validated = await validateAuthorizationRequest({ responseType: 'code', clientId: stateClaims.ssoClientId, redirectUri: stateClaims.ssoRedirectUri, state: stateClaims.ssoClientState, scope: stateClaims.ssoScope, codeChallenge: stateClaims.ssoCodeChallenge, codeChallengeMethod: 'S256', }, { ...options, store }); const code = crypto.randomBytes(32).toString('base64url'); const expiresAt = Date.now() + AUTHORIZATION_CODE_TTL_SECONDS * 1000; await store.insertAuthorizationCode({ codeHash: hashValue(code), clientId: validated.client.clientId, userId: Number(userId), redirectUri: validated.redirectUri, scope: validated.scopes.join(' '), codeChallenge: validated.codeChallenge, expiresAt, }); void store.cleanupExpired?.().catch(() => {}); const redirect = new URL(validated.redirectUri); redirect.searchParams.set('code', code); redirect.searchParams.set('state', validated.clientState); await auditSafely(store, { eventType: 'authorization_code_issued', clientId: validated.client.clientId, userId, ...requestContext, }); return { redirectUrl: redirect.toString(), expiresIn: AUTHORIZATION_CODE_TTL_SECONDS, }; } async function authenticateClient(credentials = {}, options = {}) { const store = options.store || defaultStore; const client = await requireActiveClient(credentials.clientId, { ...options, store }); if (client.clientType === 'public') return client; const secret = String(credentials.clientSecret || ''); if (!secret || !timingSafeEqualText(hashValue(secret), client.clientSecretHash)) { throw new ThirdPartySsoError('client_secret 无效', 401, 'invalid_client'); } return client; } async function exchangeAuthorizationCode(input = {}, credentials = {}, options = {}) { if (String(input.grantType || input.grant_type || '') !== 'authorization_code') { throw new ThirdPartySsoError('仅支持 authorization_code', 400, 'unsupported_grant_type'); } const store = options.store || defaultStore; const client = await authenticateClient(credentials, { ...options, store }); const rawCode = String(input.code || '').trim(); if (!/^[A-Za-z0-9_-]{40,100}$/u.test(rawCode)) { throw new ThirdPartySsoError('授权码无效', 400, 'invalid_grant'); } const record = await store.findAuthorizationCode(hashValue(rawCode), client.clientId); if (!record || record.consumedAt || new Date(record.expiresAt).getTime() <= Date.now()) { throw new ThirdPartySsoError('授权码无效、已使用或已过期', 400, 'invalid_grant'); } const redirectUri = normalizeRedirectUri(input.redirectUri || input.redirect_uri); if (redirectUri !== normalizeRedirectUri(record.redirectUri)) { throw new ThirdPartySsoError('redirect_uri 与授权请求不一致', 400, 'invalid_grant'); } const verifier = normalizeCodeVerifier(input.codeVerifier || input.code_verifier); if (!timingSafeEqualText(buildCodeChallenge(verifier), record.codeChallenge)) { throw new ThirdPartySsoError('PKCE 校验失败', 400, 'invalid_grant'); } const consumed = await store.consumeAuthorizationCode(hashValue(rawCode), client.clientId); if (Number(consumed) !== 1) { throw new ThirdPartySsoError('授权码已被使用', 400, 'invalid_grant'); } const resolveUser = options.resolveUser || getResolvedUserWithPageAccess; const user = await resolveUser(record.userId); if (!user || (user.status && user.status !== 'active')) { throw new ThirdPartySsoError('用户不存在或已停用', 403, 'access_denied'); } const scopes = normalizeScopes(record.scope); const jti = crypto.randomUUID(); const accessToken = jwt.sign( { sub: String(user.id), tokenType: TOKEN_TYPE, clientId: client.clientId, scope: scopes.join(' '), }, getSigningSecret(), { expiresIn: ACCESS_TOKEN_TTL_SECONDS, issuer: TOKEN_ISSUER, audience: client.clientId, jwtid: jti, } ); await store.touchClient(client.clientId); await auditSafely(store, { eventType: 'access_token_issued', clientId: client.clientId, userId: user.id, ...(options.requestContext || {}), }); return { access_token: accessToken, token_type: 'Bearer', expires_in: ACCESS_TOKEN_TTL_SECONDS, scope: scopes.join(' '), }; } async function verifyThirdPartyAccessToken(token, options = {}) { const rawToken = String(token || '').trim(); const decoded = jwt.decode(rawToken); const clientId = String(decoded?.clientId || decoded?.aud || '').trim(); if (!clientId) { throw new ThirdPartySsoError('访问令牌无效', 401, 'invalid_token'); } const store = options.store || defaultStore; const client = await requireActiveClient(clientId, { ...options, store }); let claims; try { claims = jwt.verify(rawToken, getSigningSecret(), { issuer: TOKEN_ISSUER, audience: client.clientId, }); } catch (_error) { throw new ThirdPartySsoError('访问令牌无效或已过期', 401, 'invalid_token'); } if (claims.tokenType !== TOKEN_TYPE || !claims.jti || await store.isTokenRevoked(claims.jti)) { throw new ThirdPartySsoError('访问令牌无效或已撤销', 401, 'invalid_token'); } const resolveUser = options.resolveUser || getResolvedUserWithPageAccess; const user = await resolveUser(claims.sub); if (!user || (user.status && user.status !== 'active')) { throw new ThirdPartySsoError('用户不存在或已停用', 401, 'invalid_token'); } return { claims, client, user, scopes: normalizeScopes(claims.scope), }; } function buildUserInfo(verified) { const { user, scopes } = verified; const scopeSet = new Set(scopes); const result = { sub: String(user.id) }; if (scopeSet.has('profile')) { Object.assign(result, { username: user.username, name: user.name, picture: user.picture || user.avatar_url || '', auth_source: user.auth_source || user.authSource || 'dingtalk', }); } if (scopeSet.has('email')) result.email = user.email || ''; if (scopeSet.has('department')) { result.department = { id: user.departmentId || user.department_id || null, name: user.departmentName || '', }; } if (scopeSet.has('permissions')) { result.permissions = { role: user.role || 'user', allowed_page_keys: Array.isArray(user.allowedPageKeys) ? user.allowedPageKeys : [], }; } return result; } async function revokeAccessToken(token, credentials = {}, options = {}) { const rawToken = String(token || '').trim(); const decoded = jwt.decode(rawToken); if (!decoded?.clientId || !decoded?.jti) return { revoked: true }; const store = options.store || defaultStore; const client = await authenticateClient(credentials, { ...options, store }); if (decoded.clientId !== client.clientId) return { revoked: true }; let claims; try { claims = jwt.verify(rawToken, getSigningSecret(), { issuer: TOKEN_ISSUER, audience: client.clientId, ignoreExpiration: true, }); } catch (_error) { return { revoked: true }; } if (claims.tokenType === TOKEN_TYPE && claims.exp > Math.floor(Date.now() / 1000)) { await store.revokeToken({ jti: claims.jti, clientId: client.clientId, userId: claims.sub, expiresAt: claims.exp * 1000, }); await auditSafely(store, { eventType: 'access_token_revoked', clientId: client.clientId, userId: claims.sub, ...(options.requestContext || {}), }); } return { revoked: true }; } function publicClient(client) { return { clientId: client.clientId, clientName: client.clientName, clientType: client.clientType, redirectUris: client.redirectUris, allowedScopes: client.allowedScopes, status: client.status, lastUsedAt: client.lastUsedAt, createdAt: client.createdAt, updatedAt: client.updatedAt, }; } async function listSsoClients(options = {}) { const store = options.store || defaultStore; return (await store.listClients()).map(publicClient); } async function createSsoClient(input = {}, actor = {}, options = {}) { const clientName = String(input.clientName || input.name || '').trim().slice(0, 120); if (!clientName) { throw new ThirdPartySsoError('请填写应用名称', 400, 'invalid_request'); } const clientType = input.clientType === 'public' ? 'public' : 'confidential'; const redirectUris = normalizeRedirectUris( input.redirectUris, options.redirectUriNormalizer || normalizeRedirectUri ); const allowedScopes = normalizeScopes(input.allowedScopes); const clientId = `sso_${crypto.randomBytes(18).toString('base64url')}`; const clientSecret = clientType === 'confidential' ? crypto.randomBytes(32).toString('base64url') : ''; const store = options.store || defaultStore; const client = await store.createClient({ clientId, clientName, clientType, clientSecretHash: clientSecret ? hashValue(clientSecret) : '', redirectUris, allowedScopes, createdBy: actor.id || actor.sub || null, }); await auditSafely(store, { eventType: 'client_created', clientId, userId: actor.id || actor.sub || null, }); return { ...publicClient(client), clientSecret: clientSecret || undefined }; } async function updateSsoClient(clientId, input = {}, actor = {}, options = {}) { const store = options.store || defaultStore; const existing = await requireActiveClient(clientId, { ...options, store }).catch(async (error) => { const disabled = await store.findClient(normalizeClientId(clientId)); if (disabled) return disabled; throw error; }); const changes = {}; if (input.clientName !== undefined || input.name !== undefined) { changes.clientName = String(input.clientName || input.name || '').trim().slice(0, 120); if (!changes.clientName) throw new ThirdPartySsoError('应用名称不能为空'); } if (input.redirectUris !== undefined) { changes.redirectUris = normalizeRedirectUris( input.redirectUris, options.redirectUriNormalizer || normalizeRedirectUri ); } if (input.allowedScopes !== undefined) changes.allowedScopes = normalizeScopes(input.allowedScopes); if (input.status !== undefined) { if (!['active', 'disabled'].includes(input.status)) { throw new ThirdPartySsoError('应用状态无效'); } changes.status = input.status; } const updated = await store.updateClient(existing.clientId, changes); await auditSafely(store, { eventType: 'client_updated', clientId: existing.clientId, userId: actor.id || actor.sub || null, detail: { fields: Object.keys(changes) }, }); return publicClient(updated); } async function rotateSsoClientSecret(clientId, actor = {}, options = {}) { const store = options.store || defaultStore; const client = await requireActiveClient(clientId, { ...options, store }); if (client.clientType !== 'confidential') { throw new ThirdPartySsoError('公开客户端使用 PKCE,不包含 client_secret'); } const clientSecret = crypto.randomBytes(32).toString('base64url'); const updated = await store.updateClient(client.clientId, { clientSecretHash: hashValue(clientSecret), }); await auditSafely(store, { eventType: 'client_secret_rotated', clientId: client.clientId, userId: actor.id || actor.sub || null, }); return { ...publicClient(updated), clientSecret }; } async function ensureThirdPartySsoSchema() { await database.local.query( `CREATE TABLE IF NOT EXISTS third_party_sso_clients ( id BIGINT PRIMARY KEY AUTO_INCREMENT, client_id VARCHAR(80) NOT NULL, client_name VARCHAR(120) NOT NULL, client_type ENUM('confidential', 'public') NOT NULL DEFAULT 'confidential', client_secret_hash CHAR(64) NULL, redirect_uris TEXT NOT NULL, allowed_scopes VARCHAR(500) NOT NULL DEFAULT '["profile"]', status ENUM('active', 'disabled') NOT NULL DEFAULT 'active', created_by BIGINT NULL, last_used_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_third_party_sso_clients_client_id (client_id), KEY idx_third_party_sso_clients_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方统一认证应用'` ); await database.local.query( `CREATE TABLE IF NOT EXISTS third_party_sso_authorization_codes ( code_hash CHAR(64) PRIMARY KEY, client_id VARCHAR(80) NOT NULL, user_id BIGINT NOT NULL, redirect_uri VARCHAR(1024) NOT NULL, scope VARCHAR(500) NOT NULL, code_challenge VARCHAR(128) NOT NULL, expires_at DATETIME NOT NULL, consumed_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_third_party_sso_codes_client (client_id), KEY idx_third_party_sso_codes_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方统一认证一次性授权码'` ); await database.local.query( `CREATE TABLE IF NOT EXISTS third_party_sso_revoked_tokens ( jti_hash CHAR(64) PRIMARY KEY, client_id VARCHAR(80) NOT NULL, user_id BIGINT NOT NULL, expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_third_party_sso_revoked_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方统一认撤销令牌'` ); await database.local.query( `CREATE TABLE IF NOT EXISTS third_party_sso_audit_logs ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_type VARCHAR(80) NOT NULL, client_id VARCHAR(80) NULL, user_id BIGINT NULL, result VARCHAR(30) NOT NULL DEFAULT 'success', ip_address VARCHAR(64) NULL, user_agent VARCHAR(500) NULL, detail TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_third_party_sso_audit_client_time (client_id, created_at), KEY idx_third_party_sso_audit_user_time (user_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='第三方统一认审计日志'` ); } module.exports = { ACCESS_TOKEN_TTL_SECONDS, AUTHORIZATION_CODE_TTL_SECONDS, SUPPORTED_SCOPES, THIRD_PARTY_FLOW, TOKEN_ISSUER, TOKEN_TYPE, ThirdPartySsoError, authenticateClient, buildCodeChallenge, buildUserInfo, completeThirdPartyAuthorization, createSsoClient, ensureThirdPartySsoSchema, exchangeAuthorizationCode, getThirdPartySsoCallbackBaseUrl, hashValue, listSsoClients, normalizeRedirectUri, revokeAccessToken, rotateSsoClientSecret, updateSsoClient, validateAuthorizationRequest, verifyThirdPartyAccessToken, };