实现客户端登录七天滑动续期
需求:解决短效访问令牌到期后客户端一小时掉登录的问题。 实现:由 Electron Main 加密管理并轮换刷新凭据,按真实用户活动续期,七天闲置后清理会话,并补齐并发、迁移和终态回归测试。
This commit is contained in:
@@ -4,9 +4,14 @@ import { shell } from 'electron';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { handleAuthRoutes } from '@electron/api/routes/auth';
|
||||
import {
|
||||
clearWorksSquareSession,
|
||||
commitWorksSquareSession,
|
||||
flushWorksSquareSessionPersistence,
|
||||
getWorksSquareSessionSnapshot,
|
||||
initializeWorksSquareSession,
|
||||
resetWorksSquareSessionForTests,
|
||||
storeWorksSquareSession,
|
||||
} from '@electron/services/works-square-session';
|
||||
import { resetManagedWorksSquareRuntimeForTests } from '@electron/services/works-square-runtime';
|
||||
|
||||
const providerServiceMock = vi.hoisted(() => ({
|
||||
deleteAccountApiKey: vi.fn(),
|
||||
@@ -52,7 +57,8 @@ function createRequest(method: string, body?: unknown): IncomingMessage {
|
||||
describe('auth host api routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearWorksSquareSession();
|
||||
resetWorksSquareSessionForTests();
|
||||
resetManagedWorksSquareRuntimeForTests();
|
||||
providerServiceMock.deleteAccountApiKey.mockReset();
|
||||
providerServiceMock.deleteAccountApiKey.mockResolvedValue(true);
|
||||
});
|
||||
@@ -91,13 +97,19 @@ describe('auth host api routes', () => {
|
||||
success: true,
|
||||
token: {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 43200,
|
||||
username: 'zhangsan',
|
||||
user_id: '1',
|
||||
client_id: 'app',
|
||||
},
|
||||
session: {
|
||||
accessToken: 'access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: expect.any(Number),
|
||||
lastActiveAt: expect.any(Number),
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
@@ -114,6 +126,41 @@ describe('auth host api routes', () => {
|
||||
expect(String(init.body)).not.toContain('passw0rd');
|
||||
});
|
||||
|
||||
it('allows explicit reauthorization to replace an unreadable persisted session', async () => {
|
||||
const persistence = {
|
||||
load: vi.fn().mockRejectedValue(new Error('credential cannot be decrypted')),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
await initializeWorksSquareSession({ persistence });
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
access_token: 'replacement-access-token',
|
||||
refresh_token: 'replacement-refresh-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
username: 'zhangsan',
|
||||
}), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
await handleAuthRoutes(
|
||||
createRequest('POST', { username: 'zhangsan', password: 'passw0rd' }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/login'),
|
||||
{
|
||||
opencodeManager: { stop: vi.fn().mockResolvedValue(undefined) },
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(persistence.save).toHaveBeenNthCalledWith(1, null);
|
||||
expect(persistence.save).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
accessToken: 'replacement-access-token',
|
||||
refreshToken: 'replacement-refresh-token',
|
||||
}));
|
||||
});
|
||||
|
||||
it('passes through SSO credential errors without exposing secrets', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ code: 1, msg: 'Bad credentials', data: null }), { status: 401 }),
|
||||
@@ -182,12 +229,18 @@ describe('auth host api routes', () => {
|
||||
success: true,
|
||||
token: {
|
||||
access_token: 'desktop-access-token',
|
||||
refresh_token: 'desktop-refresh-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 43200,
|
||||
username: 'student',
|
||||
user_id: '42',
|
||||
},
|
||||
session: {
|
||||
accessToken: 'desktop-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: expect.any(Number),
|
||||
lastActiveAt: expect.any(Number),
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
expect(shell.openExternal).toHaveBeenCalledWith(
|
||||
'https://square.nianxx.cn/#desktop-auth?request_id=desktop-request-id',
|
||||
@@ -234,13 +287,16 @@ describe('auth host api routes', () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(getWorksSquareSessionSnapshot()).toMatchObject({
|
||||
accessToken: 'desktop-access-token',
|
||||
refreshToken: 'desktop-refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 43_200_000,
|
||||
canRefresh: true,
|
||||
});
|
||||
expect(getWorksSquareSessionSnapshot()).not.toHaveProperty('refreshToken');
|
||||
});
|
||||
|
||||
it('accepts renderer session sync after app restart', async () => {
|
||||
vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z'));
|
||||
const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const response = createResponse();
|
||||
|
||||
const handled = await handleAuthRoutes(
|
||||
@@ -249,6 +305,7 @@ describe('auth host api routes', () => {
|
||||
refreshToken: 'persisted-refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: 1_783_000_000_000,
|
||||
lastActiveAt,
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/session/sync'),
|
||||
@@ -257,15 +314,221 @@ describe('auth host api routes', () => {
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
expect(response.json()).toEqual({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'persisted-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: 1_783_000_000_000,
|
||||
lastActiveAt,
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
expect(getWorksSquareSessionSnapshot()).toEqual({
|
||||
accessToken: 'persisted-access-token',
|
||||
refreshToken: 'persisted-refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: 1_783_000_000_000,
|
||||
lastActiveAt,
|
||||
canRefresh: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not overwrite recoverable Main credentials when restore is temporarily unavailable', async () => {
|
||||
const persistence = {
|
||||
load: vi.fn().mockRejectedValue(new Error('credential store locked')),
|
||||
save: vi.fn(),
|
||||
};
|
||||
await initializeWorksSquareSession({ persistence });
|
||||
const response = createResponse();
|
||||
|
||||
await handleAuthRoutes(
|
||||
createRequest('POST', {
|
||||
accessToken: 'renderer-access-without-refresh',
|
||||
refreshToken: null,
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
lastActiveAt: Date.now(),
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/session/sync'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(persistence.load).toHaveBeenCalledTimes(2);
|
||||
expect(persistence.save).not.toHaveBeenCalled();
|
||||
expect(getWorksSquareSessionSnapshot()).toBeNull();
|
||||
});
|
||||
|
||||
it('retries Main restore before treating user activity as an expired session', async () => {
|
||||
const persistedSession = {
|
||||
accessToken: 'restored-access-token',
|
||||
refreshToken: 'restored-refresh-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
lastActiveAt: Date.now() - 60_000,
|
||||
};
|
||||
const persistence = {
|
||||
load: vi.fn()
|
||||
.mockRejectedValueOnce(new Error('credential store locked'))
|
||||
.mockRejectedValueOnce(new Error('credential store still locked'))
|
||||
.mockResolvedValue(persistedSession),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
await initializeWorksSquareSession({ persistence });
|
||||
|
||||
const unavailableResponse = createResponse();
|
||||
await handleAuthRoutes(
|
||||
createRequest('POST'),
|
||||
unavailableResponse.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/session/sync'),
|
||||
{} as never,
|
||||
);
|
||||
expect(unavailableResponse.statusCode).toBe(503);
|
||||
|
||||
const activityResponse = createResponse();
|
||||
await handleAuthRoutes(
|
||||
createRequest('POST'),
|
||||
activityResponse.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/session/activity'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(activityResponse.statusCode).toBe(200);
|
||||
expect(activityResponse.json()).toMatchObject({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'restored-access-token',
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
expect(persistence.load).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does not let a stale renderer session roll back the Main-owned refresh token', async () => {
|
||||
vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z'));
|
||||
const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000;
|
||||
storeWorksSquareSession({
|
||||
accessToken: 'main-access-r1',
|
||||
refreshToken: 'main-refresh-r1',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
lastActiveAt,
|
||||
});
|
||||
const syncResponse = createResponse();
|
||||
|
||||
await handleAuthRoutes(
|
||||
createRequest('POST', {
|
||||
accessToken: 'renderer-access-r0',
|
||||
refreshToken: 'renderer-refresh-r0',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 7200_000,
|
||||
lastActiveAt: Date.now(),
|
||||
}),
|
||||
syncResponse.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/session/sync'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(syncResponse.statusCode).toBe(200);
|
||||
expect(syncResponse.json()).toEqual({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'main-access-r1',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
lastActiveAt,
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
access_token: 'main-access-r2',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
}), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const refreshResponse = createResponse();
|
||||
|
||||
await handleAuthRoutes(
|
||||
createRequest('POST', { forceRefresh: true }),
|
||||
refreshResponse.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/session/refresh'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(refreshResponse.statusCode).toBe(200);
|
||||
const [, refreshInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(refreshInit.body)).toBe(
|
||||
'grant_type=refresh_token&refresh_token=main-refresh-r1',
|
||||
);
|
||||
expect(String(refreshInit.body)).not.toContain('renderer-refresh-r0');
|
||||
});
|
||||
|
||||
it('refreshes through the Main-owned session and returns the rotated token bundle', async () => {
|
||||
vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z'));
|
||||
const lastActiveAt = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'rotated-refresh-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
}), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
storeWorksSquareSession({
|
||||
accessToken: 'expired-access-token',
|
||||
refreshToken: 'old-refresh-token',
|
||||
expiresAt: Date.now() - 1,
|
||||
lastActiveAt,
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handleAuthRoutes(
|
||||
createRequest('POST', { forceRefresh: false }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/session/refresh'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
success: true,
|
||||
session: {
|
||||
accessToken: 'new-access-token',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
lastActiveAt,
|
||||
canRefresh: true,
|
||||
},
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('updates activity only through the explicit activity route', async () => {
|
||||
vi.setSystemTime(new Date('2026-07-06T08:00:00.000Z'));
|
||||
storeWorksSquareSession({
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
lastActiveAt: Date.now() - 24 * 60 * 60 * 1000,
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handleAuthRoutes(
|
||||
createRequest('POST'),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/session/activity'),
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(getWorksSquareSessionSnapshot()?.lastActiveAt).toBe(Date.now());
|
||||
});
|
||||
|
||||
it('stops the runtime and clears the managed Works Square key on logout', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ success: true }), { status: 200 }),
|
||||
@@ -317,4 +580,155 @@ describe('auth host api routes', () => {
|
||||
});
|
||||
expect(providerServiceMock.deleteAccountApiKey).toHaveBeenCalledWith('niancode-user-models');
|
||||
});
|
||||
|
||||
it('does not report logout success when a remote event session cannot be closed', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ success: true }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
await handleAuthRoutes(
|
||||
createRequest('POST', { accessToken: 'access-token' }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/logout'),
|
||||
{
|
||||
opencodeManager: { stop: vi.fn().mockResolvedValue(undefined) },
|
||||
imageWorkspace: {
|
||||
closeEventSessions: vi.fn().mockRejectedValue(new Error('remote close failed')),
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('uses the Main session token for logout instead of a stale Renderer token', async () => {
|
||||
storeWorksSquareSession({
|
||||
accessToken: 'main-current-access-token',
|
||||
refreshToken: 'main-current-refresh-token',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
lastActiveAt: Date.now(),
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ success: true }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const closeEventSessions = vi.fn(async () => undefined);
|
||||
const response = createResponse();
|
||||
|
||||
await handleAuthRoutes(
|
||||
createRequest('POST', { accessToken: 'renderer-stale-access-token' }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/logout'),
|
||||
{
|
||||
opencodeManager: { stop: vi.fn().mockResolvedValue(undefined) },
|
||||
imageWorkspace: { closeEventSessions },
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://biz.nianxx.cn/auth/token/logout',
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: 'Bearer main-current-access-token' },
|
||||
}),
|
||||
);
|
||||
expect(closeEventSessions).toHaveBeenCalledWith({
|
||||
accessToken: 'main-current-access-token',
|
||||
tolerateRemoteFailure: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks a new login while cleanup of the previous runtime still fails', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ success: true }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const stop = vi.fn().mockRejectedValue(new Error('runtime stop failed'));
|
||||
const context = { opencodeManager: { stop } } as never;
|
||||
const logoutResponse = createResponse();
|
||||
|
||||
await handleAuthRoutes(
|
||||
createRequest('POST', { accessToken: 'old-access-token' }),
|
||||
logoutResponse.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/logout'),
|
||||
context,
|
||||
);
|
||||
expect(logoutResponse.statusCode).toBe(500);
|
||||
fetchMock.mockClear();
|
||||
|
||||
const loginResponse = createResponse();
|
||||
await handleAuthRoutes(
|
||||
createRequest('POST', { username: 'zhangsan', password: 'passw0rd' }),
|
||||
loginResponse.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/login'),
|
||||
context,
|
||||
);
|
||||
|
||||
expect(loginResponse.statusCode).toBe(503);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(stop).toHaveBeenCalledTimes(2);
|
||||
expect(getWorksSquareSessionSnapshot()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not report logout success when secure storage rejects API key deletion', async () => {
|
||||
providerServiceMock.deleteAccountApiKey.mockResolvedValueOnce(false);
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ success: true }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
await handleAuthRoutes(
|
||||
createRequest('POST', { accessToken: 'access-token' }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/logout'),
|
||||
{
|
||||
opencodeManager: { stop: vi.fn().mockResolvedValue(undefined) },
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(500);
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('still calls remote logout when clearing the persisted local session fails', async () => {
|
||||
const persistence = {
|
||||
load: vi.fn().mockResolvedValue(null),
|
||||
save: vi.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('credential store locked')),
|
||||
};
|
||||
await initializeWorksSquareSession({ persistence });
|
||||
await commitWorksSquareSession({
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
lastActiveAt: Date.now(),
|
||||
});
|
||||
await flushWorksSquareSessionPersistence();
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ success: true }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
await handleAuthRoutes(
|
||||
createRequest('POST', { accessToken: 'access-token' }),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1:13210/api/auth/logout'),
|
||||
{
|
||||
opencodeManager: { stop: vi.fn().mockResolvedValue(undefined) },
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://biz.nianxx.cn/auth/token/logout',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
);
|
||||
expect(response.statusCode).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user