forked from wangziqi/gongxue-base
feat: add web oauth login adapters
This commit is contained in:
@@ -3,9 +3,10 @@ import {
|
||||
bindPhoneRoute,
|
||||
logoutRoute,
|
||||
meRoute,
|
||||
oauthProviderPlaceholderRoute,
|
||||
qqLoginRoute,
|
||||
sendSmsCodeRoute,
|
||||
verifySmsCodeRoute,
|
||||
wechatWebLoginRoute,
|
||||
wechatMiniappLoginRoute,
|
||||
} from './routes.js';
|
||||
|
||||
@@ -15,7 +16,7 @@ export const authRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/auth/me', meRoute],
|
||||
['POST', '/api/auth/logout', logoutRoute],
|
||||
['POST', '/api/auth/phone/bind', bindPhoneRoute],
|
||||
['POST', '/api/auth/oauth/wechat', oauthProviderPlaceholderRoute],
|
||||
['POST', '/api/auth/oauth/wechat', wechatWebLoginRoute],
|
||||
['POST', '/api/auth/oauth/wechat-miniapp', wechatMiniappLoginRoute],
|
||||
['POST', '/api/auth/oauth/qq', oauthProviderPlaceholderRoute],
|
||||
['POST', '/api/auth/oauth/qq', qqLoginRoute],
|
||||
];
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
loadTenantAuthProviderConfig,
|
||||
optionalPublicString,
|
||||
providerEndpoint,
|
||||
providerEndpointForKeys,
|
||||
requirePublicString,
|
||||
requireSecretString,
|
||||
} from '../../core/tenant-provider-config.js';
|
||||
@@ -649,6 +650,134 @@ function oauthProviderAliases(value: string) {
|
||||
return [provider];
|
||||
}
|
||||
|
||||
function stringField(source: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function intField(source: Record<string, unknown>, keys: string[]) {
|
||||
const value = stringField(source, keys) || keys.map(key => source[key]).find(item => typeof item === 'number');
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function sanitizeOAuthProfilePayload(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(item => sanitizeOAuthProfilePayload(item));
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
const normalized = key.toLowerCase().replace(/[-_\s]/g, '');
|
||||
if (
|
||||
normalized.includes('accesstoken') ||
|
||||
normalized.includes('refreshtoken') ||
|
||||
normalized.includes('sessionkey') ||
|
||||
normalized.includes('secret') ||
|
||||
normalized.includes('clientsecret')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
sanitized[key] = sanitizeOAuthProfilePayload(child);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
async function parseOAuthProviderResponse(response: Response) {
|
||||
const text = await response.text();
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return {};
|
||||
|
||||
const tryJson = (candidate: string) => {
|
||||
try {
|
||||
return jsonObject(JSON.parse(candidate));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const directJson = tryJson(trimmed);
|
||||
if (directJson) return directJson;
|
||||
|
||||
const jsonStart = trimmed.indexOf('{');
|
||||
const jsonEnd = trimmed.lastIndexOf('}');
|
||||
if (jsonStart >= 0 && jsonEnd > jsonStart) {
|
||||
const jsonpJson = tryJson(trimmed.slice(jsonStart, jsonEnd + 1));
|
||||
if (jsonpJson) return jsonpJson;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(trimmed);
|
||||
const parsed = Object.fromEntries(params.entries());
|
||||
return jsonObject(parsed);
|
||||
}
|
||||
|
||||
async function callWechatWebAccessToken(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('code', input.code);
|
||||
url.searchParams.set('grant_type', 'authorization_code');
|
||||
|
||||
const response = await fetch(url);
|
||||
const raw = await parseOAuthProviderResponse(response);
|
||||
const errcode = Number(raw.errcode || 0);
|
||||
if (!response.ok || errcode) {
|
||||
const suffix = Number.isFinite(errcode) && errcode ? ` (${errcode})` : '';
|
||||
throw new HttpError(401, `WeChat web code exchange failed${suffix}`, 'WECHAT_WEB_CODE_EXCHANGE_FAILED');
|
||||
}
|
||||
|
||||
const accessToken = stringField(raw, ['access_token', 'accessToken']);
|
||||
const openId = stringField(raw, ['openid', 'openId']);
|
||||
const unionId = stringField(raw, ['unionid', 'unionId']);
|
||||
const scope = stringField(raw, ['scope']);
|
||||
const expiresIn = intField(raw, ['expires_in', 'expiresIn']);
|
||||
if (!accessToken || !openId) {
|
||||
throw new HttpError(401, 'WeChat web token response is missing access_token or openid', 'WECHAT_WEB_CODE_EXCHANGE_INVALID');
|
||||
}
|
||||
|
||||
return { accessToken, openId, unionId, scope, expiresIn };
|
||||
}
|
||||
|
||||
async function callWechatWebUserInfo(input: {
|
||||
endpoint: string;
|
||||
accessToken: string;
|
||||
openId: string;
|
||||
lang: string;
|
||||
}) {
|
||||
const url = new URL(input.endpoint);
|
||||
url.searchParams.set('access_token', input.accessToken);
|
||||
url.searchParams.set('openid', input.openId);
|
||||
url.searchParams.set('lang', input.lang);
|
||||
|
||||
const response = await fetch(url);
|
||||
const raw = await parseOAuthProviderResponse(response);
|
||||
const errcode = Number(raw.errcode || 0);
|
||||
if (!response.ok || errcode) {
|
||||
const suffix = Number.isFinite(errcode) && errcode ? ` (${errcode})` : '';
|
||||
throw new HttpError(401, `WeChat web userinfo failed${suffix}`, 'WECHAT_WEB_USERINFO_FAILED');
|
||||
}
|
||||
|
||||
const nickname = stringField(raw, ['nickname', 'name']);
|
||||
const avatarUrl = stringField(raw, ['headimgurl', 'avatarUrl', 'avatar_url']);
|
||||
const unionId = stringField(raw, ['unionid', 'unionId']);
|
||||
return {
|
||||
nickname,
|
||||
avatarUrl,
|
||||
unionId,
|
||||
profile: {
|
||||
...jsonObject(sanitizeOAuthProfilePayload(raw)),
|
||||
nickname,
|
||||
avatarUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function callWechatCode2Session(input: {
|
||||
endpoint: string;
|
||||
appId: string;
|
||||
@@ -761,13 +890,302 @@ export async function wechatMiniappLoginRoute(ctx: RequestContext) {
|
||||
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(
|
||||
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',
|
||||
export async function wechatWebLoginRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const code = requiredString(body, 'code');
|
||||
const clientProfile = jsonObject(body.profile);
|
||||
const lang = optionalString(body, 'lang') || 'zh_CN';
|
||||
const ipAddress = clientIpFrom(ctx);
|
||||
const userAgent = userAgentFrom(ctx);
|
||||
const providerConfig = await loadTenantAuthProviderConfig(tenantId, oauthProviderAliases('wechat-web'));
|
||||
if (!providerConfig) {
|
||||
throw new HttpError(503, 'WeChat web auth provider is not configured', 'PROVIDER_NOT_CONFIGURED');
|
||||
}
|
||||
|
||||
const appId = requirePublicString(providerConfig, ['appId'], 'OAUTH_PUBLIC_CONFIG_REQUIRED');
|
||||
const appSecret = requireSecretString(providerConfig, ['appSecret', 'clientSecret', 'secret'], 'OAUTH_SECRET_REQUIRED');
|
||||
const tokenEndpoint = providerEndpointForKeys(
|
||||
providerConfig,
|
||||
['tokenEndpoint', 'accessTokenEndpoint', 'endpoint'],
|
||||
'https://api.weixin.qq.com/sns/oauth2/access_token',
|
||||
['weixin.qq.com'],
|
||||
'OAUTH_ENDPOINT_NOT_ALLOWED',
|
||||
);
|
||||
const userInfoEndpoint = providerEndpointForKeys(
|
||||
providerConfig,
|
||||
['userInfoEndpoint', 'userinfoEndpoint'],
|
||||
'https://api.weixin.qq.com/sns/userinfo',
|
||||
['weixin.qq.com'],
|
||||
'OAUTH_ENDPOINT_NOT_ALLOWED',
|
||||
);
|
||||
|
||||
const token = await callWechatWebAccessToken({ endpoint: tokenEndpoint, appId, appSecret, code });
|
||||
const userInfo = await callWechatWebUserInfo({
|
||||
endpoint: userInfoEndpoint,
|
||||
accessToken: token.accessToken,
|
||||
openId: token.openId,
|
||||
lang,
|
||||
});
|
||||
const provider = 'wechat_web';
|
||||
const unionId = userInfo.unionId || token.unionId || null;
|
||||
const providerSubject = `${appId}:${token.openId}`;
|
||||
const profile = {
|
||||
...userInfo.profile,
|
||||
...jsonObject(sanitizeOAuthProfilePayload(clientProfile)),
|
||||
nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']),
|
||||
avatarUrl: userInfo.avatarUrl || stringField(clientProfile, ['avatarUrl', 'avatar_url']),
|
||||
};
|
||||
|
||||
return transaction(async client => {
|
||||
const { user, isNewUser } = await upsertOAuthUser(client, {
|
||||
tenantId,
|
||||
provider,
|
||||
providerSubject,
|
||||
openId: token.openId,
|
||||
unionId,
|
||||
profile,
|
||||
secretPayload: {
|
||||
tokenSeenAt: new Date().toISOString(),
|
||||
scope: token.scope || null,
|
||||
expiresIn: token.expiresIn,
|
||||
},
|
||||
});
|
||||
const session = await createLoginSession(client, {
|
||||
tenantId,
|
||||
userId: user.id,
|
||||
provider,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: {
|
||||
appId,
|
||||
openId: token.openId,
|
||||
unionId,
|
||||
isNewUser,
|
||||
},
|
||||
});
|
||||
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
userId: user.id,
|
||||
provider,
|
||||
identifier: token.openId,
|
||||
result: 'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: {
|
||||
appId,
|
||||
unionId,
|
||||
isNewUser,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
provider,
|
||||
user,
|
||||
isNewUser,
|
||||
session,
|
||||
identity: {
|
||||
provider,
|
||||
openId: token.openId,
|
||||
unionId,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function callQqAccessToken(input: {
|
||||
endpoint: string;
|
||||
appId: string;
|
||||
appSecret: string;
|
||||
code: string;
|
||||
redirectUri: string;
|
||||
}) {
|
||||
const url = new URL(input.endpoint);
|
||||
url.searchParams.set('grant_type', 'authorization_code');
|
||||
url.searchParams.set('client_id', input.appId);
|
||||
url.searchParams.set('client_secret', input.appSecret);
|
||||
url.searchParams.set('code', input.code);
|
||||
url.searchParams.set('redirect_uri', input.redirectUri);
|
||||
url.searchParams.set('fmt', 'json');
|
||||
|
||||
const response = await fetch(url);
|
||||
const raw = await parseOAuthProviderResponse(response);
|
||||
const error = stringField(raw, ['error']);
|
||||
if (!response.ok || error) {
|
||||
throw new HttpError(401, 'QQ code exchange failed', 'QQ_CODE_EXCHANGE_FAILED');
|
||||
}
|
||||
|
||||
const accessToken = stringField(raw, ['access_token', 'accessToken']);
|
||||
const expiresIn = intField(raw, ['expires_in', 'expiresIn']);
|
||||
if (!accessToken) {
|
||||
throw new HttpError(401, 'QQ token response is missing access_token', 'QQ_CODE_EXCHANGE_INVALID');
|
||||
}
|
||||
|
||||
return { accessToken, expiresIn };
|
||||
}
|
||||
|
||||
async function callQqOpenId(input: { endpoint: string; appId: string; accessToken: string }) {
|
||||
const url = new URL(input.endpoint);
|
||||
url.searchParams.set('access_token', input.accessToken);
|
||||
url.searchParams.set('fmt', 'json');
|
||||
|
||||
const response = await fetch(url);
|
||||
const raw = await parseOAuthProviderResponse(response);
|
||||
const error = stringField(raw, ['error']);
|
||||
if (!response.ok || error) {
|
||||
throw new HttpError(401, 'QQ openid exchange failed', 'QQ_OPENID_EXCHANGE_FAILED');
|
||||
}
|
||||
|
||||
const openId = stringField(raw, ['openid', 'openId']);
|
||||
const clientId = stringField(raw, ['client_id', 'clientId']);
|
||||
if (!openId) {
|
||||
throw new HttpError(401, 'QQ openid response is missing openid', 'QQ_OPENID_EXCHANGE_INVALID');
|
||||
}
|
||||
if (clientId && clientId !== input.appId) {
|
||||
throw new HttpError(401, 'QQ openid response client_id does not match appId', 'QQ_OPENID_CLIENT_MISMATCH');
|
||||
}
|
||||
|
||||
return { openId, clientId };
|
||||
}
|
||||
|
||||
async function callQqUserInfo(input: {
|
||||
endpoint: string;
|
||||
appId: string;
|
||||
accessToken: string;
|
||||
openId: string;
|
||||
}) {
|
||||
const url = new URL(input.endpoint);
|
||||
url.searchParams.set('access_token', input.accessToken);
|
||||
url.searchParams.set('oauth_consumer_key', input.appId);
|
||||
url.searchParams.set('openid', input.openId);
|
||||
url.searchParams.set('fmt', 'json');
|
||||
|
||||
const response = await fetch(url);
|
||||
const raw = await parseOAuthProviderResponse(response);
|
||||
const ret = Number(raw.ret || 0);
|
||||
if (!response.ok || ret !== 0) {
|
||||
throw new HttpError(401, 'QQ userinfo failed', 'QQ_USERINFO_FAILED');
|
||||
}
|
||||
|
||||
const nickname = stringField(raw, ['nickname', 'name']);
|
||||
const avatarUrl = stringField(raw, ['figureurl_qq_2', 'figureurl_qq_1', 'figureurl_2', 'figureurl_1', 'avatarUrl']);
|
||||
return {
|
||||
nickname,
|
||||
avatarUrl,
|
||||
profile: {
|
||||
...jsonObject(sanitizeOAuthProfilePayload(raw)),
|
||||
nickname,
|
||||
avatarUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function qqLoginRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const code = requiredString(body, 'code');
|
||||
const ipAddress = clientIpFrom(ctx);
|
||||
const userAgent = userAgentFrom(ctx);
|
||||
const providerConfig = await loadTenantAuthProviderConfig(tenantId, oauthProviderAliases('qq'));
|
||||
if (!providerConfig) {
|
||||
throw new HttpError(503, 'QQ auth provider is not configured', 'PROVIDER_NOT_CONFIGURED');
|
||||
}
|
||||
|
||||
const appId = requirePublicString(providerConfig, ['appId', 'clientId'], 'OAUTH_PUBLIC_CONFIG_REQUIRED');
|
||||
const appSecret = requireSecretString(providerConfig, ['appSecret', 'clientSecret', 'secret'], 'OAUTH_SECRET_REQUIRED');
|
||||
const redirectUri = optionalPublicString(providerConfig, ['redirectUri', 'callbackUrl']) || optionalString(body, 'redirectUri');
|
||||
if (!redirectUri) {
|
||||
throw new HttpError(400, 'QQ OAuth redirectUri is required for code exchange', 'OAUTH_REDIRECT_URI_REQUIRED');
|
||||
}
|
||||
const tokenEndpoint = providerEndpointForKeys(
|
||||
providerConfig,
|
||||
['tokenEndpoint', 'accessTokenEndpoint', 'endpoint'],
|
||||
'https://graph.qq.com/oauth2.0/token',
|
||||
['graph.qq.com'],
|
||||
'OAUTH_ENDPOINT_NOT_ALLOWED',
|
||||
);
|
||||
const openIdEndpoint = providerEndpointForKeys(
|
||||
providerConfig,
|
||||
['openIdEndpoint', 'openidEndpoint'],
|
||||
'https://graph.qq.com/oauth2.0/me',
|
||||
['graph.qq.com'],
|
||||
'OAUTH_ENDPOINT_NOT_ALLOWED',
|
||||
);
|
||||
const userInfoEndpoint = providerEndpointForKeys(
|
||||
providerConfig,
|
||||
['userInfoEndpoint', 'userinfoEndpoint'],
|
||||
'https://graph.qq.com/user/get_user_info',
|
||||
['graph.qq.com'],
|
||||
'OAUTH_ENDPOINT_NOT_ALLOWED',
|
||||
);
|
||||
|
||||
const token = await callQqAccessToken({ endpoint: tokenEndpoint, appId, appSecret, code, redirectUri });
|
||||
const openId = await callQqOpenId({ endpoint: openIdEndpoint, appId, accessToken: token.accessToken });
|
||||
const userInfo = await callQqUserInfo({
|
||||
endpoint: userInfoEndpoint,
|
||||
appId,
|
||||
accessToken: token.accessToken,
|
||||
openId: openId.openId,
|
||||
});
|
||||
const provider = 'qq';
|
||||
const providerSubject = `${appId}:${openId.openId}`;
|
||||
const clientProfile = jsonObject(body.profile);
|
||||
const profile = {
|
||||
...userInfo.profile,
|
||||
...jsonObject(sanitizeOAuthProfilePayload(clientProfile)),
|
||||
nickname: userInfo.nickname || stringField(clientProfile, ['nickname', 'nickName', 'name']),
|
||||
avatarUrl: userInfo.avatarUrl || stringField(clientProfile, ['avatarUrl', 'avatar_url']),
|
||||
};
|
||||
|
||||
return transaction(async client => {
|
||||
const { user, isNewUser } = await upsertOAuthUser(client, {
|
||||
tenantId,
|
||||
provider,
|
||||
providerSubject,
|
||||
openId: openId.openId,
|
||||
profile,
|
||||
secretPayload: {
|
||||
tokenSeenAt: new Date().toISOString(),
|
||||
expiresIn: token.expiresIn,
|
||||
},
|
||||
});
|
||||
const session = await createLoginSession(client, {
|
||||
tenantId,
|
||||
userId: user.id,
|
||||
provider,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: {
|
||||
appId,
|
||||
openId: openId.openId,
|
||||
isNewUser,
|
||||
},
|
||||
});
|
||||
|
||||
await writeLoginEvent(client, {
|
||||
tenantId,
|
||||
userId: user.id,
|
||||
provider,
|
||||
identifier: openId.openId,
|
||||
result: 'success',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
metadata: {
|
||||
appId,
|
||||
isNewUser,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
provider,
|
||||
user,
|
||||
isNewUser,
|
||||
session,
|
||||
identity: {
|
||||
provider,
|
||||
openId: openId.openId,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user