325 lines
12 KiB
JavaScript
325 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const crypto = require('crypto');
|
|
const zlib = require('zlib');
|
|
const { loginOpenAIOAuthWithGoogle, _private } = require('../src/services/openai_oauth_service');
|
|
|
|
function jwt(payload) {
|
|
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
|
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
return `${header}.${body}.signature`;
|
|
}
|
|
|
|
(async () => {
|
|
const { verifier, challenge } = _private.generatePKCE();
|
|
assert.match(verifier, /^[a-f0-9]{128}$/);
|
|
assert.strictEqual(
|
|
challenge,
|
|
crypto.createHash('sha256').update(verifier).digest().toString('base64url'),
|
|
);
|
|
|
|
const generatedState = _private.generateState();
|
|
assert.match(generatedState, /^[a-f0-9]{64}$/);
|
|
|
|
const authorize = new URL(_private.buildAuthorizeUrl({ challenge, state: 'state-1' }));
|
|
assert.strictEqual(authorize.origin, 'https://auth.openai.com');
|
|
assert.strictEqual(authorize.pathname, '/oauth/authorize');
|
|
assert.strictEqual(authorize.searchParams.get('client_id'), _private.OAUTH_CLIENT_ID);
|
|
assert.strictEqual(authorize.searchParams.get('redirect_uri'), _private.OAUTH_REDIRECT_URI);
|
|
assert.strictEqual(authorize.searchParams.get('state'), 'state-1');
|
|
assert.strictEqual(authorize.searchParams.get('code_challenge'), challenge);
|
|
assert.deepStrictEqual(
|
|
[...authorize.searchParams.keys()],
|
|
[
|
|
'client_id',
|
|
'code_challenge',
|
|
'code_challenge_method',
|
|
'codex_cli_simplified_flow',
|
|
'id_token_add_organizations',
|
|
'redirect_uri',
|
|
'response_type',
|
|
'scope',
|
|
'state',
|
|
],
|
|
);
|
|
|
|
const callback = _private.readCallback(
|
|
`${_private.OAUTH_REDIRECT_URI}?code=code-1&state=state-1`,
|
|
'state-1',
|
|
);
|
|
assert.strictEqual(callback.code, 'code-1');
|
|
assert.strictEqual(_private.readCallback('http://localhost:1455/other?code=x', 'state-1'), null);
|
|
assert.throws(
|
|
() => _private.readCallback(`${_private.OAUTH_REDIRECT_URI}?code=x&state=wrong`, 'state-1'),
|
|
error => error && error.code === 'OPENAI_OAUTH_STATE_MISMATCH',
|
|
);
|
|
|
|
const session = { workspaces: [{ id: 'personal' }, { id: 'team' }] };
|
|
const encoded = Buffer.from(JSON.stringify(session)).toString('base64url');
|
|
assert.deepStrictEqual(_private.decodeAuthSessionCookie(`${encoded}.stamp.signature`), session);
|
|
const compressed = zlib.deflateRawSync(Buffer.from(JSON.stringify(session))).toString('base64url');
|
|
assert.deepStrictEqual(_private.decodeAuthSessionCookie(`.${compressed}.stamp.signature`), session);
|
|
assert.strictEqual(_private.chooseWorkspace(session, 'team').id, 'team');
|
|
assert.strictEqual(_private.chooseWorkspace(session, 'missing').id, 'personal');
|
|
|
|
const accessToken = jwt({
|
|
email: 'user@gmail.com',
|
|
'https://api.openai.com/auth': {
|
|
user_id: 'user-1',
|
|
chatgpt_account_id: 'account-1',
|
|
},
|
|
});
|
|
const idToken = jwt({ sub: 'user-1', email: 'user@gmail.com' });
|
|
const auth = _private.buildAuthJson('code-1', {
|
|
access_token: accessToken,
|
|
refresh_token: 'refresh-1',
|
|
id_token: idToken,
|
|
token_type: 'Bearer',
|
|
expires_in: 3600,
|
|
});
|
|
assert.strictEqual(auth.code, 'code-1');
|
|
assert.strictEqual(auth.code_used, true);
|
|
assert.strictEqual(auth.access_token, accessToken);
|
|
assert.strictEqual(auth.refresh_token, 'refresh-1');
|
|
assert.strictEqual(auth.id_token, idToken);
|
|
assert.strictEqual(auth.tokens.account_id, 'account-1');
|
|
assert.strictEqual(auth.email, 'user@gmail.com');
|
|
assert.ok(auth.expires_at);
|
|
assert.strictEqual(_private.normalizeIdentityEmail('U.ser+tag@googlemail.com'), 'user@gmail.com');
|
|
|
|
assert.strictEqual(
|
|
_private.shouldRunOAuthInteractor('https://auth.openai.com/choose-an-account', true),
|
|
true,
|
|
'即使已有授权 Cookie,也必须先处理账号选择页',
|
|
);
|
|
assert.strictEqual(
|
|
_private.shouldRunOAuthInteractor('https://auth.openai.com/sign-in-with-chatgpt/codex/consent', true),
|
|
false,
|
|
'已建立授权会话后才允许进入协议 consent',
|
|
);
|
|
assert.strictEqual(
|
|
_private.shouldRunOAuthInteractor('https://auth.openai.com/sign-in-with-chatgpt/codex/consent', false),
|
|
true,
|
|
'consent 页面缺少授权 Cookie 时仍需恢复登录态',
|
|
);
|
|
|
|
let accountClicked = false;
|
|
const accountCandidate = {
|
|
isVisible: async () => true,
|
|
innerText: async () => 'Caufield Amistadi\namistadicaufield@gmail.com',
|
|
getAttribute: async () => null,
|
|
isDisabled: async () => false,
|
|
click: async () => { accountClicked = true; },
|
|
};
|
|
const accountPage = {
|
|
locator: () => ({
|
|
count: async () => 1,
|
|
nth: () => accountCandidate,
|
|
}),
|
|
getByText: () => ({
|
|
first: () => ({ isVisible: async () => false }),
|
|
}),
|
|
};
|
|
const selected = await _private.clickMatchingOpenAIAccount(
|
|
accountPage,
|
|
'AmistadiCaufield@gmail.com',
|
|
);
|
|
assert.strictEqual(selected.ok, true);
|
|
assert.strictEqual(accountClicked, true);
|
|
|
|
const busyPage = {
|
|
locator: () => ({
|
|
count: async () => 1,
|
|
nth: () => ({
|
|
isVisible: async () => true,
|
|
innerText: async () => 'amistadicaufield@gmail.com',
|
|
getAttribute: async name => (name === 'aria-busy' ? 'true' : null),
|
|
isDisabled: async () => true,
|
|
click: async () => { throw new Error('busy account button must not be clicked'); },
|
|
}),
|
|
}),
|
|
getByText: () => ({ first: () => ({ isVisible: async () => false }) }),
|
|
};
|
|
const pending = await _private.clickMatchingOpenAIAccount(
|
|
busyPage,
|
|
'AmistadiCaufield@gmail.com',
|
|
);
|
|
assert.strictEqual(pending.ok, true);
|
|
assert.strictEqual(pending.pending, true);
|
|
assert.strictEqual(
|
|
_private.isOpenAIPhoneRequiredPage('https://auth.openai.com/add-phone'),
|
|
true,
|
|
);
|
|
assert.strictEqual(
|
|
_private.isOpenAIPhoneRequiredPage('https://auth.openai.com/other', 'Phone number required'),
|
|
true,
|
|
);
|
|
|
|
const deactivatedCopy = 'Authentication Error. You do not have an account because it has been deleted or deactivated.';
|
|
assert.strictEqual(
|
|
_private.detectOpenAIAccountDeactivated('https://auth.openai.com/error', deactivatedCopy).code,
|
|
'CHATGPT_ACCOUNT_DEACTIVATED',
|
|
);
|
|
const deactivatedPage = {
|
|
url: () => 'https://auth.openai.com/error',
|
|
waitForTimeout: async () => {},
|
|
locator: selector => (selector === 'body'
|
|
? { innerText: async () => deactivatedCopy }
|
|
: { first: () => ({ isVisible: async () => false }) }),
|
|
};
|
|
const deactivatedResult = await loginOpenAIOAuthWithGoogle(
|
|
deactivatedPage,
|
|
{ email: 'deleted@example.com' },
|
|
() => {},
|
|
);
|
|
assert.strictEqual(deactivatedResult.code, 'CHATGPT_ACCOUNT_DEACTIVATED');
|
|
assert.strictEqual(deactivatedResult.retryable, false);
|
|
assert.strictEqual(deactivatedResult.restartBrowser, false);
|
|
|
|
assert.strictEqual(
|
|
_private.isOpenAIWorkspaceSelectionPage(
|
|
'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
|
|
'Select a workspace Personal account Continue',
|
|
),
|
|
true,
|
|
);
|
|
let workspaceContinueClicked = false;
|
|
const workspaceContinueButton = {
|
|
isVisible: async () => true,
|
|
innerText: async () => 'Continue',
|
|
getAttribute: async () => null,
|
|
isDisabled: async () => false,
|
|
click: async () => { workspaceContinueClicked = true; },
|
|
};
|
|
const workspacePage = {
|
|
url: () => 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
|
|
locator: selector => (selector === 'body'
|
|
? { innerText: async () => 'Select a workspace Personal account Continue' }
|
|
: { count: async () => 1, nth: () => workspaceContinueButton }),
|
|
waitForTimeout: async () => {},
|
|
};
|
|
const workspaceSubmitted = await _private.submitOpenAIWorkspaceSelection(workspacePage);
|
|
assert.strictEqual(workspaceSubmitted.ok, true);
|
|
assert.strictEqual(workspaceContinueClicked, true);
|
|
|
|
let nativeSubmitCalled = false;
|
|
const protocolCandidate = {
|
|
isVisible: async () => true,
|
|
innerText: async () => 'Caufield Amistadi\namistadicaufield@gmail.com',
|
|
evaluate: async (fn) => {
|
|
const button = {
|
|
name: 'session_id',
|
|
value: 'session-1',
|
|
closest: () => ({
|
|
action: 'https://auth.openai.com/choose-an-account',
|
|
method: 'POST',
|
|
requestSubmit: submittedButton => {
|
|
nativeSubmitCalled = submittedButton === button;
|
|
},
|
|
}),
|
|
};
|
|
const OriginalFormData = global.FormData;
|
|
global.FormData = class MockFormData {
|
|
entries() { return [['intent', 'select']][Symbol.iterator](); }
|
|
};
|
|
try { return fn(button); } finally { global.FormData = OriginalFormData; }
|
|
},
|
|
};
|
|
const protocolPage = {
|
|
url: () => 'https://auth.openai.com/choose-an-account',
|
|
locator: () => ({ count: async () => 1, nth: () => protocolCandidate }),
|
|
};
|
|
const submitted = await _private.submitMatchingOpenAIAccount(
|
|
protocolPage,
|
|
'AmistadiCaufield@gmail.com',
|
|
);
|
|
assert.strictEqual(submitted.ok, true);
|
|
assert.strictEqual(nativeSubmitCalled, true);
|
|
assert.strictEqual(submitted.targetUrl, 'https://auth.openai.com/choose-an-account');
|
|
|
|
let exchangePayload = null;
|
|
const exchangePage = {
|
|
url: () => 'https://auth.openai.com/oauth/token',
|
|
evaluate: async (_fn, payload) => {
|
|
exchangePayload = payload;
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
url: payload.targetUrl,
|
|
data: {
|
|
access_token: 'access-2',
|
|
refresh_token: 'refresh-2',
|
|
id_token: 'id-2',
|
|
},
|
|
};
|
|
},
|
|
};
|
|
const exchanged = await _private.exchangeCodeInBrowser(exchangePage, 'code-2', 'verifier-2');
|
|
assert.strictEqual(exchanged.refresh_token, 'refresh-2');
|
|
assert.strictEqual(exchangePayload.targetUrl, 'https://auth.openai.com/oauth/token');
|
|
assert.strictEqual(exchangePayload.method, 'POST');
|
|
assert.strictEqual(exchangePayload.form.code, 'code-2');
|
|
assert.strictEqual(exchangePayload.form.code_verifier, 'verifier-2');
|
|
|
|
let chatGPTGoogleClicks = 0;
|
|
let chatGPTLoginUrl = 'https://chatgpt.com/';
|
|
const missingAction = {
|
|
first: () => ({ isVisible: async () => false }),
|
|
};
|
|
const chatGPTGoogleButton = {
|
|
isVisible: async () => true,
|
|
click: async () => {
|
|
chatGPTGoogleClicks++;
|
|
chatGPTLoginUrl = 'https://accounts.google.com/signin/v2';
|
|
},
|
|
};
|
|
const chatGPTPanelPage = {
|
|
url: () => chatGPTLoginUrl,
|
|
waitForTimeout: async () => {},
|
|
locator: selector => selector === 'button:text-is("Continue with Google")'
|
|
? { first: () => chatGPTGoogleButton }
|
|
: missingAction,
|
|
};
|
|
const panelResult = await loginOpenAIOAuthWithGoogle(
|
|
chatGPTPanelPage,
|
|
{ email: 'target@example.com' },
|
|
() => {},
|
|
{
|
|
isSignedInChatGPTHome: async () => false,
|
|
handleGoogleOAuth: async () => ({ ok: false, code: 'TEST_GOOGLE_REACHED', msg: 'reached' }),
|
|
},
|
|
);
|
|
assert.strictEqual(chatGPTGoogleClicks, 1, 'OAuth二次登录应点击ChatGPT右侧面板的Continue with Google');
|
|
assert.strictEqual(panelResult.code, 'TEST_GOOGLE_REACHED');
|
|
|
|
let chooserCalls = 0;
|
|
let chooserUrl = 'https://chatgpt.com/';
|
|
const chooserPage = {
|
|
url: () => chooserUrl,
|
|
waitForTimeout: async () => {},
|
|
locator: () => missingAction,
|
|
};
|
|
const chooserResult = await loginOpenAIOAuthWithGoogle(
|
|
chooserPage,
|
|
{ email: 'target@example.com' },
|
|
() => {},
|
|
{
|
|
isSignedInChatGPTHome: async () => false,
|
|
handleChatGPTAccountChooser: async () => {
|
|
chooserCalls++;
|
|
chooserUrl = 'https://accounts.google.com/signin/v2';
|
|
return { found: true, handled: true, transitioned: true };
|
|
},
|
|
handleGoogleOAuth: async () => ({ ok: false, code: 'TEST_CHOOSER_REACHED', msg: 'reached' }),
|
|
},
|
|
);
|
|
assert.strictEqual(chooserCalls, 1, 'OAuth二次登录应复用ChatGPT账号选择弹窗处理器');
|
|
assert.strictEqual(chooserResult.code, 'TEST_CHOOSER_REACHED');
|
|
|
|
console.log('openai_oauth_service.test.js: PASS');
|
|
})().catch(error => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|