mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 20:17:03 +00:00
fix(auth): harden delayed oauth token registration
This commit is contained in:
1 parent
e2e5d99465
commit
01313a5a8e
4 files changed
+457
-36
No files matched your search
@@ -173,13 +173,13 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
|
||||
expect(registry.providers.kiro.accounts['github-ABC123']?.nickname).toBe('work');
|
||||
});
|
||||
|
||||
it('returns 409 when callback completes upstream but no account can be registered locally', async () => {
|
||||
it('returns wait after callback submission when the local token is not yet available', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/kiro-auth-url\?is_webui=true&method=google$/,
|
||||
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
|
||||
response: {
|
||||
auth_url: 'https://auth.example.com/authorize?state=state-409',
|
||||
state: 'state-409',
|
||||
auth_url: 'https://auth.example.com/authorize?state=state-callback-wait',
|
||||
state: 'state-callback-wait',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -187,23 +187,41 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
|
||||
method: 'POST',
|
||||
response: { status: 'ok' },
|
||||
},
|
||||
{
|
||||
url: /\/v0\/management\/get-auth-status\?state=state-callback-wait$/,
|
||||
response: { status: 'ok' },
|
||||
},
|
||||
]);
|
||||
|
||||
const startResponse = await postJson('/api/cliproxy/auth/kiro/start-url', {
|
||||
nickname: 'work',
|
||||
kiroMethod: 'google',
|
||||
});
|
||||
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
|
||||
expect(startResponse.status).toBe(200);
|
||||
|
||||
const callbackResponse = await postJson('/api/cliproxy/auth/kiro/submit-callback', {
|
||||
redirectUrl: 'http://localhost/callback?code=abc123&state=state-409',
|
||||
});
|
||||
const realDateNow = Date.now;
|
||||
let now = realDateNow();
|
||||
Date.now = () => now;
|
||||
try {
|
||||
const callbackResponse = await postJson('/api/cliproxy/auth/codex/submit-callback', {
|
||||
redirectUrl: 'http://localhost/callback?code=abc123&state=state-callback-wait',
|
||||
});
|
||||
|
||||
expect(callbackResponse.status).toBe(409);
|
||||
expect(callbackResponse.body).toEqual({
|
||||
error:
|
||||
'Authenticated token could not be matched to a new account. Retry the flow and choose a different nickname if needed.',
|
||||
});
|
||||
expect(callbackResponse.status).toBe(200);
|
||||
expect(callbackResponse.body).toEqual({ status: 'wait' });
|
||||
|
||||
now += 16_000;
|
||||
|
||||
const statusResponse = await getJson(
|
||||
'/api/cliproxy/auth/codex/status?state=state-callback-wait'
|
||||
);
|
||||
|
||||
expect(statusResponse.status).toBe(409);
|
||||
expect(statusResponse.body).toEqual({
|
||||
status: 'error',
|
||||
error:
|
||||
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.',
|
||||
});
|
||||
} finally {
|
||||
Date.now = realDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps polling briefly after upstream completion before surfacing the missing-token error', async () => {
|
||||
@@ -310,6 +328,191 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('lets polling finish successfully after callback submission once the token appears', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
|
||||
response: {
|
||||
auth_url: 'https://auth.example.com/authorize?state=state-callback-delayed',
|
||||
state: 'state-callback-delayed',
|
||||
},
|
||||
},
|
||||
{
|
||||
url: /\/v0\/management\/oauth-callback$/,
|
||||
method: 'POST',
|
||||
response: { status: 'ok' },
|
||||
},
|
||||
{
|
||||
url: /\/v0\/management\/get-auth-status\?state=state-callback-delayed$/,
|
||||
response: { status: 'ok' },
|
||||
},
|
||||
]);
|
||||
|
||||
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
|
||||
expect(startResponse.status).toBe(200);
|
||||
|
||||
const callbackResponse = await postJson('/api/cliproxy/auth/codex/submit-callback', {
|
||||
redirectUrl: 'http://localhost/callback?code=abc123&state=state-callback-delayed',
|
||||
});
|
||||
|
||||
expect(callbackResponse.status).toBe(200);
|
||||
expect(callbackResponse.body).toEqual({ status: 'wait' });
|
||||
|
||||
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(tokenDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tokenDir, 'codex-callback@example.com.json'),
|
||||
JSON.stringify({ type: 'codex', email: 'callback@example.com' }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const statusResponse = await getJson(
|
||||
'/api/cliproxy/auth/codex/status?state=state-callback-delayed'
|
||||
);
|
||||
|
||||
expect(statusResponse.status).toBe(200);
|
||||
expect(statusResponse.body).toEqual({
|
||||
status: 'ok',
|
||||
account: {
|
||||
id: 'callback@example.com',
|
||||
email: 'callback@example.com',
|
||||
nickname: 'callback',
|
||||
provider: 'codex',
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the pending auth state alive through the local-token grace window even near TTL expiry', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
|
||||
response: {
|
||||
auth_url: 'https://auth.example.com/authorize?state=state-ttl-grace',
|
||||
state: 'state-ttl-grace',
|
||||
},
|
||||
},
|
||||
{
|
||||
url: /\/v0\/management\/get-auth-status\?state=state-ttl-grace$/,
|
||||
response: { status: 'ok' },
|
||||
},
|
||||
{
|
||||
url: /\/v0\/management\/get-auth-status\?state=state-ttl-grace$/,
|
||||
response: { status: 'ok' },
|
||||
},
|
||||
]);
|
||||
|
||||
const realDateNow = Date.now;
|
||||
let now = realDateNow();
|
||||
Date.now = () => now;
|
||||
try {
|
||||
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
|
||||
expect(startResponse.status).toBe(200);
|
||||
|
||||
now += 10 * 60 * 1000 - 1_000;
|
||||
|
||||
const firstStatusResponse = await getJson(
|
||||
'/api/cliproxy/auth/codex/status?state=state-ttl-grace'
|
||||
);
|
||||
|
||||
expect(firstStatusResponse.status).toBe(200);
|
||||
expect(firstStatusResponse.body).toEqual({ status: 'wait' });
|
||||
|
||||
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(tokenDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tokenDir, 'codex-ttl@example.com.json'),
|
||||
JSON.stringify({ type: 'codex', email: 'ttl@example.com' }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
now += 2_000;
|
||||
|
||||
const secondStatusResponse = await getJson(
|
||||
'/api/cliproxy/auth/codex/status?state=state-ttl-grace'
|
||||
);
|
||||
|
||||
expect(secondStatusResponse.status).toBe(200);
|
||||
expect(secondStatusResponse.body).toEqual({
|
||||
status: 'ok',
|
||||
account: {
|
||||
id: 'ttl@example.com',
|
||||
email: 'ttl@example.com',
|
||||
nickname: 'ttl',
|
||||
provider: 'codex',
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
Date.now = realDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
it('does not treat rewrites of pre-existing token files as a newly added account', async () => {
|
||||
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(tokenDir, { recursive: true });
|
||||
const tokenPath = path.join(tokenDir, 'codex-existing@example.com.json');
|
||||
fs.writeFileSync(
|
||||
tokenPath,
|
||||
JSON.stringify({ type: 'codex', email: 'existing@example.com', version: 1 }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
|
||||
response: {
|
||||
auth_url: 'https://auth.example.com/authorize?state=state-existing-rewrite',
|
||||
state: 'state-existing-rewrite',
|
||||
},
|
||||
},
|
||||
{
|
||||
url: /\/v0\/management\/get-auth-status\?state=state-existing-rewrite$/,
|
||||
response: { status: 'ok' },
|
||||
},
|
||||
{
|
||||
url: /\/v0\/management\/get-auth-status\?state=state-existing-rewrite$/,
|
||||
response: { status: 'ok' },
|
||||
},
|
||||
]);
|
||||
|
||||
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {});
|
||||
expect(startResponse.status).toBe(200);
|
||||
|
||||
fs.writeFileSync(
|
||||
tokenPath,
|
||||
JSON.stringify({ type: 'codex', email: 'existing@example.com', version: 2 }),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const realDateNow = Date.now;
|
||||
let now = realDateNow();
|
||||
Date.now = () => now;
|
||||
try {
|
||||
const firstStatusResponse = await getJson(
|
||||
'/api/cliproxy/auth/codex/status?state=state-existing-rewrite'
|
||||
);
|
||||
|
||||
expect(firstStatusResponse.status).toBe(200);
|
||||
expect(firstStatusResponse.body).toEqual({ status: 'wait' });
|
||||
|
||||
now += 16_000;
|
||||
|
||||
const secondStatusResponse = await getJson(
|
||||
'/api/cliproxy/auth/codex/status?state=state-existing-rewrite'
|
||||
);
|
||||
|
||||
expect(secondStatusResponse.status).toBe(409);
|
||||
expect(secondStatusResponse.body).toEqual({
|
||||
status: 'error',
|
||||
error:
|
||||
'Authentication completed upstream, but no new local token was saved for this account. Update CCS/CLIProxy and retry.',
|
||||
});
|
||||
} finally {
|
||||
Date.now = realDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
it('registers the new account before reporting polled auth success', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user