diff --git a/tests/widget-system/auth-source-contract.test.cjs b/tests/widget-system/auth-source-contract.test.cjs
index 75bc2003..67e9f89b 100644
--- a/tests/widget-system/auth-source-contract.test.cjs
+++ b/tests/widget-system/auth-source-contract.test.cjs
@@ -173,6 +173,12 @@ test('shared renderer contracts every configured authentication state', () => {
assert.match(client, /aksrtAuthAutoUntil/);
assert.match(client, /data-password-show/);
assert.match(client, /pollQr/);
+ assert.match(client, /AbortController/);
+ assert.match(client, /refreshInFlight|inFlight/);
+ assert.match(client, /visibilitychange/);
+ assert.match(client, /Math\.pow\(2/);
+ assert.match(client, /QR_TIMEOUT/);
+ assert.match(client, /stopQr\(root\)/);
assert.match(client, /window\.notyf\(escapeHtml\(text\), type \|\| 'info'\)/);
assert.match(client, /notify\('请先完成人机验证', 'warning'\)/);
assert.match(client, /form\.noValidate = true/);
@@ -182,3 +188,47 @@ test('shared renderer contracts every configured authentication state', () => {
assert.match(settings, /invitation_mode/);
assert.match(settings, /\['close', 'open', 'must'\]/);
});
+
+test('OAuth frontend is accessible, redirect-aware, and removes retired providers', () => {
+ const shared = read('templates/parts/auth-content.php');
+ const modal = read('templates/parts/modal-auth.php');
+ const client = read('assets/js/auth.js');
+ const userCenter = read('assets/js/routes/user-center.js');
+ const bindings = read('inc/helpers/user-center-renderers/settings-bindings.php');
+ const signCss = read('assets/css/routes/sign.css');
+ const mainCss = read('assets/css/main.css');
+
+ assert.match(modal, /role="dialog"[^>]*aria-modal="true"[^>]*aria-labelledby="aksrt-auth-modal-title"/);
+ assert.match(modal, /id="aksrt-auth-modal-title"/);
+ assert.match(client, /focusableModalItems/);
+ assert.match(client, /lastModalTrigger/);
+ assert.match(client, /restore\.focus\(\)/);
+ assert.match(shared, /role="tabpanel"[^>]*data-auth-panel="signin"/);
+ assert.match(shared, /role="tabpanel"[^>]*data-auth-panel="oauth"/);
+ assert.match(client, /setAttribute\('role', 'tab'\)/);
+ assert.match(client, /aria-controls/);
+ assert.match(client, /aria-selected/);
+ assert.match(shared, /data-password-show[^>]*aria-label=/);
+ assert.match(shared, /aria-pressed="false"/);
+ assert.match(client, /setAttribute\('aria-label', visible \? '隐藏密码' : '显示密码'\)/);
+ assert.match(client, /setAttribute\('aria-pressed', visible \? 'true' : 'false'\)/);
+ assert.match(shared, /data-auth-qr-status[^>]*role="status"[^>]*aria-live="polite"/);
+ assert.match(shared, /data-provider=/);
+ assert.match(shared, /aria-label="<\?php echo esc_attr\(sprintf/);
+ assert.match(client, /searchParams\.set\('redirect_to', redirect\)/);
+
+ assert.match(client, /oauth_action/);
+ assert.match(client, /oauth_provider/);
+ assert.match(client, /oauth_error/);
+ assert.match(client, /oauthErrorMessages/);
+ assert.doesNotMatch(client, /notify\([^\n;]*searchParams\.get\('oauth_error'/);
+ assert.match(userCenter, /errorMessages\[currentUrl\.searchParams\.get\('oauth_error'\)/);
+ assert.match(userCenter, /result\.status === 409/);
+ assert.match(userCenter, /d\.message \|\| \(d\.data && d\.data\.message\)/);
+ assert.match(bindings, /\$binding\['provider'\] \?\? \$binding\['key'\]/);
+ assert.match(bindings, /\$binding\['start_url'\] \?\? \$binding\['url'\]/);
+ assert.match(bindings, /data-provider=/);
+
+ assert.doesNotMatch(signCss + mainCss, /social-login-item[^\n{]*(?:baidu|alipay)|social-login-item\.(?:baidu|alipay)/i);
+ assert.match(mainCss, /\.pay-box \.but\.baidu/);
+});
diff --git a/tests/widget-system/oauth-security-contract.test.cjs b/tests/widget-system/oauth-security-contract.test.cjs
new file mode 100644
index 00000000..8456faf6
--- /dev/null
+++ b/tests/widget-system/oauth-security-contract.test.cjs
@@ -0,0 +1,177 @@
+const assert = require('node:assert/strict')
+const fs = require('node:fs')
+const path = require('node:path')
+const test = require('node:test')
+const vm = require('node:vm')
+const { execFileSync } = require('node:child_process')
+
+const root = path.resolve(__dirname, '..', '..')
+const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
+const walkFiles = (dir, extensions) => {
+ const result = []
+ for (const entry of fs.readdirSync(path.join(root, dir), { withFileTypes: true })) {
+ const relative = path.join(dir, entry.name)
+ if (entry.isDirectory()) result.push(...walkFiles(relative, extensions))
+ else if (extensions.some((ext) => entry.name.endsWith(ext))) result.push(relative)
+ }
+ return result
+}
+
+const oauthScope = [
+ 'src/Auth/OAuth.php',
+ 'src/Auth/OAuthProviderCatalog.php',
+ 'src/Auth/OAuthProviderBroker.php',
+ 'src/Auth/OAuthAccountService.php',
+ 'src/Auth/OAuthProxyClient.php',
+ 'src/Auth/OAuthProxyServer.php',
+ 'src/Auth/CLoginClient.php',
+ 'src/Admin/Schema/SchemaUsers.php',
+ 'assets/admin/src/schema/sections/users/oauth.js',
+].map(read).join('\n')
+
+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('Alipay payment remains available', () => {
+ const functions = read('functions.php')
+ const paymentSchema = read('src/Admin/Schema/SchemaPayment.php')
+ const gateway = read('src/Payment/Gateways/AlipayGateway.php')
+ assert.match(functions, /AlipayGateway/)
+ assert.match(paymentSchema, /alipay_appid/)
+ assert.match(paymentSchema, /alipay_privatekey/)
+ assert.match(gateway, /class AlipayGateway/)
+})
+
+test('OAuth uses one-time transactions and no PHP sessions', () => {
+ const auth = fs.readdirSync(path.join(root, 'src', 'Auth'))
+ .filter((name) => name.endsWith('.php'))
+ .map((name) => read(path.join('src', 'Auth', name)))
+ .join('\n')
+ assert.match(auth, /OAuthTransactionStore::issue/)
+ assert.match(auth, /OAuthTransactionStore::claim/)
+ assert.equal(auth.includes('session_start('), false)
+ assert.equal(auth.includes('$_SESSION'), false)
+})
+
+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*=>`))
+ }
+ assert.match(catalog, /CLOGIN_TYPES = \['qq', 'weixin', 'weibo'\]/)
+ assert.match(catalog, /oauth_s/)
+ assert.match(catalog, /oauth_bind_type/)
+})
+
+test('verified email is required for email auto-linking', () => {
+ const account = read('src/Auth/OAuthAccountService.php')
+ const broker = read('src/Auth/OAuthProviderBroker.php')
+ assert.match(account, /!empty\(\$identity\['email_verified'\]\)/)
+ assert.match(broker, /primary.*verified/s)
+ assert.match(broker, /email_verified.*Google|Google.*email_verified/s)
+})
+
+test('OAuth secrets are marked and masked by the schema provider', () => {
+ const phpSchema = read('src/Admin/Schema/SchemaUsers.php')
+ const jsSchema = read('assets/admin/src/schema/sections/users/oauth.js')
+ const provider = read('src/Admin/SchemaProvider.php')
+ for (const key of [
+ 'oauth_proxy_secret',
+ 'github_appkey',
+ 'gitee_appkey',
+ 'qq_appkey',
+ 'google_client_secret',
+ 'weibo_appkey',
+ 'weixin_appsecret',
+ 'weixingzh_token',
+ 'weixingzh_appsecret',
+ 'clogin_key',
+ ]) {
+ assert.match(phpSchema, new RegExp(`${key}[^\\n]+secret`))
+ assert.match(jsSchema, new RegExp(`${key}[^\\n]+secret`))
+ }
+ assert.match(provider, /\*\*\*/)
+ assert.match(provider, /!empty\(\$field\['secret'\]\)/)
+})
+
+test('PHP and JavaScript OAuth schema field keys stay in parity', () => {
+ const php = read('src/Admin/Schema/SchemaUsers.php')
+ const js = read('assets/admin/src/schema/sections/users/oauth.js')
+ const start = php.indexOf("'oauth' => [")
+ const phpSection = php.slice(start, php.indexOf("'sms' => [", start))
+ const phpKeys = [...phpSection.matchAll(/\['key' => '([^']+)', 'label'/g)].map((match) => match[1])
+ const jsKeys = [...js.matchAll(/\{ key: '([^']+)', label:/g)].map((match) => match[1])
+ assert.deepEqual([...new Set(phpKeys)].sort(), [...new Set(jsKeys)].sort())
+})
+test('OAuth result handler maps safe errors and preserves unrelated URL state', () => {
+ const notices = []
+ let replaced = ''
+ const listeners = {}
+ const location = new URL('https://site.test/sign/?oauth_action=error&oauth_provider=github&oauth_error=%3Cscript%3E&keep=1#section')
+ const documentElement = { dataset: {}, contains: () => true }
+ const document = {
+ readyState: 'complete',
+ documentElement,
+ visibilityState: 'visible',
+ querySelectorAll: () => [],
+ querySelector: () => null,
+ addEventListener: (name, fn) => { listeners[name] = fn },
+ activeElement: null,
+ body: { classList: { add() {}, remove() {} } },
+ }
+ const window = {
+ aksrtData: {},
+ URL,
+ location,
+ history: { replaceState: (_state, _title, value) => { replaced = value } },
+ notyf: (message, type) => notices.push({ message, type }),
+ matchMedia: () => ({ matches: false }),
+ setTimeout,
+ clearTimeout,
+ setInterval,
+ clearInterval,
+ localStorage: { getItem: () => null, setItem() {} },
+ }
+ vm.runInNewContext(read('assets/js/auth.js'), {
+ window,
+ document,
+ URL,
+ FormData: class {},
+ fetch: () => Promise.reject(new Error('unexpected fetch')),
+ console,
+ CustomEvent: class {},
+ AbortController,
+ Date,
+ Math,
+ Promise,
+ })
+ assert.deepEqual(notices, [{ message: '第三方授权失败,请重试', type: 'danger' }])
+ assert.equal(replaced, '/sign/?keep=1#section')
+ assert.equal(replaced.includes('script'), false)
+})
+test('obsolete OAuth admin routes and components are removed', () => {
+ const controller = read('src/Admin/UserController.php')
+ const usersApi = read('assets/admin/src/api/modules/users.js')
+ assert.equal(controller.includes("'/settings/oauth'"), false)
+ assert.equal(controller.includes("'/settings/oauth-proxy'"), false)
+ assert.equal(usersApi.includes('getOAuthSettings'), false)
+ assert.equal(usersApi.includes('saveOAuthSettings'), false)
+ assert.equal(fs.existsSync(path.join(root, 'assets/admin/src/components/OAuthProviderForm.vue')), false)
+ assert.equal(fs.existsSync(path.join(root, 'assets/admin/src/pages/users/UsersSectionPage.vue')), false)
+})
diff --git a/tests/widget-system/oauth-security-harness.php b/tests/widget-system/oauth-security-harness.php
new file mode 100644
index 00000000..5102a0cd
--- /dev/null
+++ b/tests/widget-system/oauth-security-harness.php
@@ -0,0 +1,156 @@
+ 'github'], 60, true);
+ check(\AKSRT\Auth\OAuthTransactionStore::peek($token, 'direct', true) !== null, 'transaction peek failed');
+ check(\AKSRT\Auth\OAuthTransactionStore::peek($token, 'other', true) === null, 'transaction kind mismatch accepted');
+ $_COOKIE['aksrt_oauth_browser'] = str_repeat('B', 43);
+ check(\AKSRT\Auth\OAuthTransactionStore::claim($token, 'direct', true) === null, 'wrong browser claimed transaction');
+ $_COOKIE['aksrt_oauth_browser'] = str_repeat('A', 43);
+ check(\AKSRT\Auth\OAuthTransactionStore::claim($token, 'direct', true) !== null, 'valid browser failed claim');
+ check(\AKSRT\Auth\OAuthTransactionStore::claim($token, 'direct', true) === null, 'transaction replay accepted');
+
+ $expired = \AKSRT\Auth\OAuthTransactionStore::issue('direct', [], 30, true);
+ foreach ($GLOBALS['wp_options'] as $key => $row) {
+ if (is_array($row) && ($row['kind'] ?? '') === 'direct') $GLOBALS['wp_options'][$key]['expires_at'] = time() - 1;
+ }
+ check(\AKSRT\Auth\OAuthTransactionStore::peek($expired, 'direct', true) === null, 'expired transaction accepted');
+
+ $request = [
+ 'v' => '2', 'request_id' => str_repeat('C', 43), 'provider' => 'github', 'action' => 'login',
+ 'agent_back_url' => 'https://example.com/oauth/proxy/callback/', 'ts' => (string) time(),
+ ];
+ $secret = 'test-secret-value';
+ $requestSign = \AKSRT\Auth\OAuthProxyProtocol::sign_request($request, $secret);
+ check(\AKSRT\Auth\OAuthProxyProtocol::verify_request($request, $secret, $requestSign), 'request signature failed');
+ check(!\AKSRT\Auth\OAuthProxyProtocol::verify_callback($request, $secret, $requestSign), 'direction-separated HMAC failed');
+ $tampered = $request; $tampered['provider'] = 'qq';
+ check(!\AKSRT\Auth\OAuthProxyProtocol::verify_request($tampered, $secret, $requestSign), 'tampered request accepted');
+ check(!\AKSRT\Auth\OAuthProxyProtocol::timestamp_valid((string) (time() - 1000)), 'expired timestamp accepted');
+
+ check(\AKSRT\Auth\OAuthUrlPolicy::public_https('http://example.com') === '', 'HTTP URL accepted');
+ check(\AKSRT\Auth\OAuthUrlPolicy::public_https('https://user@example.com') === '', 'userinfo URL accepted');
+ check(\AKSRT\Auth\OAuthUrlPolicy::public_https('https://127.0.0.1') === '', 'loopback URL accepted');
+ check(\AKSRT\Auth\OAuthUrlPolicy::public_https('https://10.0.0.1') === '', 'private URL accepted');
+ check(\AKSRT\Auth\OAuthProxyProtocol::callback_url('https://example.com/wrong') === '', 'wrong callback path accepted');
+
+ $provider = \AKSRT\Admin\SchemaProvider::get_instance();
+ $base = $provider->get_defaults('users', 'oauth');
+ $base['oauth_s'] = true;
+ $base['oauth_weixingzh_s'] = true;
+ $base['weixingzh_gzh_type'] = 'service';
+ $base['weixingzh_appid'] = 'appid';
+ $base['weixingzh_appsecret'] = 'secret';
+ $base['weixingzh_token'] = '***';
+ $GLOBALS['wp_options']['aksrt_settings'] = [];
+ check(!$provider->validate('users', $base, 'oauth')['valid'], 'masked missing token treated as configured');
+ $GLOBALS['wp_options']['aksrt_settings'] = ['weixingzh_token' => 'stored-token'];
+ check($provider->validate('users', $base, 'oauth')['valid'], 'stored token not preserved by mask validation');
+
+ $provider->write_settings('users', ['weixingzh_token' => '***'], 'oauth');
+ check($GLOBALS['wp_options']['aksrt_settings']['weixingzh_token'] === 'stored-token', 'masked token overwrote stored secret');
+ $provider->write_settings('users', ['weixingzh_token' => 'replacement'], 'oauth');
+ check($GLOBALS['wp_options']['aksrt_settings']['weixingzh_token'] === 'replacement', 'new token failed to replace secret');
+ $provider->write_settings('users', ['__clear_secrets' => ['weixingzh_token']], 'oauth');
+ check(!isset($GLOBALS['wp_options']['aksrt_settings']['weixingzh_token']), 'explicit secret clear failed');
+ $GLOBALS['wp_options']['aksrt_settings']['weixingzh_token'] = 'final-token';
+ $clearData = $base;
+ $clearData['weixingzh_token'] = '***';
+ $clearData['__clear_secrets'] = ['weixingzh_token'];
+ check(!$provider->validate('users', $clearData, 'oauth')['valid'], 'active provider allowed required secret clear');
+ check($provider->read_settings('users', 'oauth')['weixingzh_token'] === '***', 'secret read was not masked');
+
+ echo "OAuth security harness passed.\n";
+}
diff --git a/tests/widget-system/toast-contract.test.cjs b/tests/widget-system/toast-contract.test.cjs
index 7a42c6df..7845f062 100644
--- a/tests/widget-system/toast-contract.test.cjs
+++ b/tests/widget-system/toast-contract.test.cjs
@@ -352,7 +352,7 @@ test('settings actions use rehydrated fixed-id notyf flows', () => {
assert.match(userCenter, /notyf\([^;]+?'danger'[^;]+?toastId\);/);
assert.match(userCenter, /register\('aksrt-settings',[\s\S]*?initSettings\(root\);[\s\S]*?initBindPhone\(root\);/);
assert.match(userCenter, /aksrtSettingsInitialized/);
- assert.match(userCenter, /oauth_bound[\s\S]*?notyf\([^;]+?'success'\)[\s\S]*?history\.replaceState/);
+ assert.match(userCenter, /oauth_action[\s\S]*?notyf\(message, type\)[\s\S]*?history\.replaceState/);
assert.doesNotMatch(userCenter, /oauth_unbound/);
assert.match(userCenter, /'aksrt-settings-phone-code-toast'/);