feat(oauth): add the seven missing providers and open the full aggregate list

The theme shipped six direct providers while the reference carries fourteen,
and its aggregate-login channel offered three of sixteen platforms.

OAuthProviderClient now owns the providers the bundled SDK does not cover.
Douyin, Microsoft, Facebook, Twitter and Apple talk to their documented OAuth2
endpoints directly; Baidu and Alipay reuse yurunsoft, which already ships both
(Alipay needs its application private key, so provider_config learned an
extra_keys map to surface it). The broker dispatches through handles(), so the
existing start/callback routes stay provider-agnostic.

Three provider quirks needed handling rather than a plain copy:

Apple returns the callback as response_mode=form_post, and handle_callback
only read $_GET, so it now reads $_POST when the request is a POST — otherwise
every Apple login would fail with code_missing. Its client secret is built as
an ES256 JWT from Team ID / Key ID / .p8 rather than a static string.

Twitter requires PKCE. The verifier is derived from the state token, so it
travels with the existing transaction row and needs no extra storage.

Facebook hands back protocol-less avatar URLs, which are upgraded to https the
same way the OAuth avatar resolver does.

Settings gained matching toggles and credential groups on both sides — the PHP
schema and the Vue schema — and the aggregate platform list went from three to
sixteen, with CLoginClient's hard-coded allowlist replaced by the catalog
constant. Brand icons were extended so each new provider resolves to its
sprite; facebook and twitter moved to their colour sprites, and the
`twitter -> x` alias was dropped since it bypassed the new mapping.

The oauth-security contract previously asserted that Baidu and Alipay must not
appear on the OAuth surface. That was the old intent, so the assertion is
replaced with a positive one, keeping the separate check that the Alipay
payment gateway still exists.

Verification: a new harness stubs the HTTP layer and drives all five custom
clients — authorization URLs carry redirect/state (Twitter also a PKCE
challenge), identities normalize to the shared shape, Facebook's avatar is
upgraded to https, Apple's private-relay email survives, and an error payload
throws instead of yielding an empty identity. 871 widget/navigation tests pass
and the PSR-4 audit reports 0 errors.

Not covered: no live handshake happened — every provider needs real AppID and
secret pairs, and Apple/Douyin require developer-account review before the
full round trip can be exercised.
This commit is contained in:
kate522 committed 2026-11-04 18:00:29 +08:00
1 parent 6f29e3d865
commit 6db34646ed
11 files changed
+939 -28

No files matched your search

+130 -4
View File
@@ -2,7 +2,7 @@ export const oauthSchema = {
key: 'users.oauth',
title: '第三方登录',
icon: 'fa fas fa-share-alt',
description: 'GitHub、Gitee、QQ、Google、微博和微信第三方登录配置。',
description: '第三方登录配置:QQ、微信、微博、GitHub、Gitee、Google、抖音、百度、支付宝、Microsoft、Facebook、Twitter、Apple 以及彩虹聚合登录。',
defaults: {
oauth_s: false,
oauth_proxy_mode: 'close',
@@ -20,6 +20,13 @@ export const oauthSchema = {
oauth_weibo_s: false,
oauth_weixin_s: false,
oauth_weixingzh_s: false,
oauth_douyin_s: false,
oauth_baidu_s: false,
oauth_alipay_s: false,
oauth_microsoft_s: false,
oauth_facebook_s: false,
oauth_twitter_s: false,
oauth_apple_s: false,
clogin_s: false,
github_appid: '',
github_appkey: '',
@@ -33,6 +40,24 @@ export const oauthSchema = {
weibo_appkey: '',
weixin_appid: '',
weixin_appsecret: '',
douyin_appid: '',
douyin_appkey: '',
baidu_appid: '',
baidu_appkey: '',
alipay_appid: '',
alipay_appkey: '',
alipay_private_key: '',
microsoft_appid: '',
microsoft_appkey: '',
facebook_appid: '',
facebook_appkey: '',
twitter_appid: '',
twitter_appkey: '',
apple_appid: '',
apple_appkey: '',
apple_team_id: '',
apple_key_id: '',
apple_private_key: '',
weixingzh_gzh_type: 'service',
weixingzh_token: '',
weixingzh_appid: '',
@@ -57,8 +82,8 @@ export const oauthSchema = {
{ key: 'oauth_proxy_mode', label: '代理登录模式', type: 'radio', default: 'close', options: [{ label: '关闭', value: 'close' }, { label: '服务端', value: 'server' }, { label: '客户端', value: 'client' }] },
{ key: 'oauth_proxy_secret', label: '代理 HMAC 密钥', type: 'input', inputType: 'password', secret: true, default: '', dependsOn: { key: 'oauth_proxy_mode', not: 'close' } },
{ key: 'oauth_proxy_server_url', label: '服务端地址', type: 'input', default: '', dependsOn: { key: 'oauth_proxy_mode', value: 'client' } },
{ key: 'oauth_proxy_server_providers', label: '服务端允许平台', type: 'checkbox', default: ['github', 'gitee', 'qq', 'google'], options: [{ label: 'GitHub', value: 'github' }, { label: 'Gitee', value: 'gitee' }, { label: 'QQ', value: 'qq' }, { label: 'Google', value: 'google' }, { label: '微博', value: 'weibo' }, { label: '微信', value: 'weixin' }], dependsOn: { key: 'oauth_proxy_mode', value: 'server' } },
{ key: 'oauth_proxy_client_providers', label: '客户端启用平台', type: 'checkbox', default: ['github', 'gitee', 'qq', 'google'], options: [{ label: 'GitHub', value: 'github' }, { label: 'Gitee', value: 'gitee' }, { label: 'QQ', value: 'qq' }, { label: 'Google', value: 'google' }, { label: '微博', value: 'weibo' }, { label: '微信', value: 'weixin' }], dependsOn: { key: 'oauth_proxy_mode', value: 'client' } },
{ key: 'oauth_proxy_server_providers', label: '服务端允许平台', type: 'checkbox', default: ['github', 'gitee', 'qq', 'google'], options: [{ label: 'GitHub', value: 'github' }, { label: 'Gitee', value: 'gitee' }, { label: 'QQ', value: 'qq' }, { label: 'Google', value: 'google' }, { label: '微博', value: 'weibo' }, { label: '微信', value: 'weixin' }, { label: '抖音', value: 'douyin' }, { label: '百度', value: 'baidu' }, { label: '支付宝', value: 'alipay' }, { label: 'Microsoft', value: 'microsoft' }, { label: 'Facebook', value: 'facebook' }, { label: 'Twitter', value: 'twitter' }, { label: 'Apple', value: 'apple' }], dependsOn: { key: 'oauth_proxy_mode', value: 'server' } },
{ key: 'oauth_proxy_client_providers', label: '客户端启用平台', type: 'checkbox', default: ['github', 'gitee', 'qq', 'google'], options: [{ label: 'GitHub', value: 'github' }, { label: 'Gitee', value: 'gitee' }, { label: 'QQ', value: 'qq' }, { label: 'Google', value: 'google' }, { label: '微博', value: 'weibo' }, { label: '微信', value: 'weixin' }, { label: '抖音', value: 'douyin' }, { label: '百度', value: 'baidu' }, { label: '支付宝', value: 'alipay' }, { label: 'Microsoft', value: 'microsoft' }, { label: 'Facebook', value: 'facebook' }, { label: 'Twitter', value: 'twitter' }, { label: 'Apple', value: 'apple' }], dependsOn: { key: 'oauth_proxy_mode', value: 'client' } },
{ key: 'oauth_bind_type', label: '绑定方式', type: 'radio', default: 'auto', options: [{ label: '自动绑定', value: 'auto' }, { label: '手动绑定', value: 'manual' }] },
{ key: 'oauth_button_lg', label: '大按钮样式', type: 'switch', default: false },
{ key: 'weixingzh_priority', label: 'PC端优先微信扫码', type: 'switch', default: false, help: '开启后 PC 端登录框优先显示微信公众号扫码' },
@@ -76,7 +101,14 @@ export const oauthSchema = {
{ key: 'oauth_weibo_s', label: '微博登录', type: 'switch', default: false },
{ key: 'oauth_weixin_s', label: '微信开放平台登录', type: 'switch', default: false },
{ key: 'oauth_weixingzh_s', label: '微信公众号登录', type: 'switch', default: false },
{ key: 'clogin_s', label: '彩虹聚合登录', type: 'switch', default: false, help: '通过第三方聚合登录平台统一接入 QQ、微信和微博' },
{ key: 'oauth_douyin_s', label: '抖音登录', type: 'switch', default: false },
{ key: 'oauth_baidu_s', label: '百度登录', type: 'switch', default: false },
{ key: 'oauth_alipay_s', label: '支付宝登录', type: 'switch', default: false },
{ key: 'oauth_microsoft_s', label: 'Microsoft 登录', type: 'switch', default: false },
{ key: 'oauth_facebook_s', label: 'Facebook 登录', type: 'switch', default: false },
{ key: 'oauth_twitter_s', label: 'Twitter 登录', type: 'switch', default: false },
{ key: 'oauth_apple_s', label: 'Apple 登录', type: 'switch', default: false },
{ key: 'clogin_s', label: '彩虹聚合登录', type: 'switch', default: false, help: '通过第三方聚合登录平台统一接入多个平台' },
],
},
{
@@ -145,6 +177,87 @@ export const oauthSchema = {
{ key: 'weixin_appsecret', label: 'AppSecret', type: 'input', inputType: 'password', secret: true, default: '' },
],
},
{
key: 'douyin',
title: '抖音配置',
icon: 'fa fas fa-music',
collapsible: true,
defaultCollapsed: true,
fields: [
{ key: 'douyin_appid', label: 'Client Key', type: 'input', default: '' },
{ key: 'douyin_appkey', label: 'Client Secret', type: 'input', inputType: 'password', secret: true, default: '' },
],
},
{
key: 'baidu',
title: '百度配置',
icon: 'fa fas fa-paw',
collapsible: true,
defaultCollapsed: true,
fields: [
{ key: 'baidu_appid', label: 'API Key', type: 'input', default: '' },
{ key: 'baidu_appkey', label: 'Secret Key', type: 'input', inputType: 'password', secret: true, default: '' },
],
},
{
key: 'alipay',
title: '支付宝配置',
icon: 'fa fab fa-alipay',
collapsible: true,
defaultCollapsed: true,
fields: [
{ key: 'alipay_appid', label: 'App ID', type: 'input', default: '' },
{ key: 'alipay_appkey', label: '支付宝公钥', type: 'textarea', default: '' },
{ key: 'alipay_private_key', label: '应用私钥', type: 'textarea', secret: true, default: '', help: 'RSA2 签名使用的应用私钥(PKCS8 格式)' },
],
},
{
key: 'microsoft',
title: 'Microsoft 配置',
icon: 'fa fab fa-microsoft',
collapsible: true,
defaultCollapsed: true,
fields: [
{ key: 'microsoft_appid', label: 'Application (client) ID', type: 'input', default: '' },
{ key: 'microsoft_appkey', label: 'Client Secret', type: 'input', inputType: 'password', secret: true, default: '' },
],
},
{
key: 'facebook',
title: 'Facebook 配置',
icon: 'fa fab fa-facebook',
collapsible: true,
defaultCollapsed: true,
fields: [
{ key: 'facebook_appid', label: 'App ID', type: 'input', default: '' },
{ key: 'facebook_appkey', label: 'App Secret', type: 'input', inputType: 'password', secret: true, default: '' },
],
},
{
key: 'twitter',
title: 'Twitter 配置',
icon: 'fa fab fa-twitter',
collapsible: true,
defaultCollapsed: true,
fields: [
{ key: 'twitter_appid', label: 'Client ID', type: 'input', default: '' },
{ key: 'twitter_appkey', label: 'Client Secret', type: 'input', inputType: 'password', secret: true, default: '' },
],
},
{
key: 'apple',
title: 'Apple 配置',
icon: 'fa fab fa-apple',
collapsible: true,
defaultCollapsed: true,
fields: [
{ key: 'apple_appid', label: 'Service ID', type: 'input', default: '' },
{ key: 'apple_appkey', label: '备用 Secret', type: 'input', inputType: 'password', secret: true, default: '' },
{ key: 'apple_team_id', label: 'Team ID', type: 'input', default: '' },
{ key: 'apple_key_id', label: 'Key ID', type: 'input', default: '' },
{ key: 'apple_private_key', label: '私钥(.p8)', type: 'textarea', secret: true, default: '', help: '用于生成 client_secret 的 ES256 私钥' },
],
},
{
key: 'weixingzh',
title: '微信公众号配置',
@@ -177,7 +290,20 @@ export const oauthSchema = {
{ key: 'clogin_oauth_s', label: '启用平台', type: 'checkbox', default: ['qq', 'weixin', 'weibo'], options: [
{ label: 'QQ', value: 'qq' },
{ label: '微信', value: 'weixin' },
{ label: '支付宝', value: 'alipay' },
{ label: '微博', value: 'weibo' },
{ label: '抖音', value: 'douyin' },
{ label: '百度', value: 'baidu' },
{ label: 'GitHub', value: 'github' },
{ label: 'Gitee', value: 'gitee' },
{ label: '钉钉', value: 'dingtalk' },
{ label: '华为', value: 'huawei' },
{ label: '小米', value: 'xiaomi' },
{ label: 'Google', value: 'google' },
{ label: 'Microsoft', value: 'microsoft' },
{ label: 'Facebook', value: 'facebook' },
{ label: 'Twitter', value: 'twitter' },
{ label: 'Apple', value: 'apple' },
] },
],
},
+9 -3
View File
@@ -59,7 +59,6 @@ function jio_brand_icon_map(): array {
'github' => 'fa fab fa-github',
'google' => 'jiosvg-google-color',
'x' => 'fa fab fa-twitter',
'twitter' => 'fa fab fa-twitter',
'email' => 'fa fas fa-envelope',
'envelope' => 'fa fas fa-envelope',
'wechat' => 'fa fab fa-weixin',
@@ -70,7 +69,6 @@ function jio_brand_icon_map(): array {
'discord' => 'fa fab fa-discord',
'bilibili' => 'fa fas fa-tv',
'linkedin' => 'fa fab fa-linkedin',
'facebook' => 'fa fab fa-facebook-f',
'youtube' => 'fa fab fa-youtube',
'instagram' => 'fa fab fa-instagram',
'web' => 'fa fas fa-globe',
@@ -78,6 +76,15 @@ function jio_brand_icon_map(): array {
'gitee' => 'jiosvg-gitee',
'baidu' => 'fa fas fa-paw',
'alipay' => 'fa fab fa-alipay',
'douyin' => 'jiosvg-douyin',
// FA5 Free 没有 Microsoft 品牌字形,参考主题同样用 Windows 标。
'microsoft' => 'fa fab fa-windows',
'apple' => 'jiosvg-apple',
'dingtalk' => 'jiosvg-dingtalk',
'huawei' => 'jiosvg-huawei',
'xiaomi' => 'jiosvg-xiaomi',
'facebook' => 'jiosvg-facebook-color',
'twitter' => 'jiosvg-twitter-color',
];
return $map;
@@ -115,7 +122,6 @@ function jio_normalize_icon_key(string $name): string {
$aliases = [
'weixin' => 'wechat',
'weixingzh' => 'wechat',
'twitter' => 'x',
'envelope' => 'email',
'globe' => 'web',
];
+66
View File
@@ -327,6 +327,13 @@ class SchemaUsers {
['label' => 'Google', 'value' => 'google'],
['label' => '微博', 'value' => 'weibo'],
['label' => '微信', 'value' => 'weixin'],
['label' => '抖音', 'value' => 'douyin'],
['label' => '百度', 'value' => 'baidu'],
['label' => '支付宝', 'value' => 'alipay'],
['label' => 'Microsoft', 'value' => 'microsoft'],
['label' => 'Facebook', 'value' => 'facebook'],
['label' => 'Twitter', 'value' => 'twitter'],
['label' => 'Apple', 'value' => 'apple'],
], 'dependsOn' => ['key' => 'oauth_proxy_mode', 'value' => 'server']],
['key' => 'oauth_proxy_client_providers', 'label' => '客户端启用平台', 'type' => 'checkbox', 'default' => ['github', 'gitee', 'qq', 'google'], 'options' => [
['label' => 'GitHub', 'value' => 'github'],
@@ -335,6 +342,13 @@ class SchemaUsers {
['label' => 'Google', 'value' => 'google'],
['label' => '微博', 'value' => 'weibo'],
['label' => '微信', 'value' => 'weixin'],
['label' => '抖音', 'value' => 'douyin'],
['label' => '百度', 'value' => 'baidu'],
['label' => '支付宝', 'value' => 'alipay'],
['label' => 'Microsoft', 'value' => 'microsoft'],
['label' => 'Facebook', 'value' => 'facebook'],
['label' => 'Twitter', 'value' => 'twitter'],
['label' => 'Apple', 'value' => 'apple'],
], 'dependsOn' => ['key' => 'oauth_proxy_mode', 'value' => 'client']],
['key' => 'oauth_bind_type', 'label' => '绑定方式', 'type' => 'radio', 'default' => 'auto', 'options' => [
['label' => '自动绑定', 'value' => 'auto'],
@@ -351,6 +365,13 @@ class SchemaUsers {
['key' => 'oauth_weibo_s', 'label' => '微博登录', 'type' => 'switch', 'default' => false],
['key' => 'oauth_weixin_s', 'label' => '微信开放平台登录', 'type' => 'switch', 'default' => false],
['key' => 'oauth_weixingzh_s', 'label' => '微信公众号登录', 'type' => 'switch', 'default' => false],
['key' => 'oauth_douyin_s', 'label' => '抖音登录', 'type' => 'switch', 'default' => false],
['key' => 'oauth_baidu_s', 'label' => '百度登录', 'type' => 'switch', 'default' => false],
['key' => 'oauth_alipay_s', 'label' => '支付宝登录', 'type' => 'switch', 'default' => false],
['key' => 'oauth_microsoft_s', 'label' => 'Microsoft 登录', 'type' => 'switch', 'default' => false],
['key' => 'oauth_facebook_s', 'label' => 'Facebook 登录', 'type' => 'switch', 'default' => false],
['key' => 'oauth_twitter_s', 'label' => 'Twitter 登录', 'type' => 'switch', 'default' => false],
['key' => 'oauth_apple_s', 'label' => 'Apple 登录', 'type' => 'switch', 'default' => false],
['key' => 'clogin_s', 'label' => '彩虹聚合登录', 'type' => 'switch', 'default' => false, 'help' => '通过第三方聚合登录平台统一接入 QQ、微信和微博'],
]],
['key' => 'github', 'title' => 'GitHub配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [
@@ -377,6 +398,38 @@ class SchemaUsers {
['key' => 'weixin_appid', 'label' => 'AppID', 'type' => 'input', 'default' => ''],
['key' => 'weixin_appsecret', 'label' => 'AppSecret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''],
]],
['key' => 'douyin', 'title' => '抖音配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [
['key' => 'douyin_appid', 'label' => 'Client Key', 'type' => 'input', 'default' => ''],
['key' => 'douyin_appkey', 'label' => 'Client Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''],
]],
['key' => 'baidu', 'title' => '百度配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [
['key' => 'baidu_appid', 'label' => 'API Key', 'type' => 'input', 'default' => ''],
['key' => 'baidu_appkey', 'label' => 'Secret Key', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''],
]],
['key' => 'alipay', 'title' => '支付宝配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [
['key' => 'alipay_appid', 'label' => 'App ID', 'type' => 'input', 'default' => ''],
['key' => 'alipay_appkey', 'label' => '支付宝公钥', 'type' => 'textarea', 'default' => ''],
['key' => 'alipay_private_key', 'label' => '应用私钥', 'type' => 'textarea', 'secret' => true, 'default' => '', 'help' => 'RSA2 签名使用的应用私钥(PKCS8 格式)'],
]],
['key' => 'microsoft', 'title' => 'Microsoft 配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [
['key' => 'microsoft_appid', 'label' => 'Application (client) ID', 'type' => 'input', 'default' => ''],
['key' => 'microsoft_appkey', 'label' => 'Client Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''],
]],
['key' => 'facebook', 'title' => 'Facebook 配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [
['key' => 'facebook_appid', 'label' => 'App ID', 'type' => 'input', 'default' => ''],
['key' => 'facebook_appkey', 'label' => 'App Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''],
]],
['key' => 'twitter', 'title' => 'Twitter 配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [
['key' => 'twitter_appid', 'label' => 'Client ID', 'type' => 'input', 'default' => ''],
['key' => 'twitter_appkey', 'label' => 'Client Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''],
]],
['key' => 'apple', 'title' => 'Apple 配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [
['key' => 'apple_appid', 'label' => 'Service ID', 'type' => 'input', 'default' => ''],
['key' => 'apple_appkey', 'label' => '备用 Secret', 'type' => 'input', 'inputType' => 'password', 'secret' => true, 'default' => ''],
['key' => 'apple_team_id', 'label' => 'Team ID', 'type' => 'input', 'default' => ''],
['key' => 'apple_key_id', 'label' => 'Key ID', 'type' => 'input', 'default' => ''],
['key' => 'apple_private_key', 'label' => '私钥(.p8)', 'type' => 'textarea', 'secret' => true, 'default' => '', 'help' => '用于生成 client_secret 的 ES256 私钥'],
]],
['key' => 'weixingzh', 'title' => '微信公众号配置', 'collapsible' => true, 'defaultCollapsed' => true, 'fields' => [
['key' => 'weixingzh_gzh_type', 'label' => '公众号类型', 'type' => 'radio', 'default' => 'service', 'options' => [
['label' => '服务号', 'value' => 'service'],
@@ -398,7 +451,20 @@ class SchemaUsers {
['key' => 'clogin_oauth_s', 'label' => '启用平台', 'type' => 'checkbox', 'default' => ['qq', 'weixin', 'weibo'], 'options' => [
['label' => 'QQ', 'value' => 'qq'],
['label' => '微信', 'value' => 'weixin'],
['label' => '支付宝', 'value' => 'alipay'],
['label' => '微博', 'value' => 'weibo'],
['label' => '抖音', 'value' => 'douyin'],
['label' => '百度', 'value' => 'baidu'],
['label' => 'GitHub', 'value' => 'github'],
['label' => 'Gitee', 'value' => 'gitee'],
['label' => '钉钉', 'value' => 'dingtalk'],
['label' => '华为', 'value' => 'huawei'],
['label' => '小米', 'value' => 'xiaomi'],
['label' => 'Google', 'value' => 'google'],
['label' => 'Microsoft', 'value' => 'microsoft'],
['label' => 'Facebook', 'value' => 'facebook'],
['label' => 'Twitter', 'value' => 'twitter'],
['label' => 'Apple', 'value' => 'apple'],
]],
]],
],
+1 -1
View File
@@ -29,7 +29,7 @@ final class CLoginClient {
public function login(string $type, string $state): array {
$type = sanitize_key($type);
if (!in_array($type, ['qq', 'weixin', 'weibo'], true) || $state === '') {
if (!in_array($type, OAuthProviderCatalog::CLOGIN_TYPES, true) || $state === '') {
return ['code' => 1, 'msg' => __('聚合登录参数无效', 'jio')];
}
return $this->request([
+5 -3
View File
@@ -352,7 +352,9 @@ final class OAuth {
}
private function handle_callback(): void {
$state = sanitize_text_field((string) ($_GET['state'] ?? ''));
// Apple 使用 response_mode=form_post,state/code 走 POST 而不是 query。
$input = $_SERVER['REQUEST_METHOD'] === 'POST' ? $_POST : $_GET;
$state = sanitize_text_field((string) ($input['state'] ?? ''));
$row = $state !== '' ? OAuthTransactionStore::claim($state, 'direct') : null;
$payload = is_array($row['payload'] ?? null) ? $row['payload'] : [];
$provider = sanitize_key((string) ($payload['provider'] ?? ''));
@@ -363,10 +365,10 @@ final class OAuth {
if ($action === 'bind' && (int) ($payload['user_id'] ?? 0) !== get_current_user_id()) {
$this->redirect_error('state_user_mismatch', $provider, $action);
}
if (!empty($_GET['error'])) {
if (!empty($input['error'])) {
$this->redirect_error('provider_cancelled', $provider, $action);
}
$code = sanitize_text_field((string) ($_GET['code'] ?? ''));
$code = sanitize_text_field((string) ($input['code'] ?? ''));
if ($code === '') {
$this->redirect_error('code_missing', $provider, $action);
}
+6
View File
@@ -23,6 +23,9 @@ final class OAuthProviderBroker {
'state' => $state,
]);
}
if (OAuthProviderClient::handles($provider)) {
return OAuthProviderClient::auth_url($provider, $config, $redirect_uri, $state);
}
$oauth = $this->client($provider, $config, $redirect_uri);
$scope = $provider === 'github' ? 'read:user user:email' : null;
return (string) $oauth->getAuthUrl($redirect_uri, $state, $scope);
@@ -36,6 +39,9 @@ final class OAuthProviderBroker {
if ($provider === 'google') {
return $this->google_identity($config, $code, $redirect_uri);
}
if (OAuthProviderClient::handles($provider)) {
return OAuthProviderClient::identity($provider, $config, $code, $redirect_uri, $state);
}
$oauth = $this->client($provider, $config, $redirect_uri);
$oauth->getAccessToken($state, $code, $state);
if ($provider === 'qq') {
+51 -2
View File
@@ -42,9 +42,54 @@ final class OAuthProviderCatalog {
'id_key' => 'weixin_appid',
'secret_key' => 'weixin_appsecret',
],
'douyin' => [
'label' => '抖音',
'id_key' => 'douyin_appid',
'secret_key' => 'douyin_appkey',
],
'baidu' => [
'label' => '百度',
'id_key' => 'baidu_appid',
'secret_key' => 'baidu_appkey',
],
'alipay' => [
'label' => '支付宝',
'id_key' => 'alipay_appid',
'secret_key' => 'alipay_appkey',
'extra_keys' => ['private_key' => 'alipay_private_key'],
],
'microsoft' => [
'label' => 'Microsoft',
'id_key' => 'microsoft_appid',
'secret_key' => 'microsoft_appkey',
],
'facebook' => [
'label' => 'Facebook',
'id_key' => 'facebook_appid',
'secret_key' => 'facebook_appkey',
],
'twitter' => [
'label' => 'Twitter',
'id_key' => 'twitter_appid',
'secret_key' => 'twitter_appkey',
],
'apple' => [
'label' => 'Apple',
'id_key' => 'apple_appid',
'secret_key' => 'apple_appkey',
'extra_keys' => [
'team_id' => 'apple_team_id',
'key_id' => 'apple_key_id',
'private_key' => 'apple_private_key',
],
],
];
private const CLOGIN_TYPES = ['qq', 'weixin', 'weibo'];
/** 彩虹聚合登录支持的平台,与参考主题一致。 */
public const CLOGIN_TYPES = [
'qq', 'weixin', 'alipay', 'weibo', 'douyin', 'baidu', 'github', 'gitee',
'dingtalk', 'huawei', 'xiaomi', 'google', 'microsoft', 'facebook', 'twitter', 'apple',
];
public static function direct_keys(): array {
return array_keys(self::DIRECT);
@@ -101,13 +146,17 @@ final class OAuthProviderCatalog {
$definition = self::DIRECT[$provider];
$client_id = trim((string) ($settings[$definition['id_key']] ?? ''));
$client_secret = trim((string) ($settings[$definition['secret_key']] ?? ''));
$extra = [];
foreach ((array) ($definition['extra_keys'] ?? []) as $field => $setting_key) {
$extra[$field] = trim((string) ($settings[$setting_key] ?? ''));
}
return [
'provider' => $provider,
'enabled' => self::master_enabled() && !empty($settings['oauth_' . $provider . '_s']),
'client_id' => $client_id,
'client_secret' => $client_secret,
'configured' => $client_id !== '' && $client_secret !== '',
];
] + $extra;
}
public static function enabled_direct_keys(): array {
+436
View File
@@ -0,0 +1,436 @@
<?php
/**
* Third-party login providers that the bundled SDK does not cover.
*
* Douyin, Microsoft, Facebook, Twitter and Apple are implemented here directly
* against their documented OAuth2 endpoints; Baidu and Alipay come from the
* bundled yurunsoft SDK, which already ships working clients for both.
*
* @package Jio
*/
namespace Jio\Auth;
defined('ABSPATH') || exit;
final class OAuthProviderClient {
/** Providers served by this class rather than the bundled SDK. */
public const CUSTOM = ['douyin', 'microsoft', 'facebook', 'twitter', 'apple'];
/** Providers served by yurunsoft/yurun-oauth-login. */
public const SDK = ['baidu', 'alipay'];
/** Whether this class owns the provider (direct endpoints or SDK bridge). */
public static function handles(string $provider): bool {
return in_array($provider, self::CUSTOM, true) || in_array($provider, self::SDK, true);
}
/**
* Build the authorization URL.
*
* @param array<string,string> $config provider_config() output plus extra secrets.
* @param string $redirect_uri Callback URL registered with the provider.
* @param string $state Opaque CSRF token.
*/
public static function auth_url(string $provider, array $config, string $redirect_uri, string $state): string {
if (in_array($provider, self::SDK, true)) {
return self::sdk_client($provider, $config, $redirect_uri)->getAuthUrl($redirect_uri, $state);
}
switch ($provider) {
case 'douyin':
return 'https://open.douyin.com/platform/oauth/connect?' . http_build_query([
'client_key' => $config['client_id'],
'redirect_uri' => $redirect_uri,
'response_type' => 'code',
'scope' => 'user_info',
'state' => $state,
]);
case 'microsoft':
return 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize?' . http_build_query([
'client_id' => $config['client_id'],
'redirect_uri' => $redirect_uri,
'response_type' => 'code',
'response_mode' => 'query',
'scope' => 'openid profile email User.Read',
'state' => $state,
]);
case 'facebook':
return 'https://www.facebook.com/v21.0/dialog/oauth?' . http_build_query([
'client_id' => $config['client_id'],
'redirect_uri' => $redirect_uri,
'response_type' => 'code',
'scope' => 'public_profile,email',
'state' => $state,
]);
case 'twitter':
// Twitter 的 PKCE code_challenge 需要随 state 一起保存,回调时再校验。
$verifier = self::twitter_verifier($state);
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
return 'https://twitter.com/i/oauth2/authorize?' . http_build_query([
'client_id' => $config['client_id'],
'redirect_uri' => $redirect_uri,
'response_type' => 'code',
'scope' => 'users.read tweet.read offline.access',
'state' => $state,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]);
case 'apple':
return 'https://appleid.apple.com/auth/authorize?' . http_build_query([
'client_id' => $config['client_id'],
'redirect_uri' => $redirect_uri,
'response_type' => 'code',
// Apple 只支持 form_post 回调,服务端需要读 $_POST。
'response_mode' => 'form_post',
'scope' => 'name email',
'state' => $state,
]);
}
throw new \RuntimeException(__('不支持的第三方登录平台', 'jio'));
}
/**
* Exchange the callback code for a normalized identity.
*
* @param array<string,string> $config provider_config() output plus extra secrets.
*/
public static function identity(string $provider, array $config, string $code, string $redirect_uri, string $state): array {
if (in_array($provider, self::SDK, true)) {
return self::sdk_identity($provider, $config, $code, $redirect_uri, $state);
}
switch ($provider) {
case 'douyin':
return self::douyin_identity($config, $code);
case 'microsoft':
return self::microsoft_identity($config, $code, $redirect_uri);
case 'facebook':
return self::facebook_identity($config, $code, $redirect_uri);
case 'twitter':
return self::twitter_identity($config, $code, $redirect_uri, $state);
case 'apple':
return self::apple_identity($config, $code, $redirect_uri);
}
throw new \RuntimeException(__('不支持的第三方登录平台', 'jio'));
}
// ---------------------------------------------------------------- SDK bridge
/** @param array<string,string> $config */
private static function sdk_client(string $provider, array $config, string $redirect_uri) {
switch ($provider) {
case 'baidu':
$client = new \Yurun\OAuthLogin\Baidu\OAuth2($config['client_id'], $config['client_secret'], $redirect_uri);
break;
case 'alipay':
$client = new \Yurun\OAuthLogin\Alipay\OAuth2($config['client_id'], $config['client_secret'], $redirect_uri);
// 支付宝要求应用私钥;client_secret 位存的是支付宝公钥。
if (!empty($config['private_key'])) {
$client->appPrivateKey = (string) $config['private_key'];
}
break;
default:
throw new \RuntimeException(__('不支持的第三方登录平台', 'jio'));
}
return $client;
}
/** @param array<string,string> $config */
private static function sdk_identity(string $provider, array $config, string $code, string $redirect_uri, string $state): array {
$client = self::sdk_client($provider, $config, $redirect_uri);
$client->getAccessToken($state, $code, $state);
$info = (array) $client->getUserInfo();
if ($provider === 'baidu') {
return [
'provider' => 'baidu',
'provider_id' => (string) ($info['userid'] ?? $info['uid'] ?? ''),
'username' => (string) ($info['uname'] ?? ''),
'name' => (string) ($info['uname'] ?? ''),
'email' => sanitize_email((string) ($info['email'] ?? '')),
'email_verified' => false,
'avatar_url' => str_replace('tb.himg.baidu.com', 'himg.bdimg.com', (string) ($info['portrait'] ?? '')),
];
}
// 支付宝:user_id 为唯一标识,昵称在 user_info.nick_name。
$user_info = (array) ($info['user_info'] ?? $info);
return [
'provider' => 'alipay',
'provider_id' => (string) ($info['user_id'] ?? $user_info['user_id'] ?? ''),
'username' => (string) ($user_info['nick_name'] ?? ''),
'name' => (string) ($user_info['nick_name'] ?? ''),
'email' => '',
'email_verified' => false,
'avatar_url' => (string) ($user_info['avatar'] ?? ''),
];
}
// ------------------------------------------------------------------- Douyin
/** @param array<string,string> $config */
private static function douyin_identity(array $config, string $code): array {
$token = wp_remote_post('https://open.douyin.com/oauth/access_token/', [
'timeout' => 20,
'body' => [
'client_key' => $config['client_id'],
'client_secret' => $config['client_secret'],
'code' => $code,
'grant_type' => 'authorization_code',
],
]);
$data = self::json_body($token, __('获取抖音授权令牌失败', 'jio'));
$payload = (array) ($data['data'] ?? []);
if (empty($payload['access_token']) || empty($payload['open_id'])) {
throw new \RuntimeException(__('获取抖音授权令牌失败', 'jio'));
}
$profile = wp_remote_post('https://open.douyin.com/oauth/userinfo/', [
'timeout' => 20,
'body' => [
'access_token' => $payload['access_token'],
'open_id' => $payload['open_id'],
],
]);
$info = self::json_body($profile, __('获取抖音用户信息失败', 'jio'));
$user = (array) ($info['data'] ?? []);
return [
'provider' => 'douyin',
'provider_id' => (string) $payload['open_id'],
'username' => (string) ($user['nickname'] ?? ''),
'name' => (string) ($user['nickname'] ?? ''),
'email' => '',
'email_verified' => false,
'avatar_url' => (string) ($user['avatar_larger'] ?? $user['avatar_300x300'] ?? $user['avatar_168x168'] ?? ''),
];
}
// ---------------------------------------------------------------- Microsoft
/** @param array<string,string> $config */
private static function microsoft_identity(array $config, string $code, string $redirect_uri): array {
$token = wp_remote_post('https://login.microsoftonline.com/common/oauth2/v2.0/token', [
'timeout' => 20,
'body' => [
'client_id' => $config['client_id'],
'client_secret' => $config['client_secret'],
'code' => $code,
'grant_type' => 'authorization_code',
'redirect_uri' => $redirect_uri,
'scope' => 'openid profile email User.Read',
],
]);
$data = self::json_body($token, __('获取 Microsoft 授权令牌失败', 'jio'));
if (empty($data['access_token'])) {
throw new \RuntimeException(__('获取 Microsoft 授权令牌失败', 'jio'));
}
$profile = wp_remote_get('https://graph.microsoft.com/oidc/userinfo', [
'timeout' => 20,
'headers' => ['Authorization' => 'Bearer ' . $data['access_token']],
]);
$info = self::json_body($profile, __('获取 Microsoft 用户信息失败', 'jio'));
$email = sanitize_email((string) ($info['email'] ?? $info['preferred_username'] ?? ''));
return [
'provider' => 'microsoft',
'provider_id' => (string) ($info['sub'] ?? ''),
'username' => (string) ($info['email'] ?? $info['preferred_username'] ?? ''),
'name' => (string) ($info['name'] ?? ''),
'email' => $email,
'email_verified' => $email !== '' && !empty($info['email']),
'avatar_url' => '',
];
}
// ----------------------------------------------------------------- Facebook
/** @param array<string,string> $config */
private static function facebook_identity(array $config, string $code, string $redirect_uri): array {
$token = wp_remote_get('https://graph.facebook.com/v21.0/oauth/access_token?' . http_build_query([
'client_id' => $config['client_id'],
'client_secret' => $config['client_secret'],
'redirect_uri' => $redirect_uri,
'code' => $code,
]), ['timeout' => 20]);
$data = self::json_body($token, __('获取 Facebook 授权令牌失败', 'jio'));
if (empty($data['access_token'])) {
throw new \RuntimeException(__('获取 Facebook 授权令牌失败', 'jio'));
}
$profile = wp_remote_get('https://graph.facebook.com/v21.0/me?' . http_build_query([
'fields' => 'id,name,email,picture.type(large)',
'access_token' => $data['access_token'],
]), ['timeout' => 20]);
$info = self::json_body($profile, __('获取 Facebook 用户信息失败', 'jio'));
// Facebook 头像地址不带协议,补 https 以适配页面协议。
$avatar = (string) ($info['picture']['data']['url'] ?? '');
$email = sanitize_email((string) ($info['email'] ?? ''));
return [
'provider' => 'facebook',
'provider_id' => (string) ($info['id'] ?? ''),
'username' => $email !== '' ? $email : (string) ($info['name'] ?? ''),
'name' => (string) ($info['name'] ?? ''),
'email' => $email,
'email_verified' => $email !== '',
'avatar_url' => $avatar !== '' ? self::https_url($avatar) : '',
];
}
// ------------------------------------------------------------------ Twitter
/** @param array<string,string> $config */
private static function twitter_identity(array $config, string $code, string $redirect_uri, string $state): array {
$verifier = self::twitter_verifier($state);
$token = wp_remote_post('https://api.twitter.com/2/oauth2/token', [
'timeout' => 20,
'headers' => ['Authorization' => 'Basic ' . base64_encode($config['client_id'] . ':' . $config['client_secret'])],
'body' => [
'code' => $code,
'grant_type' => 'authorization_code',
'redirect_uri' => $redirect_uri,
'code_verifier' => $verifier,
],
]);
$data = self::json_body($token, __('获取 Twitter 授权令牌失败', 'jio'));
if (empty($data['access_token'])) {
throw new \RuntimeException(__('获取 Twitter 授权令牌失败', 'jio'));
}
$profile = wp_remote_get('https://api.twitter.com/2/users/me?' . http_build_query([
'user.fields' => 'id,name,username,profile_image_url',
]), [
'timeout' => 20,
'headers' => ['Authorization' => 'Bearer ' . $data['access_token']],
]);
$info = (array) (self::json_body($profile, __('获取 Twitter 用户信息失败', 'jio'))['data'] ?? []);
return [
'provider' => 'twitter',
'provider_id' => (string) ($info['id'] ?? ''),
'username' => (string) ($info['username'] ?? ''),
'name' => (string) ($info['name'] ?? ''),
'email' => '',
'email_verified' => false,
'avatar_url' => (string) ($info['profile_image_url'] ?? ''),
];
}
/** Derive the PKCE verifier from the state so it never needs extra storage. */
private static function twitter_verifier(string $state): string {
return rtrim(strtr(base64_encode(hash('sha256', 'jio-twitter|' . $state, true)), '+/', '-_'), '=');
}
// -------------------------------------------------------------------- Apple
/** @param array<string,string> $config */
private static function apple_identity(array $config, string $code, string $redirect_uri): array {
$secret = self::apple_client_secret($config);
$token = wp_remote_post('https://appleid.apple.com/auth/token', [
'timeout' => 20,
'body' => [
'client_id' => $config['client_id'],
'client_secret' => $secret,
'code' => $code,
'grant_type' => 'authorization_code',
'redirect_uri' => $redirect_uri,
],
]);
$data = self::json_body($token, __('获取 Apple 授权令牌失败', 'jio'));
if (empty($data['id_token'])) {
throw new \RuntimeException(__('获取 Apple 授权令牌失败', 'jio'));
}
$claims = self::jwt_claims((string) $data['id_token']);
if (!is_array($claims) || empty($claims['sub'])) {
throw new \RuntimeException(__('解析 Apple 用户信息失败', 'jio'));
}
$email = sanitize_email((string) ($claims['email'] ?? ''));
return [
'provider' => 'apple',
'provider_id' => (string) $claims['sub'],
'username' => $email !== '' ? $email : (string) $claims['sub'],
'name' => '',
'email' => $email,
'email_verified' => !empty($claims['email_verified']) || !empty($claims['is_private_email']) === false && $email !== '',
'avatar_url' => '',
];
}
/**
* Build the ES256 client secret Apple requires (a short-lived JWT).
*
* @param array<string,string> $config
*/
private static function apple_client_secret(array $config): string {
$team = (string) ($config['team_id'] ?? '');
$key = (string) ($config['key_id'] ?? '');
$pem = (string) ($config['private_key'] ?? '');
if ($pem === '') {
// 未配置密钥时退化为普通 client_secret,仍然遵循同一签名流程。
return (string) $config['client_secret'];
}
$now = time();
$header = self::b64url(wp_json_encode(['alg' => 'ES256', 'kid' => $key]));
$payload = self::b64url(wp_json_encode([
'iss' => $team,
'iat' => $now,
'exp' => $now + 3600,
'aud' => 'https://appleid.apple.com',
'sub' => (string) $config['client_id'],
]));
$signing = $header . '.' . $payload;
$key_id = openssl_pkey_get_private($pem);
if ($key_id === false) {
throw new \RuntimeException(__('Apple 私钥格式无效', 'jio'));
}
$signature = '';
openssl_sign($signing, $signature, $key_id, OPENSSL_ALGO_SHA256);
return $signing . '.' . self::b64url($signature);
}
/** Decode the payload of a JWT without verifying it (the token came over TLS). */
private static function jwt_claims(string $token): ?array {
$parts = explode('.', $token);
if (count($parts) < 2) {
return null;
}
$payload = json_decode((string) base64_decode(strtr($parts[1], '-_', '+/'), true), true);
return is_array($payload) ? $payload : null;
}
private static function b64url(string $value): string {
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
// ------------------------------------------------------------------ helpers
/**
* Decode a JSON response, raising a translated message on transport errors.
*
* @param array|\WP_Error $response
* @return array<string,mixed>
*/
private static function json_body($response, string $message): array {
if (is_wp_error($response)) {
throw new \RuntimeException($response->get_error_message());
}
if ((int) wp_remote_retrieve_response_code($response) !== 200) {
throw new \RuntimeException($message);
}
$data = json_decode((string) wp_remote_retrieve_body($response), true);
if (!is_array($data)) {
throw new \RuntimeException($message);
}
if (!empty($data['error'])) {
$detail = $data['error_description'] ?? $data['error']['message'] ?? $data['error'];
throw new \RuntimeException(is_string($detail) ? $detail : $message);
}
return $data;
}
private static function https_url(string $url): string {
return (string) preg_replace('#^http://#i', 'https://', $url);
}
}
@@ -0,0 +1,133 @@
<?php
/** Exercise the new provider clients without touching the network. */
declare(strict_types=1);
define('ABSPATH', __DIR__ . DIRECTORY_SEPARATOR);
$GLOBALS['http_calls'] = [];
function sanitize_email(string $email): string { return (string) filter_var($email, FILTER_SANITIZE_EMAIL); }
function sanitize_key($value): string { return preg_replace('/[^a-z0-9_-]/', '', strtolower((string) $value)) ?? ''; }
function __(string $text, string $domain = ''): string { return $text; }
function wp_json_encode($value): string { return (string) json_encode($value); }
function is_wp_error($value): bool { return $value instanceof WP_Error; }
class WP_Error {
public function __construct(private string $message = '') {}
public function get_error_message(): string { return $this->message; }
}
/** Canned responses keyed by URL. */
final class HttpStub {
public static array $responses = [];
public static function respond(string $url, int $code, array $body): void {
self::$responses[$url] = ['code' => $code, 'body' => wp_json_encode($body)];
}
}
function wp_remote_get(string $url, array $args = []) {
$GLOBALS['http_calls'][] = ['GET', $url, $args];
return HttpStub::$responses[$url] ?? new WP_Error('no_stub_for_' . $url);
}
function wp_remote_post(string $url, array $args = []) {
$GLOBALS['http_calls'][] = ['POST', $url, $args];
return HttpStub::$responses[$url] ?? new WP_Error('no_stub_for_' . $url);
}
function wp_remote_retrieve_response_code($response): int { return (int) ($response['code'] ?? 0); }
function wp_remote_retrieve_body($response): string { return (string) ($response['body'] ?? ''); }
function base64url_decode(string $value): string { return (string) base64_decode(strtr($value, '-_', '+/'), true); }
require dirname(__DIR__, 2) . '/src/Auth/OAuthProviderClient.php';
$config = [
'client_id' => 'cid',
'client_secret' => 'secret',
'private_key' => '',
'team_id' => 'TEAM',
'key_id' => 'KEY',
];
$redirect = 'https://example.test/oauth/callback/';
$state = 'state-abc123';
$code = 'authcode';
$out = [];
/** Auth URL assertions. */
foreach (['douyin', 'microsoft', 'facebook', 'twitter', 'apple'] as $provider) {
$url = \Jio\Auth\OAuthProviderClient::auth_url($provider, $config, $redirect, $state);
$query = [];
parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
$out['auth'][$provider] = [
'url' => $url,
'has_redirect' => ($query['redirect_uri'] ?? '') === $redirect,
'has_state' => ($query['state'] ?? '') === $state,
'challenge' => $query['code_challenge'] ?? '',
];
}
/** Identity normalisation, one canned response set per provider. */
HttpStub::respond('https://open.douyin.com/oauth/access_token/', 200, [
'data' => ['access_token' => 'dytoken', 'open_id' => 'dy-open-id'],
]);
HttpStub::respond('https://open.douyin.com/oauth/userinfo/', 200, [
'data' => ['nickname' => '抖音用户', 'avatar_larger' => 'https://p3.douyinpic.com/a.jpg'],
]);
$out['identity']['douyin'] = \Jio\Auth\OAuthProviderClient::identity('douyin', $config, $code, $redirect, $state);
HttpStub::respond('https://login.microsoftonline.com/common/oauth2/v2.0/token', 200, [
'access_token' => 'mstoken',
]);
HttpStub::respond('https://graph.microsoft.com/oidc/userinfo', 200, [
'sub' => 'ms-sub', 'email' => 'ms@example.test', 'name' => 'MS User',
]);
$out['identity']['microsoft'] = \Jio\Auth\OAuthProviderClient::identity('microsoft', $config, $code, $redirect, $state);
HttpStub::respond('https://graph.facebook.com/v21.0/oauth/access_token?' . http_build_query([
'client_id' => 'cid',
'client_secret' => 'secret',
'redirect_uri' => $redirect,
'code' => $code,
]), 200, ['access_token' => 'fbtoken']);
HttpStub::respond('https://graph.facebook.com/v21.0/me?' . http_build_query([
'fields' => 'id,name,email,picture.type(large)',
'access_token' => 'fbtoken',
]), 200, [
'id' => 'fb-id', 'name' => 'FB User', 'email' => 'fb@example.test',
'picture' => ['data' => ['url' => 'http://graph.facebook.com/pic.jpg']],
]);
$out['identity']['facebook'] = \Jio\Auth\OAuthProviderClient::identity('facebook', $config, $code, $redirect, $state);
HttpStub::respond('https://api.twitter.com/2/oauth2/token', 200, ['access_token' => 'twtoken']);
HttpStub::respond('https://api.twitter.com/2/users/me?' . http_build_query([
'user.fields' => 'id,name,username,profile_image_url',
]), 200, [
'data' => [
'id' => 'tw-id', 'name' => 'TW User', 'username' => 'twuser',
'profile_image_url' => 'https://pbs.twimg.com/p.jpg',
],
]);
$out['identity']['twitter'] = \Jio\Auth\OAuthProviderClient::identity('twitter', $config, $code, $redirect, $state);
/** Apple: build a fake id_token payload (no signature verification needed here). */
$apple_payload = rtrim(strtr(base64_encode((string) wp_json_encode([
'sub' => 'apple-sub', 'email' => 'apple@privaterelay.appleid.com', 'email_verified' => 'true',
])), '+/', '-_'), '=');
HttpStub::respond('https://appleid.apple.com/auth/token', 200, [
'id_token' => 'eyJhbGciOiJub25lIn0.' . $apple_payload . '.',
]);
$out['identity']['apple'] = \Jio\Auth\OAuthProviderClient::identity('apple', $config, $code, $redirect, $state);
/** Error path: a provider returning an error payload must throw, not leak it. */
HttpStub::respond('https://open.douyin.com/oauth/access_token/', 200, ['error' => 'invalid_code']);
try {
\Jio\Auth\OAuthProviderClient::identity('douyin', $config, 'bad', $redirect, $state);
$out['error_path'] = 'NO_THROW';
} catch (\RuntimeException $e) {
$out['error_path'] = 'threw: ' . $e->getMessage();
}
echo json_encode($out, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
@@ -0,0 +1,77 @@
/**
* 新增 OAuth provider 客户端契约。
*
* 覆盖参考主题有、而打包 SDK 未提供的五个平台:抖音、Microsoft、
* Facebook、Twitter、Apple。断言授权 URL 的关键参数、身份归一化结果,
* 以及错误响应必须抛出而不是静默返回空身份。
*/
'use strict';
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
const path = require('node:path');
const test = require('node:test');
const root = path.resolve(__dirname, '..', '..');
function runHarness() {
const result = spawnSync(
process.env.PHP_BINARY || 'php',
[path.join(__dirname, 'oauth-provider-client-harness.php')],
{ cwd: root, encoding: 'utf8' },
);
assert.equal(result.status, 0, result.stderr || result.stdout);
return JSON.parse(result.stdout);
}
test('新 provider 的授权 URL 携带 redirect/state(Twitter 额外带 PKCE)', () => {
const { auth } = runHarness();
for (const provider of ['douyin', 'microsoft', 'facebook', 'twitter', 'apple']) {
assert.equal(auth[provider].has_redirect, true, provider + ' redirect_uri');
assert.equal(auth[provider].has_state, true, provider + ' state');
}
// 只有 Twitter 走 PKCE,其余平台不带 code_challenge。
assert.match(auth.twitter.challenge, /^[A-Za-z0-9_-]{20,}$/);
for (const provider of ['douyin', 'microsoft', 'facebook', 'apple']) {
assert.equal(auth[provider].challenge, '', provider + ' should not send PKCE');
}
// Apple 必须使用 form_post,否则服务端收不到 code。
assert.match(auth.apple.url, /appleid\.apple\.com\/auth\/authorize/);
});
test('各 provider 的身份归一化产出统一字段', () => {
const { identity } = runHarness();
const required = ['provider', 'provider_id', 'username', 'name', 'email', 'email_verified', 'avatar_url'];
for (const [provider, row] of Object.entries(identity)) {
for (const key of required) {
assert.ok(key in row, `${provider} missing ${key}`);
}
assert.ok(row.provider_id !== '', `${provider} provider_id must not be empty`);
assert.equal(row.provider, provider);
}
assert.equal(identity.douyin.provider_id, 'dy-open-id');
assert.equal(identity.douyin.name, '抖音用户');
assert.match(identity.douyin.avatar_url, /^https:/);
assert.equal(identity.microsoft.email, 'ms@example.test');
assert.equal(identity.microsoft.email_verified, true);
// Facebook 头像原始地址是 http://,必须升级到 https。
assert.match(identity.facebook.avatar_url, /^https:\/\//);
assert.equal(identity.twitter.username, 'twuser');
assert.equal(identity.twitter.email, '', 'Twitter 不返回邮箱');
// Apple 的邮箱是私密转发地址,仍可用于自动绑定。
assert.match(identity.apple.email, /@privaterelay\.appleid\.com$/);
});
test('provider 返回错误时抛出异常而不是产出空身份', () => {
const { error_path: errorPath } = runHarness();
assert.match(errorPath, /^threw: /);
});
@@ -33,19 +33,20 @@ test('OAuth PHP behavior harness passes', () => {
const output = execFileSync('php', [path.join(root, 'tests/widget-system/oauth-security-harness.php')], { encoding: 'utf8' })
assert.match(output, /OAuth security harness passed/)
})
test('Baidu and Alipay are absent from the OAuth surface', () => {
for (const needle of [
'oauth_baidu_s',
'oauth_alipay_s',
'get_baidu_user_data',
'get_alipay_user_data',
'OAuthLogin\\Baidu',
'OAuthLogin\\Alipay',
"'baidu' => [",
"'alipay' => [",
]) {
assert.equal(oauthScope.includes(needle), false, `unexpected OAuth reference: ${needle}`)
test('Baidu and Alipay are wired into the OAuth surface', () => {
const catalog = read('src/Auth/OAuthProviderCatalog.php')
const client = read('src/Auth/OAuthProviderClient.php')
const schema = read('src/Admin/Schema/SchemaUsers.php')
for (const provider of ['baidu', 'alipay']) {
assert.match(catalog, new RegExp(`'${provider}'\\s*=>`), provider)
assert.match(schema, new RegExp(`oauth_${provider}_s`), provider)
}
// 复用打包的 yurun SDK,而不是自己重写签名。
assert.match(client, /Yurun\\OAuthLogin\\Baidu\\OAuth2/)
assert.match(client, /Yurun\\OAuthLogin\\Alipay\\OAuth2/)
// 支付宝的支付网关仍在,两者互不影响。
assert.match(read('src/Payment/Gateways/AlipayGateway.php'), /class AlipayGateway/)
})
test('Alipay payment remains available', () => {
@@ -71,10 +72,19 @@ test('OAuth uses one-time transactions and no PHP sessions', () => {
test('provider catalog is the canonical provider list', () => {
const catalog = read('src/Auth/OAuthProviderCatalog.php')
for (const provider of ['github', 'gitee', 'qq', 'google', 'weibo', 'weixin']) {
assert.match(catalog, new RegExp(`'${provider}'\\s*=>`))
const direct = ['github', 'gitee', 'qq', 'google', 'weibo', 'weixin', 'douyin', 'baidu', 'alipay', 'microsoft', 'facebook', 'twitter', 'apple']
for (const provider of direct) {
assert.match(catalog, new RegExp(`'${provider}'\\s*=>`), provider)
}
assert.match(catalog, /CLOGIN_TYPES = \['qq', 'weixin', 'weibo'\]/)
// 聚合登录平台与参考主题一致(16 种)。
const block = catalog.slice(catalog.indexOf('CLOGIN_TYPES = ['), catalog.indexOf('];', catalog.indexOf('CLOGIN_TYPES = [')))
const types = [...block.matchAll(/'([a-z]+)'/g)].map((m) => m[1])
assert.equal(types.length, 16, types.join(','))
for (const type of ['dingtalk', 'huawei', 'xiaomi', 'alipay', 'douyin', 'apple', 'microsoft']) {
assert.ok(types.includes(type), type)
}
assert.match(catalog, /oauth_s/)
assert.match(catalog, /oauth_bind_type/)
})