feat(makelore): add platform oidc login

This commit is contained in:
brother7 committed 2026-10-10 17:29:33 +08:00
1 parent 87d3f03d75
commit 7236def07d
12 files changed
+528 -10

No files matched your search

+10
View File
@@ -24,9 +24,19 @@ describe('NianCode app deep links', () => {
});
});
it('parses the platform OIDC redirect without inventing a request id', () => {
expect(parseNianCodeDeepLinkUrl('niancode://auth/callback?code=oauth-code&state=oauth-state')).toEqual({
type: 'desktop-auth-callback',
url: 'niancode://auth/callback?code=oauth-code&state=oauth-state',
code: 'oauth-code',
state: 'oauth-state',
});
});
it('rejects links that do not target the desktop auth callback', () => {
expect(parseNianCodeDeepLinkUrl('https://square.nianxx.cn/#desktop-auth?request_id=1')).toBeNull();
expect(parseNianCodeDeepLinkUrl('niancode://auth/callback')).toBeNull();
expect(parseNianCodeDeepLinkUrl('niancode://auth/callback?code=oauth-code')).toBeNull();
expect(parseNianCodeDeepLinkUrl('niancode://auth/callback?request_id=')).toBeNull();
expect(parseNianCodeDeepLinkUrl('niancode://auth/callback?request_id=1&access_token=secret')).toBeNull();
expect(parseNianCodeDeepLinkUrl('niancode://settings')).toBeNull();
+31
View File
@@ -21,10 +21,16 @@ const providerServiceMock = vi.hoisted(() => ({
deleteAccountApiKey: vi.fn(),
}));
const platformAuthMock = vi.hoisted(() => ({
startPlatformAuthorization: vi.fn(),
}));
vi.mock('@electron/services/providers/provider-service', () => ({
getProviderService: () => providerServiceMock,
}));
vi.mock('@electron/services/platform-auth', () => platformAuthMock);
function createResponse() {
const chunks: string[] = [];
const res = {
@@ -66,6 +72,31 @@ describe('auth host api routes', () => {
resetRememberedPasswordForTests();
providerServiceMock.deleteAccountApiKey.mockReset();
providerServiceMock.deleteAccountApiKey.mockResolvedValue(true);
platformAuthMock.startPlatformAuthorization.mockReset();
platformAuthMock.startPlatformAuthorization.mockResolvedValue({
requestId: 'request-1',
authorizationUrl: 'https://square.example.test/authorize?state=state-1',
});
});
it('starts platform OIDC in Main and returns only the browser authorization URL', async () => {
const response = createResponse();
const handled = await handleAuthRoutes(
createRequest('POST', {}),
response.res,
new URL('http://127.0.0.1:13210/api/auth/platform/start'),
{} as never,
);
expect(handled).toBe(true);
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
success: true,
requestId: 'request-1',
authorizationUrl: 'https://square.example.test/authorize?state=state-1',
});
expect(platformAuthMock.startPlatformAuthorization).toHaveBeenCalledOnce();
});
it('projects a safe current-user identity and module access without exposing secrets', async () => {
+34 -1
View File
@@ -8,11 +8,16 @@ import { useProviderStore } from '@/stores/providers';
import { codingRecoveryAccount } from '@/lib/coding-login-recovery';
const hostApiFetchMock = vi.hoisted(() => vi.fn());
const invokeIpcMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));
vi.mock('@/lib/api-client', () => ({
invokeIpc: (...args: unknown[]) => invokeIpcMock(...args),
}));
const loginWithPassword = vi.fn();
const loginWithMobile = vi.fn();
const logout = vi.fn();
@@ -128,6 +133,7 @@ describe('Login page', () => {
useProviderStore.setState({ importUserModelConfig });
importUserModelConfig.mockResolvedValue(undefined);
logout.mockResolvedValue(undefined);
invokeIpcMock.mockResolvedValue(undefined);
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/public-config') {
return {
@@ -153,7 +159,7 @@ describe('Login page', () => {
vi.useRealTimers();
});
it('shows native Chinese tabs with password login as the default and no browser authorization surface', async () => {
it('shows native Chinese tabs with password login as the default and a platform login action', async () => {
renderLogin();
await screen.findByRole('link', { name: '用户协议' });
@@ -168,9 +174,36 @@ describe('Login page', () => {
loginButton.compareDocumentPosition(rememberPasswordCheckbox)
& Node.DOCUMENT_POSITION_FOLLOWING,
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(screen.getByRole('button', { name: '平台账号登录' })).toBeDisabled();
expect(screen.queryByText(/浏览器|微信|注册/)).not.toBeInTheDocument();
});
it('opens the platform authorization URL through Main after agreement', async () => {
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/public-config') return { success: true, links: {} };
if (path === '/api/auth/remembered-password') return { success: true, available: false };
if (path === '/api/auth/platform/start') {
return { success: true, authorizationUrl: 'https://square.example.test/authorize?state=state-1' };
}
return { success: true };
});
renderLogin();
const agreement = await screen.findByRole('checkbox', { name: /我已阅读并同意/ });
fireEvent.click(agreement);
fireEvent.click(screen.getByRole('button', { name: '平台账号登录' }));
await waitFor(() => expect(invokeIpcMock).toHaveBeenCalledWith(
'shell:openExternal',
'https://square.example.test/authorize?state=state-1',
));
expect(hostApiFetchMock).toHaveBeenCalledWith('/api/auth/platform/start', {
method: 'POST',
cache: 'no-store',
body: JSON.stringify({}),
});
});
it('restores OS-protected password credentials and keeps remember password selected', async () => {
hostApiFetchMock.mockImplementation(async (path: string) => {
if (path === '/api/auth/public-config') return { success: true, links: {} };
+73
View File
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
completePlatformAuthorization,
resetPlatformAuthorizationForTests,
startPlatformAuthorization,
} from '@electron/services/platform-auth';
describe('platform OIDC authorization', () => {
beforeEach(() => {
vi.restoreAllMocks();
resetPlatformAuthorizationForTests();
vi.stubEnv('PLATFORM_AUTH_ISSUER', 'https://square.example.test');
vi.stubEnv('PLATFORM_AUTH_CLIENT_ID', 'makelore');
vi.stubGlobal('fetch', vi.fn());
});
it('uses discovery, state, nonce, and S256 PKCE for the public desktop client', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
authorization_endpoint: 'https://square.example.test/api/auth/oauth/authorize',
token_endpoint: 'https://square.example.test/api/auth/oauth/token',
}), { status: 200 }));
const started = await startPlatformAuthorization();
const authorizationUrl = new URL(started.authorizationUrl);
expect(authorizationUrl.searchParams.get('client_id')).toBe('makelore');
expect(authorizationUrl.searchParams.get('redirect_uri')).toBe('niancode://auth/callback');
expect(authorizationUrl.searchParams.get('response_type')).toBe('code');
expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256');
expect(authorizationUrl.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(authorizationUrl.searchParams.get('state')).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(authorizationUrl.searchParams.get('nonce')).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(authorizationUrl.searchParams.has('request_id')).toBe(false);
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
access_token: 'access-token',
refresh_token: 'refresh-token',
token_type: 'Bearer',
expires_in: 3600,
}), { status: 200 }));
const payload = await completePlatformAuthorization(
null,
'authorization-code',
authorizationUrl.searchParams.get('state')!,
);
expect(payload).toMatchObject({ access_token: 'access-token', refresh_token: 'refresh-token' });
const [, init] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(init.method).toBe('POST');
const body = new URLSearchParams(String(init.body));
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('code')).toBe('authorization-code');
expect(body.get('client_id')).toBe('makelore');
expect(body.get('redirect_uri')).toBe('niancode://auth/callback');
expect(body.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{86}$/);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('consumes a callback state once', async () => {
const fetchMock = vi.mocked(fetch);
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
authorization_endpoint: 'https://square.example.test/api/auth/oauth/authorize',
token_endpoint: 'https://square.example.test/api/auth/oauth/token',
}), { status: 200 }));
const started = await startPlatformAuthorization();
const state = new URL(started.authorizationUrl).searchParams.get('state')!;
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'access-token' }), { status: 200 }));
await completePlatformAuthorization(null, 'authorization-code', state);
await expect(completePlatformAuthorization(null, 'authorization-code', state))
.rejects.toThrow('OIDC authorization request expired');
});
});