feat: add china auth providers

This commit is contained in:
Codex
2026-06-28 22:19:46 +08:00
parent 523b63c53b
commit dee82e670f
11 changed files with 1011 additions and 26 deletions

View File

@@ -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],
];

View File

@@ -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<string, unknown>;
}
export interface TenantAuthProviderConfig {
tenantId: string;
provider: string;
status: 'active' | 'testing' | 'disabled';
configPublic: Record<string, unknown>;
secret: TenantSecretConfig | null;
}
interface TenantAuthProviderRow {
provider: string;
status: 'active' | 'testing' | 'disabled';
configPublic: Record<string, unknown> | null;
}
interface TenantSecretRow {
secretScope: TenantSecretScope;
secretKey: string;
secretValue: string | null;
secretJson: Record<string, unknown> | null;
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
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<string, unknown>,
provider: string,
): Promise<TenantSecretConfig | null> {
const secretRef = parseSecretRef(configPublic.secretRef);
const scope = secretRef?.scope || defaultSecretScope(provider);
const key = secretRef?.key || provider;
const row = await queryOne<TenantSecretRow>(
`
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<TenantAuthProviderConfig | null> {
const uniqueProviders = [...new Set(providers.map(item => item.trim()).filter(Boolean))];
if (uniqueProviders.length === 0) return null;
const rows = await query<TenantAuthProviderRow>(
`
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);
}

View File

@@ -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<SmsSendResult>;
}
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<string, string>) {
return Object.keys(params)
.sort()
.map(key => `${encodeRFC3986(key)}=${encodeRFC3986(params[key] ?? '')}`)
.join('&');
}
function safeRaw(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function maskedProviderError(provider: string, status: number, raw: Record<string, unknown>) {
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<string, string> = {};
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<SmsSendResult> {
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<SmsSendResult> {
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<SmsSendResult> {
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'];
}

View File

@@ -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<string, unknown>) : {};
}
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',
);
}

View File

@@ -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<string, unknown>;
secretPayload?: Record<string, unknown>;
},
) {
const existing = await client.query<PlatformUserSummary>(
`
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<PlatformUserSummary>(
`
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<PlatformUserSummary>(
`
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<PlatformUserSummary>(
`
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(
`