import crypto from 'node:crypto'; import { HttpError } from '../../core/http.js'; import { optionalPublicArray, optionalPublicObject, optionalPublicString, providerEndpoint, requirePublicString, requireSecretString, type TenantAuthProviderConfig, } from '../../core/tenant-provider-config.js'; export type SmsProviderName = 'mock' | 'aliyun' | 'tencent'; export type OAuthProviderName = 'wechat_web' | 'wechat_miniapp' | 'wechat-miniapp' | 'qq'; export interface SmsSendInput { tenantId: string; phone: string; code: string; purpose: string; metadata: Record; } export interface SmsSendResult { provider: SmsProviderName; status: 'sent' | 'mocked'; providerMessageId?: string; raw?: Record; } export interface SmsProvider { name: SmsProviderName; send(input: SmsSendInput): Promise; } function hmacSha256(key: crypto.BinaryLike | crypto.KeyObject, message: string) { return crypto.createHmac('sha256', key).update(message, 'utf8').digest(); } function sha256Hex(message: string) { return crypto.createHash('sha256').update(message, 'utf8').digest('hex'); } function encodeRFC3986(value: string) { return encodeURIComponent(value).replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`); } function canonicalQuery(params: Record) { return Object.keys(params) .sort() .map(key => `${encodeRFC3986(key)}=${encodeRFC3986(params[key] ?? '')}`) .join('&'); } function safeRaw(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; } function maskedProviderError(provider: string, status: number, raw: Record) { const code = typeof raw.Code === 'string' ? raw.Code : typeof raw.code === 'string' ? raw.code : ''; const suffix = code ? ` (${code})` : ''; throw new HttpError(status >= 400 && status < 500 ? 502 : 503, `${provider} SMS send failed${suffix}`, 'SMS_PROVIDER_SEND_FAILED'); } function templateParams(config: TenantAuthProviderConfig, input: SmsSendInput) { const configured = optionalPublicArray(config, 'templateParamSet') .concat(optionalPublicArray(config, 'templateParams')) .map(item => String(item)); if (configured.length > 0) { return configured.map(item => item .replace(/\{\{\s*code\s*\}\}/gi, input.code) .replace(/\{code\}/gi, input.code) .replace(/\$\{code\}/gi, input.code), ); } const mapping = optionalPublicObject(config, 'templateParam'); if (Object.keys(mapping).length > 0) { const normalized: Record = {}; for (const [key, value] of Object.entries(mapping)) { normalized[key] = String(value) .replace(/\{\{\s*code\s*\}\}/gi, input.code) .replace(/\{code\}/gi, input.code) .replace(/\$\{code\}/gi, input.code); } if (!Object.values(normalized).includes(input.code)) normalized.code = input.code; return normalized; } return { code: input.code }; } function phoneWithChinaCode(phone: string) { return phone.startsWith('+') ? phone : `+86${phone}`; } class MockSmsProvider implements SmsProvider { readonly name = 'mock' as const; async send(): Promise { return { provider: this.name, status: 'mocked', raw: { localOnly: true }, }; } } class NotConfiguredSmsProvider implements SmsProvider { constructor(readonly name: SmsProviderName) {} async send(): Promise { throw new HttpError(503, `${this.name} SMS provider is not configured`, 'SMS_PROVIDER_NOT_CONFIGURED'); } } class AliyunSmsProvider implements SmsProvider { readonly name = 'aliyun' as const; constructor(private readonly providerConfig: TenantAuthProviderConfig) {} async send(input: SmsSendInput): Promise { const accessKeyId = requireSecretString(this.providerConfig, ['accessKeyId', 'AccessKeyId'], 'SMS_SECRET_REQUIRED'); const accessKeySecret = requireSecretString(this.providerConfig, ['accessKeySecret', 'AccessKeySecret'], 'SMS_SECRET_REQUIRED'); const signName = requirePublicString(this.providerConfig, ['signName'], 'SMS_PUBLIC_CONFIG_REQUIRED'); const templateCode = requirePublicString(this.providerConfig, ['templateCode'], 'SMS_PUBLIC_CONFIG_REQUIRED'); const endpoint = providerEndpoint(this.providerConfig, 'https://dysmsapi.aliyuncs.com', ['aliyuncs.com'], 'SMS_ENDPOINT_NOT_ALLOWED'); const date = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); const nonce = crypto.randomUUID(); const params = { AccessKeyId: accessKeyId, Action: 'SendSms', Format: 'JSON', PhoneNumbers: input.phone, RegionId: optionalPublicString(this.providerConfig, ['regionId']) || 'cn-hangzhou', SignatureMethod: 'HMAC-SHA1', SignatureNonce: nonce, SignatureVersion: '1.0', SignName: signName, TemplateCode: templateCode, TemplateParam: JSON.stringify(templateParams(this.providerConfig, input)), Timestamp: date, Version: '2017-05-25', }; const canonical = canonicalQuery(params); const stringToSign = `POST&%2F&${encodeRFC3986(canonical)}`; const signature = crypto.createHmac('sha1', `${accessKeySecret}&`).update(stringToSign, 'utf8').digest('base64'); const body = `${canonical}&Signature=${encodeRFC3986(signature)}`; const response = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded;charset=utf-8', }, body, }); const raw = safeRaw(await response.json().catch(() => ({}))); const code = typeof raw.Code === 'string' ? raw.Code : ''; if (!response.ok || code !== 'OK') maskedProviderError(this.name, response.status, raw); return { provider: this.name, status: 'sent', providerMessageId: typeof raw.BizId === 'string' ? raw.BizId : undefined, raw: { requestId: raw.RequestId, code, }, }; } } class TencentSmsProvider implements SmsProvider { readonly name = 'tencent' as const; constructor(private readonly providerConfig: TenantAuthProviderConfig) {} async send(input: SmsSendInput): Promise { const secretId = requireSecretString(this.providerConfig, ['secretId', 'SecretId'], 'SMS_SECRET_REQUIRED'); const secretKey = requireSecretString(this.providerConfig, ['secretKey', 'SecretKey'], 'SMS_SECRET_REQUIRED'); const smsSdkAppId = requirePublicString(this.providerConfig, ['smsSdkAppId', 'appId'], 'SMS_PUBLIC_CONFIG_REQUIRED'); const signName = requirePublicString(this.providerConfig, ['signName'], 'SMS_PUBLIC_CONFIG_REQUIRED'); const templateId = requirePublicString(this.providerConfig, ['templateId'], 'SMS_PUBLIC_CONFIG_REQUIRED'); const region = optionalPublicString(this.providerConfig, ['region']) || 'ap-guangzhou'; const endpoint = providerEndpoint( this.providerConfig, 'https://sms.tencentcloudapi.com', ['tencentcloudapi.com'], 'SMS_ENDPOINT_NOT_ALLOWED', ); const host = new URL(endpoint).host; const timestamp = Math.floor(Date.now() / 1000); const date = new Date(timestamp * 1000).toISOString().slice(0, 10); const payload = JSON.stringify({ PhoneNumberSet: [phoneWithChinaCode(input.phone)], SmsSdkAppId: smsSdkAppId, SignName: signName, TemplateId: templateId, TemplateParamSet: templateParams(this.providerConfig, input), }); const canonicalHeaders = `content-type:application/json; charset=utf-8\nhost:${host}\nx-tc-action:sendsms\n`; const signedHeaders = 'content-type;host;x-tc-action'; const canonicalRequest = ['POST', '/', '', canonicalHeaders, signedHeaders, sha256Hex(payload)].join('\n'); const credentialScope = `${date}/sms/tc3_request`; const stringToSign = ['TC3-HMAC-SHA256', timestamp, credentialScope, sha256Hex(canonicalRequest)].join('\n'); const secretDate = hmacSha256(`TC3${secretKey}`, date); const secretService = hmacSha256(secretDate, 'sms'); const secretSigning = hmacSha256(secretService, 'tc3_request'); const signature = crypto.createHmac('sha256', secretSigning).update(stringToSign, 'utf8').digest('hex'); const authorization = `TC3-HMAC-SHA256 Credential=${secretId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; const response = await fetch(endpoint, { method: 'POST', headers: { authorization, 'content-type': 'application/json; charset=utf-8', host, 'x-tc-action': 'SendSms', 'x-tc-region': region, 'x-tc-timestamp': String(timestamp), 'x-tc-version': optionalPublicString(this.providerConfig, ['version']) || '2021-01-11', }, body: payload, }); const raw = safeRaw(await response.json().catch(() => ({}))); const responsePayload = safeRaw(raw.Response); const error = safeRaw(responsePayload.Error); if (!response.ok || Object.keys(error).length > 0) maskedProviderError(this.name, response.status, responsePayload); const sendStatusSet = Array.isArray(responsePayload.SendStatusSet) ? responsePayload.SendStatusSet : []; const firstStatus = safeRaw(sendStatusSet[0]); const code = typeof firstStatus.Code === 'string' ? firstStatus.Code : 'Ok'; if (code !== 'Ok') maskedProviderError(this.name, response.status || 200, responsePayload); return { provider: this.name, status: 'sent', providerMessageId: typeof firstStatus.SerialNo === 'string' ? firstStatus.SerialNo : undefined, raw: { requestId: responsePayload.RequestId, code, }, }; } } export function createSmsProvider(name: string, providerConfig?: TenantAuthProviderConfig | null): SmsProvider { if (name === 'aliyun') return providerConfig ? new AliyunSmsProvider(providerConfig) : new NotConfiguredSmsProvider('aliyun'); if (name === 'tencent') return providerConfig ? new TencentSmsProvider(providerConfig) : new NotConfiguredSmsProvider('tencent'); return new MockSmsProvider(); } export function supportedOAuthProviders(): OAuthProviderName[] { return ['wechat_web', 'wechat_miniapp', 'wechat-miniapp', 'qq']; }