cangku/backend/src/services/thirdPartySsoService.js

831 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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,
};