217 lines
7.0 KiB
JavaScript
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;
|