Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/desktop/data/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ desktop:
proxyAddress: ""
clientID: ""
clientSecret: ""
oneTapOrigins: []
oauth2:
enabled: false
pkce: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ data:
proxyAddress: "{{ .Values.desktopConfig.googleProxyAddress }}"
clientID: "{{ .Values.desktopConfig.googleClientId }}"
clientSecret: "{{ .Values.desktopConfig.googleClientSecret }}"
oneTapOrigins:
{{ toYaml .Values.desktopConfig.googleOneTapOrigins | indent 14 }}
oauth2:
enabled: {{ .Values.desktopConfig.oauth2Enabled }}
pkce: {{ .Values.desktopConfig.oauth2Pkce }}
Expand Down Expand Up @@ -189,4 +191,4 @@ data:
ssl: {{ .Values.desktopConfig.realNameOSSSSL }}
port: {{ .Values.desktopConfig.realNameOSSPort }}
realNameBucket: "{{ .Values.desktopConfig.realNameOSSRealNameBucket }}"
enterpriseRealNameBucket: "{{ .Values.desktopConfig.realNameOSSEnterpriseRealNameBucket }}"
enterpriseRealNameBucket: "{{ .Values.desktopConfig.realNameOSSEnterpriseRealNameBucket }}"
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ desktopConfig:
allowedOrigins:
- "costcenter"
- "license"
googleOneTapOrigins: []

# Database connections (auto-configured from sealos-config)
databaseMongodbURI: "" # Auto-fetched from sealos-config.databaseMongodbURI
Expand Down
11 changes: 6 additions & 5 deletions frontend/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"eslint": "8.38.0",
"eslint-config-next": "13.3.0",
"framer-motion": "^10.16.4",
"google-auth-library": "^10.9.0",
"i18next": "^23.11.5",
"immer": "^10.0.2",
"js-cookie": "^3.0.5",
Expand Down Expand Up @@ -97,6 +98,7 @@
"zustand": "^4.4.1"
},
"devDependencies": {
"@playwright/test": "^1.58.1",
"@testing-library/react": "^14.0.0",
"@types/js-cookie": "^3.0.4",
"@types/js-yaml": "^4.0.6",
Expand All @@ -110,13 +112,12 @@
"@types/react-dom": "18.3.7",
"@types/umami-browser": "^2.3.2",
"@types/uuid": "^9.0.4",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/browser": "4.0.15",
"@vitest/browser-playwright": "4.0.15",
"dotenv-cli": "^7.3.0",
"prettier": "^2.8.8",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.12",
"@playwright/test": "^1.58.1",
"@vitest/browser": "4.0.15",
"@vitest/browser-playwright": "4.0.15",
"@vitejs/plugin-react": "^5.1.1"
"vitest": "^4.0.12"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const verifyIdToken = vi.fn();
const getPayload = vi.fn();

vi.mock('google-auth-library', () => ({
OAuth2Client: vi.fn(function () {
return {
verifyIdToken
};
})
}));

vi.mock('@/services/enable', () => ({
enableGoogle: vi.fn(() => true)
}));

vi.mock('@/services/backend/persistImage', () => ({
persistImage: vi.fn(() => Promise.resolve('persisted-avatar'))
}));

vi.mock('@/services/backend/globalAuth', () => ({
getGlobalToken: vi.fn()
}));

import handler from '@/pages/api/auth/google/onetap';
import { persistImage } from '@/services/backend/persistImage';
import { getGlobalToken } from '@/services/backend/globalAuth';

const mockPersistImage = vi.mocked(persistImage);
const mockGetGlobalToken = vi.mocked(getGlobalToken);

const createMockRes = () => {
const res: any = {
headers: {},
statusCode: 200,
body: undefined,
setHeader: vi.fn((name: string, value: string | string[]) => {
res.headers[name] = value;
}),
status: vi.fn((code: number) => {
res.statusCode = code;
return res;
}),
json: vi.fn((payload: unknown) => {
res.body = payload;
return res;
}),
end: vi.fn(() => res)
};
return res;
};

const setAppConfig = (oneTapOrigins = ['https://www.example.com']) => {
(global as any).AppConfig = {
desktop: {
auth: {
idp: {
google: {
clientID: 'google-client-id',
oneTapOrigins
}
}
}
}
};
};

describe('google onetap api handler', () => {
beforeEach(() => {
vi.clearAllMocks();
setAppConfig();
getPayload.mockReturnValue({
sub: 'google-sub',
name: 'Ada Lovelace',
picture: 'https://example.com/avatar.png',
email: 'ada@example.com',
email_verified: true
});
verifyIdToken.mockResolvedValue({ getPayload });
mockGetGlobalToken.mockResolvedValue({
token: 'global-token',
user: {
name: 'Ada',
avatar: 'persisted-avatar',
userUid: 'user-uid'
},
needInit: false
});
});

it('allows preflight from configured One Tap origin', async () => {
const req: any = {
method: 'OPTIONS',
headers: {
origin: 'https://www.example.com'
}
};
const res = createMockRes();

await handler(req, res);

expect(res.statusCode).toBe(204);
expect(res.headers['Access-Control-Allow-Origin']).toBe('https://www.example.com');
expect(res.headers['Access-Control-Allow-Credentials']).toBe('true');
expect(res.headers['Access-Control-Allow-Methods']).toBe('POST, OPTIONS');
expect(res.headers['Access-Control-Allow-Headers']).toBe('Content-Type');
expect(res.headers.Vary).toBe('Origin');
expect(res.end).toHaveBeenCalled();
});

it('rejects forbidden origins before verifying Google token', async () => {
const req: any = {
method: 'POST',
headers: {
origin: 'https://evil.example.com'
},
body: {
credential: 'credential'
}
};
const res = createMockRes();

await handler(req, res);

expect(res.body).toMatchObject({
code: 403,
message: 'Forbidden origin'
});
expect(verifyIdToken).not.toHaveBeenCalled();
});

it('returns 400 when credential is missing', async () => {
const req: any = {
method: 'POST',
headers: {
origin: 'https://www.example.com'
},
body: {}
};
const res = createMockRes();

await handler(req, res);

expect(res.body).toMatchObject({
code: 400,
message: 'credential is invalid'
});
});

it('signs in with verified Google payload and sets shared auth cookie', async () => {
const req: any = {
method: 'POST',
headers: {
origin: 'https://www.example.com',
'x-forwarded-proto': 'https'
},
body: {
credential: 'credential'
}
};
const res = createMockRes();

await handler(req, res);

expect(verifyIdToken).toHaveBeenCalledWith({
idToken: 'credential',
audience: 'google-client-id'
});
expect(mockPersistImage).toHaveBeenCalledWith(
'https://example.com/avatar.png',
'avatar/GOOGLE/google-sub'
);
expect(mockGetGlobalToken).toHaveBeenCalledWith({
provider: 'GOOGLE',
providerId: 'google-sub',
name: 'Ada Lovelace',
avatar_url: 'persisted-avatar',
email: 'ada@example.com'
});
expect(res.body).toMatchObject({
code: 200,
data: {
token: 'global-token'
}
});
expect(res.headers['Set-Cookie']).toContain('sealos_auth_token=global-token');
expect(res.headers['Set-Cookie']).toContain('HttpOnly');
expect(res.headers['Set-Cookie']).toContain('Secure');
expect(res.headers['Set-Cookie']).toContain('SameSite=Lax');
});

it('does not pass unverified email into account binding', async () => {
getPayload.mockReturnValue({
sub: 'google-sub',
name: 'Ada Lovelace',
picture: '',
email: 'ada@example.com',
email_verified: false
});
const req: any = {
method: 'POST',
headers: {
origin: 'https://www.example.com'
},
body: {
credential: 'credential'
}
};
const res = createMockRes();

await handler(req, res);

expect(mockGetGlobalToken).toHaveBeenCalledWith({
provider: 'GOOGLE',
providerId: 'google-sub',
name: 'Ada Lovelace',
avatar_url: '',
email: undefined
});
});

it('returns 401 when Google token verification fails', async () => {
verifyIdToken.mockRejectedValue(new Error('bad token'));
const req: any = {
method: 'POST',
headers: {
origin: 'https://www.example.com'
},
body: {
credential: 'credential'
}
};
const res = createMockRes();

await handler(req, res);

expect(res.body).toMatchObject({
code: 401,
message: 'Unauthorized'
});
});
});
Loading
Loading