refactor(oauth): drop the hand-written providers and keep only what the SDK ships
The previous commit added five direct clients (Douyin, Microsoft, Facebook, Twitter, Apple) written against each platform's own endpoints. Maintaining five bespoke OAuth implementations — including Apple's ES256 client secret and Twitter's PKCE — is maintenance the bundled yurunsoft SDK does not ask for, and none of them could be verified without production credentials. Only providers yurunsoft/yurun-oauth-login actually implements stay on the direct surface: GitHub, Gitee, QQ, Google, Weibo, Weixin, Baidu and Alipay. Baidu and Alipay move into OAuthProviderBroker's existing client() switch and normalize() map rather than living in a separate class, so there is no new layer to reason about. OAuthProviderClient, its harness and its contract test are deleted outright (-809 lines). The Apple form_post handling added to handle_callback() is reverted too — without Apple there is no POST callback to read. The aggregate-login channel keeps its 16 platforms, matching the reference; that list describes what the aggregation provider offers, not a local implementation. Brand icons for those names stay for the same reason. Verification: 869 widget/navigation tests pass, the PSR-4 audit reports 0 errors, and the catalog constant, PHP schema and Vue schema now list the same 16 aggregate platforms.
This commit is contained in:
1 parent
6db34646ed
commit
b1c3d86e44
8 files changed
+55
-809
No files matched your search
@@ -1,133 +0,0 @@
|
||||
<?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);
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* 新增 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,22 +33,35 @@ 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 wired into the OAuth surface', () => {
|
||||
test('Baidu and Alipay are wired through the bundled SDK', () => {
|
||||
const catalog = read('src/Auth/OAuthProviderCatalog.php')
|
||||
const client = read('src/Auth/OAuthProviderClient.php')
|
||||
const broker = read('src/Auth/OAuthProviderBroker.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(broker, new RegExp(`Yurun\\\\OAuthLogin\\\\${provider[0].toUpperCase()}${provider.slice(1)}\\\\OAuth2`), 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('未提供自建客户端的平台不进入 OAuth 表面', () => {
|
||||
const catalog = read('src/Auth/OAuthProviderCatalog.php')
|
||||
const schema = read('src/Admin/Schema/SchemaUsers.php')
|
||||
const jsSchema = read('assets/admin/src/schema/sections/users/oauth.js')
|
||||
|
||||
// 抖音 / Microsoft / Facebook / Twitter / Apple 只存在于聚合渠道,不再是直连平台。
|
||||
for (const provider of ['douyin', 'microsoft', 'facebook', 'twitter', 'apple']) {
|
||||
assert.doesNotMatch(catalog, new RegExp(`'${provider}'\\s*=>\\s*\\[`), provider)
|
||||
assert.doesNotMatch(schema, new RegExp(`oauth_${provider}_s`), provider)
|
||||
assert.doesNotMatch(jsSchema, new RegExp(`oauth_${provider}_s`), provider)
|
||||
}
|
||||
assert.equal(fs.existsSync(path.join(root, 'src/Auth/OAuthProviderClient.php')), false)
|
||||
})
|
||||
|
||||
test('Alipay payment remains available', () => {
|
||||
const functions = read('functions.php')
|
||||
const paymentSchema = read('src/Admin/Schema/SchemaPayment.php')
|
||||
@@ -72,7 +85,7 @@ 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')
|
||||
const direct = ['github', 'gitee', 'qq', 'google', 'weibo', 'weixin', 'douyin', 'baidu', 'alipay', 'microsoft', 'facebook', 'twitter', 'apple']
|
||||
const direct = ['github', 'gitee', 'qq', 'google', 'weibo', 'weixin', 'baidu', 'alipay']
|
||||
for (const provider of direct) {
|
||||
assert.match(catalog, new RegExp(`'${provider}'\\s*=>`), provider)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user