feat(makelore): add platform oidc login
This commit is contained in:
1 parent
87d3f03d75
commit
7236def07d
12 files changed
+528
-10
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: {} };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user