cangku/backend/src/api/thirdPartySso.js

217 lines
7.0 KiB
JavaScript

'use strict';
const express = require('express');
const { getAuthConfig } = require('../services/authConfig');
const {
SUPPORTED_SCOPES,
TOKEN_ISSUER,
ThirdPartySsoError,
buildUserInfo,
exchangeAuthorizationCode,
getThirdPartySsoCallbackBaseUrl,
revokeAccessToken,
validateAuthorizationRequest,
verifyThirdPartyAccessToken,
} = require('../services/thirdPartySsoService');
const {
buildCallbackUrl,
createOAuthState,
getStateCookieName,
} = require('../services/socialAuth');
const {
beginDirectLogin,
createDirectLoginSession,
pollDirectLoginSession,
} = require('../services/thirdPartySsoDirectService');
const router = express.Router();
router.use(express.urlencoded({ extended: false, limit: '32kb' }));
function requestContext(req) {
return {
ipAddress: req.ip || req.socket?.remoteAddress || '',
userAgent: req.get('user-agent') || '',
};
}
function setOauthStateCookie(res, state) {
res.cookie(getStateCookieName('dingtalk'), state, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 15 * 60 * 1000,
path: '/',
});
}
function parseClientCredentials(req) {
const authorization = String(req.get('authorization') || '');
const match = authorization.match(/^Basic\s+(.+)$/iu);
if (match) {
try {
const decoded = Buffer.from(match[1], 'base64').toString('utf8');
const separator = decoded.indexOf(':');
if (separator >= 0) {
return {
clientId: decodeURIComponent(decoded.slice(0, separator)),
clientSecret: decodeURIComponent(decoded.slice(separator + 1)),
};
}
} catch (_error) {
// The service returns a standard invalid_client response below.
}
}
return {
clientId: req.body?.client_id || req.body?.clientId,
clientSecret: req.body?.client_secret || req.body?.clientSecret || '',
};
}
function extractBearerToken(req) {
const match = String(req.get('authorization') || '').match(/^Bearer\s+([^\s]+)$/iu);
return match?.[1] || '';
}
function extractApiKey(req) {
return String(req.get('x-api-key') || '').trim();
}
function sendOauthError(res, error) {
const status = error instanceof ThirdPartySsoError ? error.status : 500;
const code = error instanceof ThirdPartySsoError ? error.code : 'server_error';
return res.status(status).json({
error: code,
error_description: error.message || '统一认证服务异常',
});
}
router.get('/authorize', async (req, res) => {
try {
const validated = await validateAuthorizationRequest(req.query);
const authConfig = await getAuthConfig();
if (!authConfig.dingtalkEnabled || !authConfig.dingtalkAppKey || !authConfig.dingtalkAppSecret) {
throw new ThirdPartySsoError('钉钉登录未配置或未启用', 503, 'temporarily_unavailable');
}
const callbackUrl = buildCallbackUrl(getThirdPartySsoCallbackBaseUrl(), 'dingtalk');
const oauthState = createOAuthState('dingtalk', validated.stateClaims);
setOauthStateCookie(res, oauthState);
const authorizeUrl = new URL('https://login.dingtalk.com/oauth2/auth');
authorizeUrl.searchParams.set('redirect_uri', callbackUrl);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', authConfig.dingtalkAppKey);
authorizeUrl.searchParams.set('scope', 'openid');
authorizeUrl.searchParams.set('state', oauthState);
return res.redirect(authorizeUrl.toString());
} catch (error) {
return sendOauthError(res, error);
}
});
router.post('/direct/sessions', async (req, res) => {
try {
res.set('Cache-Control', 'no-store');
return res.status(201).json(await createDirectLoginSession(
req.body || {},
extractApiKey(req),
{ requestContext: requestContext(req) }
));
} catch (error) {
return sendOauthError(res, error);
}
});
router.get('/direct/sessions/:requestId', async (req, res) => {
try {
res.set('Cache-Control', 'no-store');
return res.json(await pollDirectLoginSession(
req.params.requestId,
extractApiKey(req),
{ requestContext: requestContext(req) }
));
} catch (error) {
return sendOauthError(res, error);
}
});
router.get('/direct/login', async (req, res) => {
try {
const directLogin = await beginDirectLogin(req.query.request_id);
const authConfig = await getAuthConfig();
if (!authConfig.dingtalkEnabled || !authConfig.dingtalkAppKey || !authConfig.dingtalkAppSecret) {
throw new ThirdPartySsoError('钉钉登录未配置或未启用', 503, 'temporarily_unavailable');
}
const callbackUrl = buildCallbackUrl(getThirdPartySsoCallbackBaseUrl(), 'dingtalk');
const oauthState = createOAuthState('dingtalk', directLogin.stateClaims);
setOauthStateCookie(res, oauthState);
const authorizeUrl = new URL('https://login.dingtalk.com/oauth2/auth');
authorizeUrl.searchParams.set('redirect_uri', callbackUrl);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', authConfig.dingtalkAppKey);
authorizeUrl.searchParams.set('scope', 'openid');
authorizeUrl.searchParams.set('state', oauthState);
return res.redirect(authorizeUrl.toString());
} catch (error) {
return sendOauthError(res, error);
}
});
router.post('/token', async (req, res) => {
try {
const result = await exchangeAuthorizationCode(req.body || {}, parseClientCredentials(req), {
requestContext: requestContext(req),
});
res.set('Cache-Control', 'no-store');
res.set('Pragma', 'no-cache');
return res.json(result);
} catch (error) {
if (error?.code === 'invalid_client') {
res.set('WWW-Authenticate', 'Basic realm="third-party-sso"');
}
return sendOauthError(res, error);
}
});
router.get('/userinfo', async (req, res) => {
try {
const verified = await verifyThirdPartyAccessToken(extractBearerToken(req));
res.set('Cache-Control', 'no-store');
return res.json(buildUserInfo(verified));
} catch (error) {
res.set('WWW-Authenticate', 'Bearer realm="third-party-sso", error="invalid_token"');
return sendOauthError(res, error);
}
});
router.post('/revoke', async (req, res) => {
try {
await revokeAccessToken(req.body?.token, parseClientCredentials(req), {
requestContext: requestContext(req),
});
return res.status(200).json({ revoked: true });
} catch (error) {
return sendOauthError(res, error);
}
});
router.get('/.well-known/oauth-authorization-server', (_req, res) => {
const issuer = TOKEN_ISSUER.replace(/\/$/u, '');
return res.json({
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
revocation_endpoint: `${issuer}/revoke`,
userinfo_endpoint: `${issuer}/userinfo`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
scopes_supported: SUPPORTED_SCOPES,
});
});
module.exports = router;