diff --git a/apps/api/src/features/auth/index.ts b/apps/api/src/features/auth/index.ts index ca68e16c..71347e06 100644 --- a/apps/api/src/features/auth/index.ts +++ b/apps/api/src/features/auth/index.ts @@ -5,6 +5,7 @@ import { oauthProviderPlaceholderRoute, sendSmsCodeRoute, verifySmsCodeRoute, + wechatMiniappLoginRoute, } from './routes.js'; export const authRoutes: RouteDefinition[] = [ @@ -13,6 +14,6 @@ export const authRoutes: RouteDefinition[] = [ ['GET', '/api/auth/me', meRoute], ['POST', '/api/auth/logout', logoutRoute], ['POST', '/api/auth/oauth/wechat', oauthProviderPlaceholderRoute], - ['POST', '/api/auth/oauth/wechat-miniapp', oauthProviderPlaceholderRoute], + ['POST', '/api/auth/oauth/wechat-miniapp', wechatMiniappLoginRoute], ['POST', '/api/auth/oauth/qq', oauthProviderPlaceholderRoute], ]; diff --git a/apps/api/src/features/auth/provider-config.ts b/apps/api/src/features/auth/provider-config.ts new file mode 100644 index 00000000..a691f79d --- /dev/null +++ b/apps/api/src/features/auth/provider-config.ts @@ -0,0 +1,192 @@ +import { query, queryOne } from '../../core/db.js'; +import { HttpError } from '../../core/http.js'; +import { config as appConfig } from '../../core/config.js'; + +export type TenantSecretScope = 'payment' | 'sms' | 'oauth' | 'storage' | 'crm' | 'ai' | 'system'; + +export interface TenantSecretConfig { + secretScope: TenantSecretScope; + secretKey: string; + secretValue: string | null; + secretJson: Record; +} + +export interface TenantAuthProviderConfig { + tenantId: string; + provider: string; + status: 'active' | 'testing' | 'disabled'; + configPublic: Record; + secret: TenantSecretConfig | null; +} + +interface TenantAuthProviderRow { + provider: string; + status: 'active' | 'testing' | 'disabled'; + configPublic: Record | null; +} + +interface TenantSecretRow { + secretScope: TenantSecretScope; + secretKey: string; + secretValue: string | null; + secretJson: Record | null; +} + +function objectValue(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; +} + +function parseSecretRef(value: unknown): { scope: TenantSecretScope; key: string } | null { + if (typeof value !== 'string' || !value.trim()) return null; + const parts = value.trim().split(':'); + if (parts.length !== 3 || parts[0] !== 'app_private.tenant_secrets') return null; + const scope = parts[1] as TenantSecretScope; + const key = parts[2]; + if (!['payment', 'sms', 'oauth', 'storage', 'crm', 'ai', 'system'].includes(scope) || !key) return null; + return { scope, key }; +} + +function defaultSecretScope(provider: string): TenantSecretScope { + const normalized = provider.toLowerCase().replace(/[_\s]/g, '-'); + if (normalized.includes('sms') || normalized === 'aliyun' || normalized === 'tencent') return 'sms'; + return 'oauth'; +} + +async function loadTenantSecret( + tenantId: string, + configPublic: Record, + provider: string, +): Promise { + const secretRef = parseSecretRef(configPublic.secretRef); + const scope = secretRef?.scope || defaultSecretScope(provider); + const key = secretRef?.key || provider; + + const row = await queryOne( + ` + select secret_scope as "secretScope", secret_key as "secretKey", + secret_value as "secretValue", secret_json as "secretJson" + from app_private.tenant_secrets + where tenant_id = $1 + and secret_scope = $2 + and secret_key = $3 + limit 1 + `, + [tenantId, scope, key], + ); + + if (!row) return null; + return { + secretScope: row.secretScope, + secretKey: row.secretKey, + secretValue: row.secretValue, + secretJson: objectValue(row.secretJson), + }; +} + +export async function loadTenantAuthProviderConfig( + tenantId: string, + providers: string[], +): Promise { + const uniqueProviders = [...new Set(providers.map(item => item.trim()).filter(Boolean))]; + if (uniqueProviders.length === 0) return null; + + const rows = await query( + ` + select provider, status, config_public as "configPublic" + from public.tenant_auth_providers + where tenant_id = $1 + and provider = any($2::text[]) + and status in ('active', 'testing') + `, + [tenantId, uniqueProviders], + ); + + const row = uniqueProviders.map(provider => rows.find(item => item.provider === provider)).find(Boolean); + if (!row) return null; + + const configPublic = objectValue(row.configPublic); + const secret = await loadTenantSecret(tenantId, configPublic, row.provider); + return { + tenantId, + provider: row.provider, + status: row.status, + configPublic, + secret, + }; +} + +export function requirePublicString(config: TenantAuthProviderConfig, keys: string[], code: string) { + for (const key of keys) { + const value = config.configPublic[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + throw new HttpError(503, `${config.provider} public config is missing ${keys[0]}`, code); +} + +export function optionalPublicString(config: TenantAuthProviderConfig, keys: string[]) { + for (const key of keys) { + const value = config.configPublic[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return ''; +} + +function isLocalDevHost(hostname: string) { + return ['127.0.0.1', 'localhost', '::1'].includes(hostname); +} + +function isAllowedHost(hostname: string, allowedHosts: string[]) { + return allowedHosts.some(host => hostname === host || hostname.endsWith(`.${host}`)); +} + +export function providerEndpoint( + authProvider: TenantAuthProviderConfig, + fallback: string, + allowedHosts: string[], + code = 'PROVIDER_ENDPOINT_NOT_ALLOWED', +) { + const raw = optionalPublicString(authProvider, ['endpoint']) || fallback; + let endpoint: URL; + try { + endpoint = new URL(raw); + } catch { + throw new HttpError(503, `${authProvider.provider} endpoint is invalid`, code); + } + + const localDev = !appConfig.isProduction && isLocalDevHost(endpoint.hostname); + if (!localDev && endpoint.protocol !== 'https:') { + throw new HttpError(503, `${authProvider.provider} endpoint must use HTTPS`, code); + } + if (!localDev && !isAllowedHost(endpoint.hostname, allowedHosts)) { + throw new HttpError(503, `${authProvider.provider} endpoint host is not allowed`, code); + } + endpoint.username = ''; + endpoint.password = ''; + return endpoint.toString(); +} + +export function optionalPublicObject(config: TenantAuthProviderConfig, key: string) { + return objectValue(config.configPublic[key]); +} + +export function optionalPublicArray(config: TenantAuthProviderConfig, key: string) { + const value = config.configPublic[key]; + return Array.isArray(value) ? value : []; +} + +export function secretString(config: TenantAuthProviderConfig, keys: string[]) { + const secret = config.secret; + if (!secret) return ''; + if (typeof secret.secretValue === 'string' && secret.secretValue.trim()) return secret.secretValue.trim(); + for (const key of keys) { + const value = secret.secretJson[key]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return ''; +} + +export function requireSecretString(config: TenantAuthProviderConfig, keys: string[], code: string) { + const value = secretString(config, keys); + if (value) return value; + throw new HttpError(503, `${config.provider} secret is missing ${keys[0]}`, code); +} diff --git a/apps/api/src/features/auth/providers.ts b/apps/api/src/features/auth/providers.ts index 55af2eba..498245a0 100644 --- a/apps/api/src/features/auth/providers.ts +++ b/apps/api/src/features/auth/providers.ts @@ -1,5 +1,17 @@ +import crypto from 'node:crypto'; +import { HttpError } from '../../core/http.js'; +import { + optionalPublicArray, + optionalPublicObject, + optionalPublicString, + providerEndpoint, + requirePublicString, + requireSecretString, + type TenantAuthProviderConfig, +} from './provider-config.js'; + export type SmsProviderName = 'mock' | 'aliyun' | 'tencent'; -export type OAuthProviderName = 'wechat_web' | 'wechat_miniapp' | 'qq'; +export type OAuthProviderName = 'wechat_web' | 'wechat_miniapp' | 'wechat-miniapp' | 'qq'; export interface SmsSendInput { tenantId: string; @@ -21,6 +33,68 @@ export interface SmsProvider { 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; @@ -37,16 +111,149 @@ class NotConfiguredSmsProvider implements SmsProvider { constructor(readonly name: SmsProviderName) {} async send(): Promise { - throw new Error(`${this.name} SMS provider is not configured yet`); + throw new HttpError(503, `${this.name} SMS provider is not configured`, 'SMS_PROVIDER_NOT_CONFIGURED'); } } -export function createSmsProvider(name: string): SmsProvider { - if (name === 'aliyun') return new NotConfiguredSmsProvider('aliyun'); - if (name === 'tencent') return new NotConfiguredSmsProvider('tencent'); +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', 'qq']; + return ['wechat_web', 'wechat_miniapp', 'wechat-miniapp', 'qq']; } diff --git a/apps/api/src/features/auth/routes.ts b/apps/api/src/features/auth/routes.ts index 4653c7f4..8abc5be5 100644 --- a/apps/api/src/features/auth/routes.ts +++ b/apps/api/src/features/auth/routes.ts @@ -5,6 +5,13 @@ import { query, transaction } from '../../core/db.js'; import { HttpError, type RequestContext } from '../../core/http.js'; import { optionalString, readJsonBody, requiredString, tenantIdFrom } from '../../core/request.js'; import { createSmsProvider } from './providers.js'; +import { + loadTenantAuthProviderConfig, + optionalPublicString, + providerEndpoint, + requirePublicString, + requireSecretString, +} from './provider-config.js'; import { assertChinaPhone, clientIpFrom, @@ -12,6 +19,7 @@ import { generateSmsCode, hashSmsCode, normalizePurpose, + upsertOAuthUser, upsertPhoneUser, userAgentFrom, writeLoginEvent, @@ -56,23 +64,51 @@ function jsonObject(value: unknown) { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; } -async function activeSmsProviderName(tenantId: string) { - if (config.authSmsProvider !== 'mock') return config.authSmsProvider; +function normalizeSmsProviderName(value: string) { + const normalized = value.toLowerCase().replace(/_/g, '-'); + if (normalized === 'aliyun' || normalized === 'aliyun-sms') return 'aliyun'; + if (normalized === 'tencent' || normalized === 'tencent-sms') return 'tencent'; + return 'mock'; +} + +function smsProviderAliases(value: string) { + const provider = normalizeSmsProviderName(value); + if (provider === 'aliyun') return ['aliyun', 'aliyun-sms', 'aliyun_sms']; + if (provider === 'tencent') return ['tencent', 'tencent-sms', 'tencent_sms']; + return ['mock']; +} + +async function activeSmsProvider(tenantId: string) { + if (config.authSmsProvider !== 'mock') { + const name = normalizeSmsProviderName(config.authSmsProvider); + return { + name, + providerConfig: name === 'mock' ? null : await loadTenantAuthProviderConfig(tenantId, smsProviderAliases(name)), + }; + } const rows = await query<{ provider: string }>( ` select provider from public.tenant_auth_providers where tenant_id = $1 - and provider in ('aliyun', 'tencent', 'mock') + and provider in ('aliyun', 'aliyun-sms', 'aliyun_sms', 'tencent', 'tencent-sms', 'tencent_sms', 'mock') and status in ('active', 'testing') - order by case provider when 'aliyun' then 0 when 'tencent' then 1 else 2 end + order by case + when provider in ('aliyun', 'aliyun-sms', 'aliyun_sms') then 0 + when provider in ('tencent', 'tencent-sms', 'tencent_sms') then 1 + else 2 + end limit 1 `, [tenantId], ); - return rows[0]?.provider || config.authSmsProvider; + const name = normalizeSmsProviderName(rows[0]?.provider || config.authSmsProvider); + return { + name, + providerConfig: name === 'mock' ? null : await loadTenantAuthProviderConfig(tenantId, smsProviderAliases(name)), + }; } export async function sendSmsCodeRoute(ctx: RequestContext) { @@ -107,8 +143,8 @@ export async function sendSmsCodeRoute(ctx: RequestContext) { throw new HttpError(429, `SMS code was sent too frequently. Retry after ${cooldown} seconds.`, 'SMS_COOLDOWN'); } - const providerName = await activeSmsProviderName(tenantId); - const provider = createSmsProvider(providerName); + const providerChoice = await activeSmsProvider(tenantId); + const provider = createSmsProvider(providerChoice.name, providerChoice.providerConfig); if (config.isProduction && provider.name === 'mock') { throw new HttpError(503, 'SMS provider is not configured for production', 'SMS_PROVIDER_REQUIRED'); } @@ -366,11 +402,141 @@ export async function logoutRoute(ctx: RequestContext) { return { ok: true }; } +function normalizeOAuthProvider(value: string) { + const normalized = value.toLowerCase().replace(/_/g, '-'); + if (normalized === 'wechat' || normalized === 'wechat-web') return 'wechat-web'; + if (normalized === 'wechat-miniapp' || normalized === 'wechat-mini') return 'wechat-miniapp'; + if (normalized === 'qq' || normalized === 'qq-oauth') return 'qq'; + return normalized; +} + +function oauthProviderAliases(value: string) { + const provider = normalizeOAuthProvider(value); + if (provider === 'wechat-miniapp') return ['wechat-miniapp', 'wechat_miniapp', 'wechat-mini', 'wechatMiniapp']; + if (provider === 'wechat-web') return ['wechat-web', 'wechat_web', 'wechat']; + if (provider === 'qq') return ['qq', 'qq-oauth', 'qq_oauth']; + return [provider]; +} + +async function callWechatCode2Session(input: { + endpoint: string; + appId: string; + appSecret: string; + code: string; +}) { + const url = new URL(input.endpoint); + url.searchParams.set('appid', input.appId); + url.searchParams.set('secret', input.appSecret); + url.searchParams.set('js_code', input.code); + url.searchParams.set('grant_type', 'authorization_code'); + + const response = await fetch(url); + const raw = jsonObject(await response.json().catch(() => ({}))); + const errcode = Number(raw.errcode || 0); + if (!response.ok || errcode) { + const suffix = Number.isFinite(errcode) && errcode ? ` (${errcode})` : ''; + throw new HttpError(401, `WeChat miniapp code exchange failed${suffix}`, 'WECHAT_CODE_EXCHANGE_FAILED'); + } + + const openId = typeof raw.openid === 'string' ? raw.openid.trim() : ''; + const sessionKey = typeof raw.session_key === 'string' ? raw.session_key.trim() : ''; + const unionId = typeof raw.unionid === 'string' ? raw.unionid.trim() : ''; + if (!openId || !sessionKey) { + throw new HttpError(401, 'WeChat miniapp response is missing openid or session_key', 'WECHAT_CODE_EXCHANGE_INVALID'); + } + + return { openId, sessionKey, unionId, raw }; +} + +export async function wechatMiniappLoginRoute(ctx: RequestContext) { + const tenantId = await tenantIdFrom(ctx); + const body = await readJsonBody(ctx); + const code = requiredString(body, 'code'); + const profile = jsonObject(body.profile); + const ipAddress = clientIpFrom(ctx); + const userAgent = userAgentFrom(ctx); + const providerConfig = await loadTenantAuthProviderConfig(tenantId, oauthProviderAliases('wechat-miniapp')); + if (!providerConfig) { + throw new HttpError(503, 'WeChat miniapp auth provider is not configured', 'PROVIDER_NOT_CONFIGURED'); + } + + const appId = requirePublicString(providerConfig, ['appId'], 'OAUTH_PUBLIC_CONFIG_REQUIRED'); + const appSecret = requireSecretString(providerConfig, ['appSecret', 'secret'], 'OAUTH_SECRET_REQUIRED'); + const endpoint = providerEndpoint( + providerConfig, + 'https://api.weixin.qq.com/sns/jscode2session', + ['weixin.qq.com'], + 'OAUTH_ENDPOINT_NOT_ALLOWED', + ); + const exchanged = await callWechatCode2Session({ endpoint, appId, appSecret, code }); + const provider = 'wechat-miniapp'; + const providerSubject = `${appId}:${exchanged.openId}`; + + const result = await transaction(async client => { + const { user, isNewUser } = await upsertOAuthUser(client, { + tenantId, + provider, + providerSubject, + openId: exchanged.openId, + unionId: exchanged.unionId || null, + profile, + secretPayload: { + hasSessionKey: true, + sessionKeyUpdatedAt: new Date().toISOString(), + }, + }); + const session = await createLoginSession(client, { + tenantId, + userId: user.id, + provider, + ipAddress, + userAgent, + metadata: { + appId, + openId: exchanged.openId, + unionId: exchanged.unionId || null, + isNewUser, + }, + }); + + await writeLoginEvent(client, { + tenantId, + userId: user.id, + provider, + identifier: exchanged.openId, + result: 'success', + ipAddress, + userAgent, + metadata: { + appId, + unionId: exchanged.unionId || null, + isNewUser, + }, + }); + + return { + provider, + user, + isNewUser, + session, + identity: { + provider, + openId: exchanged.openId, + unionId: exchanged.unionId || null, + }, + }; + }); + + return result; +} + export async function oauthProviderPlaceholderRoute(ctx: RequestContext) { const provider = ctx.url.pathname.split('/').at(-1) || 'oauth'; + const normalized = normalizeOAuthProvider(provider); + const configured = await loadTenantAuthProviderConfig(await tenantIdFrom(ctx), oauthProviderAliases(normalized)); throw new HttpError( - 501, - `${provider} OAuth adapter is scaffolded but not configured. Store public config in tenant_auth_providers and secrets in app_private.tenant_secrets.`, - 'PROVIDER_NOT_CONFIGURED', + configured ? 501 : 503, + `${provider} OAuth adapter is not implemented yet. Store public config in tenant_auth_providers and secrets in app_private.tenant_secrets.`, + configured ? 'PROVIDER_NOT_IMPLEMENTED' : 'PROVIDER_NOT_CONFIGURED', ); } diff --git a/apps/api/src/features/auth/service.ts b/apps/api/src/features/auth/service.ts index b707a913..8c77411a 100644 --- a/apps/api/src/features/auth/service.ts +++ b/apps/api/src/features/auth/service.ts @@ -180,6 +180,195 @@ export async function upsertPhoneUser( return { user, isNewUser: true }; } +export async function upsertOAuthUser( + client: pg.PoolClient, + input: { + tenantId: string; + provider: string; + providerSubject: string; + openId?: string | null; + unionId?: string | null; + phone?: string | null; + email?: string | null; + profile?: Record; + secretPayload?: Record; + }, +) { + const existing = await client.query( + ` + select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl", + u.primary_role as "primaryRole", u.created_at as "createdAt" + from public.user_identities i + join public.platform_users u on u.id = i.user_id + where i.provider = $1 + and i.provider_subject = $2 + limit 1 + `, + [input.provider, input.providerSubject], + ); + + if (existing.rows[0]) { + const user = existing.rows[0]; + await client.query( + ` + update public.user_identities + set union_id = coalesce($3, union_id), + open_id = coalesce($4, open_id), + phone = coalesce($5, phone), + email = coalesce($6::citext, email), + secret_payload = case + when $7::jsonb = '{}'::jsonb then secret_payload + else secret_payload || $7::jsonb + end, + updated_at = now() + where provider = $1 and provider_subject = $2 + `, + [ + input.provider, + input.providerSubject, + input.unionId || null, + input.openId || null, + input.phone || null, + input.email || null, + JSON.stringify(input.secretPayload || {}), + ], + ); + await ensureStudentTenantRecords(client, input.tenantId, user.id); + return { user, isNewUser: false }; + } + + const unionMatch = input.unionId + ? await client.query( + ` + select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl", + u.primary_role as "primaryRole", u.created_at as "createdAt" + from public.user_identities i + join public.platform_users u on u.id = i.user_id + where i.union_id = $1 + and i.provider in ('wechat-miniapp', 'wechat_miniapp', 'wechat_web') + order by u.created_at asc + limit 1 + `, + [input.unionId], + ) + : null; + + const phoneMatch = + !unionMatch?.rows[0] && input.phone + ? await client.query( + ` + select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl", + u.primary_role as "primaryRole", u.created_at as "createdAt" + from public.platform_users u + where u.phone = $1 + order by u.created_at asc + limit 1 + `, + [input.phone], + ) + : null; + + const matchedUser = unionMatch?.rows[0] || phoneMatch?.rows[0] || null; + if (matchedUser) { + await client.query( + ` + insert into public.user_identities ( + user_id, provider, provider_subject, union_id, open_id, phone, email, secret_payload + ) + values ($1, $2, $3, $4, $5, $6, $7::citext, $8::jsonb) + on conflict (provider, provider_subject) + do update set user_id = excluded.user_id, + union_id = excluded.union_id, + open_id = excluded.open_id, + phone = excluded.phone, + email = excluded.email, + secret_payload = excluded.secret_payload, + updated_at = now() + `, + [ + matchedUser.id, + input.provider, + input.providerSubject, + input.unionId || null, + input.openId || null, + input.phone || matchedUser.phone || null, + input.email || null, + JSON.stringify(input.secretPayload || {}), + ], + ); + await ensureStudentTenantRecords(client, input.tenantId, matchedUser.id); + return { user: matchedUser, isNewUser: false }; + } + + const profile = input.profile || {}; + const nickname = + typeof profile.nickname === 'string' + ? profile.nickname + : typeof profile.nickName === 'string' + ? profile.nickName + : typeof profile.name === 'string' + ? profile.name + : null; + const avatarUrl = + typeof profile.avatarUrl === 'string' + ? profile.avatarUrl + : typeof profile.avatar_url === 'string' + ? profile.avatar_url + : null; + + const userResult = await client.query( + ` + insert into public.platform_users (username, phone, email, name, avatar_url, primary_role, raw_profile) + values ($1, $2, $3::citext, $4, $5, 'student', $6::jsonb) + returning id, username, phone, name, avatar_url as "avatarUrl", + primary_role as "primaryRole", created_at as "createdAt" + `, + [ + `${input.provider.replace(/[^a-z0-9]/gi, '_')}_${crypto.randomBytes(5).toString('hex')}`, + input.phone || null, + input.email || null, + nickname, + avatarUrl, + JSON.stringify({ + source: input.provider, + profile, + }), + ], + ); + + const user = userResult.rows[0]; + + await client.query( + ` + insert into public.user_identities ( + user_id, provider, provider_subject, union_id, open_id, phone, email, secret_payload + ) + values ($1, $2, $3, $4, $5, $6, $7::citext, $8::jsonb) + on conflict (provider, provider_subject) + do update set user_id = excluded.user_id, + union_id = excluded.union_id, + open_id = excluded.open_id, + phone = excluded.phone, + email = excluded.email, + secret_payload = excluded.secret_payload, + updated_at = now() + `, + [ + user.id, + input.provider, + input.providerSubject, + input.unionId || null, + input.openId || null, + input.phone || null, + input.email || null, + JSON.stringify(input.secretPayload || {}), + ], + ); + + await ensureStudentTenantRecords(client, input.tenantId, user.id); + return { user, isNewUser: true }; +} + export async function ensureStudentTenantRecords(client: pg.PoolClient, tenantId: string, userId: string) { await client.query( ` diff --git a/docs/refactor/auth-payment-provider-plan.md b/docs/refactor/auth-payment-provider-plan.md index a36359ad..e01bc054 100644 --- a/docs/refactor/auth-payment-provider-plan.md +++ b/docs/refactor/auth-payment-provider-plan.md @@ -19,31 +19,109 @@ Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先 - `POST /api/auth/sms/verify`:验证码登录,自动创建或复用 `platform_users`。 - `GET /api/auth/me`:通过 Bearer token 获取当前用户。 - `POST /api/auth/logout`:吊销迁移期 session。 -- `POST /api/auth/oauth/wechat`、`/wechat-miniapp`、`/qq`:provider 占位,已固定错误码 `PROVIDER_NOT_CONFIGURED`。 +- `POST /api/auth/oauth/wechat-miniapp`:微信小程序 `code2Session` 登录,后端换取 openid/unionid,签发 `tk_` session。 +- `POST /api/auth/oauth/wechat`、`/qq`:provider 占位,已固定 `PROVIDER_NOT_CONFIGURED` / `PROVIDER_NOT_IMPLEMENTED`。 - `tenant_auth_providers`:租户级公开认证配置。 - `sms_verification_codes`:验证码审计表,不保存明文 code。 - `auth_login_events`:登录事件审计。 - `app_private.auth_sessions`:迁移期 session token hash。 +- 阿里云短信 `SendSms` provider:使用租户级 AccessKey、签名、模板发送。 +- 腾讯云短信 `SendSms` provider:使用租户级 SecretId/SecretKey、SdkAppId、签名、模板发送。 ## 短信 Provider 本地默认是 `AUTH_SMS_PROVIDER=mock`,仅开发环境返回 `debugCode`。生产环境如果仍为 mock,会直接拒绝发送。 -后续真实 provider: +真实 provider: -- `aliyun`:接阿里云短信 `SendSms`,需要 AccessKey、签名、模板 ID。 -- `tencent`:接腾讯云短信 `SendSms`,需要 SecretId、SecretKey、SdkAppId、签名、模板 ID。 +- `aliyun` / `aliyun-sms`:阿里云短信 `SendSms`,需要 AccessKey、签名、模板 ID。 +- `tencent` / `tencent-sms`:腾讯云短信 `SendSms`,需要 SecretId、SecretKey、SdkAppId、签名、模板 ID。 密钥策略: - AccessKey/SecretKey 不进入 `tenant_settings.public_config`。 - 租户级密钥写 `app_private.tenant_secrets(secret_scope='sms')` 或生产 Vault。 - 前端只能看到 provider 是否启用、签名展示名、隐私协议链接等非敏感配置。 +- provider endpoint 默认使用官方域名,生产环境只允许 HTTPS 官方域名;本地测试可使用 `localhost/127.0.0.1` fake server。 + +### 阿里云短信配置示例 + +```json +{ + "provider": "aliyun", + "status": "active", + "configPublic": { + "signName": "工学教育", + "templateCode": "SMS_123456789", + "regionId": "cn-hangzhou" + }, + "secret": { + "secretScope": "sms", + "secretKey": "aliyun", + "secretJson": { + "accessKeyId": "LTAI...", + "accessKeySecret": "..." + } + } +} +``` + +### 腾讯云短信配置示例 + +```json +{ + "provider": "tencent", + "status": "active", + "configPublic": { + "smsSdkAppId": "1400000000", + "signName": "工学教育", + "templateId": "123456", + "region": "ap-guangzhou", + "templateParamSet": ["{code}"] + }, + "secret": { + "secretScope": "sms", + "secretKey": "tencent", + "secretJson": { + "secretId": "AKID...", + "secretKey": "..." + } + } +} +``` ## 微信/QQ 登录 微信小程序登录应由前端传 `wx.login` code 到 `/api/auth/oauth/wechat-miniapp`,后端调用微信 `code2Session` 换取 openid/session_key/unionid,再落 `user_identities`。 +已实现微信小程序登录主链路: + +- 前端传 `code` 和可选 `profile`。 +- 后端读取租户 `wechat-miniapp` / `wechat_miniapp` provider 配置。 +- `appId` 存在 `config_public`,`appSecret` 存在 `app_private.tenant_secrets(secret_scope='oauth')`。 +- `provider_subject` 使用 `appId:openid`,避免不同小程序 openid 碰撞。 +- `session_key` 不返回前端,不写入公开 `raw_profile`;当前只记录 `hasSessionKey` 和更新时间标记。 +- 登录成功写 `auth_login_events`,并签发 `tk_` session。 + +配置示例: + +```json +{ + "provider": "wechat-miniapp", + "displayName": "微信小程序登录", + "status": "active", + "configPublic": { + "appId": "wx...", + "envVersion": "release" + }, + "secret": { + "secretScope": "oauth", + "secretKey": "wechat-miniapp", + "secretValue": "小程序 AppSecret" + } +} +``` + 微信网页 OAuth 和 QQ OAuth 也必须在后端完成 code 换 token、获取 openid/unionid、验错、账号合并和登录事件审计。旧 PocketBase hooks 中的邀请码/销售归属逻辑后续应拆到 `referral` feature,不继续堆在 auth 模块里。 ## 支付 Provider diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 3a11f74d..a142abf1 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -35,9 +35,10 @@ | 能力 | 状态 | 说明 | | --- | --- | --- | -| 短信验证码登录 | 迁移期 | 已有验证码、冷却、hash、登录事件;mock provider 可本地联调 | +| 短信验证码登录 | 可联调 | 已有验证码、冷却、hash、登录事件;支持 mock、阿里云短信、腾讯云短信 provider,生产仍需真实账号联调 | | 迁移期 session | 迁移期 | `tk_` token hash 存在 `app_private.auth_sessions`,用户态接口已优先解析 bearer session 并拒绝伪造 userId/tenantId | -| 微信/QQ OAuth | 待补齐 | 目前是 placeholder | +| 微信小程序登录 | 可联调 | `/api/auth/oauth/wechat-miniapp` 已接 `code2Session`、openid/unionid 身份、session 签发和登录审计 | +| 微信网页/QQ OAuth | 待补齐 | 目前仍是 placeholder,需要 code 换 token、回调域名、账号合并和审计 | | 平台管理员鉴权 | 迁移期 | `x-platform-admin-key` 已可通过配置禁用;生产前必须换平台管理员 JWT/服务端会话 | | 租户角色权限 | 可联调 | `tenant_memberships.role + permissions`,接口有权限点校验 | | 自定义角色模板 | 待补齐 | 当前有权限 JSON 覆盖,缺角色模板、菜单/模块/字段级权限配置 UI/API | diff --git a/docs/refactor/multitenant-auth-security-contract.md b/docs/refactor/multitenant-auth-security-contract.md index fb23746d..da381ba8 100644 --- a/docs/refactor/multitenant-auth-security-contract.md +++ b/docs/refactor/multitenant-auth-security-contract.md @@ -26,6 +26,8 @@ - `x-user-id` 或 body/query 的 `userId` 只允许在 `ALLOW_LEGACY_AUTH_HEADERS=true` 的本地/迁移期环境使用。 - `x-platform-admin-key` 只允许在 `ALLOW_PLATFORM_ADMIN_KEY=true` 的本地/迁移期环境使用。 - 本地短信 provider 可使用 `mock`。 +- 真实短信 provider 已支持阿里云和腾讯云,密钥只能从 `app_private.tenant_secrets` 读取。 +- 微信小程序登录已由后端调用 `code2Session`,前端不得接触 AppSecret 或 session_key。 - `NODE_ENV=production` 下禁止 `ALLOW_LEGACY_AUTH_HEADERS=true`、`ALLOW_PLATFORM_ADMIN_KEY=true`、`AUTH_SMS_PROVIDER=mock`、默认/弱密钥和 `CORS_ORIGIN=*`。 这些只允许用于本地开发和内网联调,不允许作为正式云端验收方案。 @@ -70,6 +72,7 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 - 商户密钥、短信 secret、OAuth secret 不允许明文长期存储。 - 生产应使用 KMS/Vault 或 envelope encryption。 - API 只返回 `secretRef`、掩码和配置状态。 + - provider endpoint 生产环境必须使用 HTTPS 官方域名;本地测试才允许 `localhost/127.0.0.1` fake server。 7. RLS 与 API 双层回归 - 数据库 RLS 要按 `tenant_id` 拦截。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 51ef217a..81c90bfe 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -54,8 +54,9 @@ - 租户自有商户收款和平台代收/服务商模式。 2. 国内登录和短信 - - 阿里云短信、腾讯云短信 adapter。 - - 微信小程序登录、微信网页登录、QQ 登录。 + - 已完成阿里云短信、腾讯云短信 adapter 的后端实现和本地 fake endpoint 测试。 + - 已完成微信小程序 `code2Session` 登录主链路。 + - 继续补微信网页登录、QQ 登录、手机号绑定/换绑、真实生产账号联调。 - 旧 PocketBase 用户账号和新身份体系的映射/补绑。 3. 导入体系扩展 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index f1ece8e0..77df008e 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -129,7 +129,7 @@ tenant::theme | 页面 | 主要接口 | | --- | --- | | 启动页 | `GET /api/tenant/resolve` | -| 登录页 | `POST /api/auth/sms/send`、`POST /api/auth/sms/verify`、后续微信/QQ provider | +| 登录页 | `POST /api/auth/sms/send`、`POST /api/auth/sms/verify`、`POST /api/auth/oauth/wechat-miniapp`、后续微信网页/QQ provider | | 首页 | `/api/catalog/content-entries`、`/api/catalog/banners`、`/api/catalog/announcements`、`/api/profile/me` | | 选地区 | `/api/catalog/regions`、`/api/commerce/entitlements/check` | | 题库入口 | `/api/catalog/content-entries` | @@ -182,6 +182,69 @@ content_entries - 管理后台菜单按 `GET /api/tenant-admin/permissions` 和用户权限渲染。 - H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。 +## 登录对接 + +### 短信登录 + +开发环境可以先使用 mock 短信,接口会返回 `debugCode`。生产环境禁止依赖 `debugCode`。 + +```text +POST /api/auth/sms/send +body: { "phone": "13800000000", "purpose": "login" } + +POST /api/auth/sms/verify +body: { "phone": "13800000000", "code": "123456", "purpose": "login" } +``` + +成功后保存: + +```text +session.token +session.expiresAt +user +``` + +后续请求统一带: + +```text +Authorization: Bearer +x-tenant-id: +``` + +### 微信小程序登录 + +微信小程序端调用 `Taro.login()` 获取 code,然后交给后端: + +```text +POST /api/auth/oauth/wechat-miniapp +body: { + "code": "", + "profile": { + "nickName": "...", + "avatarUrl": "..." + } +} +``` + +成功响应包含: + +```text +provider +user +isNewUser +session.token +session.expiresAt +identity.openId +identity.unionId +``` + +注意: + +- 前端不接触 `appSecret`。 +- 前端不会拿到微信 `session_key`。 +- 如果登录前已经解析到推广码,登录成功后再调用 `/api/referral/bind` 完成首绑保护。 +- 手机号授权后续应走独立的“绑定手机号”接口,不要把微信手机号解密逻辑写在页面里。 + ## 第一阶段页面建议 1. `pages/bootstrap/index` diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 314c7d99..5e6b6b4e 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; +import http from 'node:http'; import net from 'node:net'; const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; @@ -36,6 +37,7 @@ let serverProcess = null; let serverLogs = ''; let legacyDisabledServer = null; let legacyDisabledServerLogs = ''; +let fakeWechatServer = null; function buildUrl(path, query = {}) { return buildUrlAt(apiBase, path, query); @@ -200,6 +202,56 @@ async function startLegacyDisabledServer() { return baseUrl; } +async function startFakeWechatServer() { + const port = await getFreePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const requests = []; + fakeWechatServer = http.createServer((req, res) => { + const url = new URL(req.url || '/', baseUrl); + requests.push({ + method: req.method, + pathname: url.pathname, + query: Object.fromEntries(url.searchParams.entries()), + }); + + if (url.pathname !== '/sns/jscode2session') { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ errcode: 404, errmsg: 'not found' })); + return; + } + + if ( + url.searchParams.get('appid') !== 'wx-smoke-appid' || + url.searchParams.get('secret') !== 'wechat-app-secret-smoke' || + url.searchParams.get('grant_type') !== 'authorization_code' + ) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ errcode: 40013, errmsg: 'invalid appid or secret' })); + return; + } + + const jsCode = url.searchParams.get('js_code') || 'unknown'; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + openid: `openid-${jsCode}`, + session_key: `session-key-${jsCode}`, + unionid: 'unionid-smoke-user', + }), + ); + }); + + await new Promise((resolve, reject) => { + fakeWechatServer.once('error', reject); + fakeWechatServer.listen(port, '127.0.0.1', resolve); + }); + + return { + endpoint: `${baseUrl}/sns/jscode2session`, + requests, + }; +} + async function waitForProcessExit(child, timeoutMs = 5000) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -366,6 +418,10 @@ function stopServer() { if (legacyDisabledServer && !legacyDisabledServer.killed) { legacyDisabledServer.kill(); } + if (fakeWechatServer) { + fakeWechatServer.close(); + fakeWechatServer = null; + } } async function testCatalogAndLearning() { @@ -1426,6 +1482,7 @@ async function testTenantContentAssetsAndImports() { } async function testTenantAdminOps() { + const fakeWechat = await startFakeWechatServer(); const denied = await request('/api/tenant-admin/branding', { method: 'PUT', body: { brandName: '学生不能改品牌' }, @@ -1477,6 +1534,7 @@ async function testTenantAdminOps() { configPublic: { appId: 'wx-smoke-appid', envVersion: 'trial', + endpoint: fakeWechat.endpoint, }, secret: { secretValue: 'wechat-app-secret-smoke', @@ -1488,6 +1546,32 @@ async function testTenantAdminOps() { assert.equal(authProvider.item?.secret?.hasSecretValue, true, 'auth provider should report masked secret status'); assert.ok(!JSON.stringify(authProvider).includes('wechat-app-secret-smoke'), 'auth provider response must not include secret plaintext'); + const miniappLogin = await request('/api/auth/oauth/wechat-miniapp', { + userId: false, + method: 'POST', + body: { + code: 'integration-code-001', + profile: { + nickName: '微信烟测学生', + avatarUrl: 'https://example.test/avatar.png', + }, + }, + }); + assert.equal(miniappLogin.provider, 'wechat-miniapp', 'wechat miniapp login should return provider'); + assert.ok(miniappLogin.user?.id, 'wechat miniapp login should create or resolve user'); + assert.ok(miniappLogin.session?.token?.startsWith('tk_'), 'wechat miniapp login should issue API session token'); + assert.equal(miniappLogin.identity?.openId, 'openid-integration-code-001', 'wechat miniapp login should expose openId'); + assert.equal(miniappLogin.identity?.unionId, 'unionid-smoke-user', 'wechat miniapp login should expose unionId'); + assert.equal(fakeWechat.requests.at(-1)?.query?.js_code, 'integration-code-001', 'wechat code should be exchanged server-side'); + assert.ok(!JSON.stringify(miniappLogin).includes('session-key-integration-code-001'), 'login response must not leak WeChat session_key'); + assert.ok(!JSON.stringify(miniappLogin).includes('wechat-app-secret-smoke'), 'login response must not leak app secret'); + + const miniappMe = await request('/api/auth/me', { + userId: false, + headers: { authorization: `Bearer ${miniappLogin.session.token}` }, + }); + assert.equal(miniappMe.user?.id, miniappLogin.user.id, 'wechat session should work with auth/me'); + const paymentAccount = await request('/api/tenant-admin/payment-accounts', { userId: TENANT_ADMIN_USER_ID, method: 'PUT',