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