feat: Add OAuth 2.0 / OIDC SSO login support

- Add OAuth SSO type definitions (OAuthSSOProvider, OAuthSSOConfig, IOAuthLink)
- Add oauthSSO field to SystemConfig for provider configuration
- Update IUser interface to support OAuth-linked accounts
- Create OAuth SSO service with provider management and token exchange
- Add SSO controller with login initiation and callback handling
- Update frontend login page with SSO provider buttons
- Add SSOCallbackPage for handling OAuth redirects
- Update database entities and DAOs for OAuth link storage
- Add i18n translations for SSO-related UI elements
- Add comprehensive unit tests for OAuth SSO service

Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-12-31 15:08:10 +00:00
parent 53d3545f60
commit 4721146e8a
16 changed files with 1565 additions and 54 deletions

View File

@@ -0,0 +1,393 @@
// Tests for OAuth SSO Service
import {
isOAuthSSOEnabled,
isLocalAuthAllowed,
getEnabledProviders,
getProviderById,
generateAuthorizationUrl,
} from '../../src/services/oauthSSOService.js';
// Mock the config loading
jest.mock('../../src/config/index.js', () => ({
loadSettings: jest.fn(),
}));
import { loadSettings } from '../../src/config/index.js';
const mockLoadSettings = loadSettings as jest.MockedFunction<typeof loadSettings>;
describe('OAuth SSO Service', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('isOAuthSSOEnabled', () => {
it('should return false when oauthSSO is not configured', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {},
});
expect(isOAuthSSOEnabled()).toBe(false);
});
it('should return false when oauthSSO.enabled is false', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: false,
providers: [
{
id: 'google',
name: 'Google',
type: 'google',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
],
},
},
});
expect(isOAuthSSOEnabled()).toBe(false);
});
it('should return false when no providers are configured', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [],
},
},
});
expect(isOAuthSSOEnabled()).toBe(false);
});
it('should return true when enabled and providers exist', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'google',
name: 'Google',
type: 'google',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
],
},
},
});
expect(isOAuthSSOEnabled()).toBe(true);
});
});
describe('isLocalAuthAllowed', () => {
it('should return true by default when not configured', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {},
});
expect(isLocalAuthAllowed()).toBe(true);
});
it('should return true when allowLocalAuth is not explicitly set', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [],
},
},
});
expect(isLocalAuthAllowed()).toBe(true);
});
it('should return false when allowLocalAuth is false', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
allowLocalAuth: false,
providers: [],
},
},
});
expect(isLocalAuthAllowed()).toBe(false);
});
it('should return true when allowLocalAuth is true', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
allowLocalAuth: true,
providers: [],
},
},
});
expect(isLocalAuthAllowed()).toBe(true);
});
});
describe('getEnabledProviders', () => {
it('should return empty array when SSO is not enabled', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {},
});
expect(getEnabledProviders()).toEqual([]);
});
it('should return only enabled providers', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'google',
name: 'Google',
type: 'google',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
enabled: true,
},
{
id: 'github',
name: 'GitHub',
type: 'github',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
enabled: false,
},
{
id: 'microsoft',
name: 'Microsoft',
type: 'microsoft',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
// enabled is undefined, defaults to true
},
],
},
},
});
const providers = getEnabledProviders();
expect(providers).toHaveLength(2);
expect(providers[0]).toEqual({ id: 'google', name: 'Google', type: 'google' });
expect(providers[1]).toEqual({ id: 'microsoft', name: 'Microsoft', type: 'microsoft' });
});
});
describe('getProviderById', () => {
it('should return undefined when provider not found', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'google',
name: 'Google',
type: 'google',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
],
},
},
});
expect(getProviderById('github')).toBeUndefined();
});
it('should return undefined when provider is disabled', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'google',
name: 'Google',
type: 'google',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
enabled: false,
},
],
},
},
});
expect(getProviderById('google')).toBeUndefined();
});
it('should return provider when found and enabled', () => {
const provider = {
id: 'google',
name: 'Google',
type: 'google' as const,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
};
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [provider],
},
},
});
expect(getProviderById('google')).toEqual(provider);
});
});
describe('generateAuthorizationUrl', () => {
it('should return null when provider not found', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [],
},
},
});
expect(generateAuthorizationUrl('google', 'http://localhost/callback')).toBeNull();
});
it('should generate authorization URL for Google provider', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'google',
name: 'Google',
type: 'google',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
],
},
},
});
const result = generateAuthorizationUrl('google', 'http://localhost/callback');
expect(result).not.toBeNull();
expect(result!.url).toContain('https://accounts.google.com/o/oauth2/v2/auth');
expect(result!.url).toContain('client_id=test-client-id');
expect(result!.url).toContain('redirect_uri=http%3A%2F%2Flocalhost%2Fcallback');
expect(result!.url).toContain('response_type=code');
expect(result!.url).toContain('scope=openid+email+profile');
expect(result!.url).toContain('code_challenge=');
expect(result!.state).toBeDefined();
});
it('should generate authorization URL for GitHub provider without PKCE', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'github',
name: 'GitHub',
type: 'github',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
],
},
},
});
const result = generateAuthorizationUrl('github', 'http://localhost/callback');
expect(result).not.toBeNull();
expect(result!.url).toContain('https://github.com/login/oauth/authorize');
expect(result!.url).not.toContain('code_challenge=');
expect(result!.state).toBeDefined();
});
it('should generate authorization URL for Microsoft provider', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'microsoft',
name: 'Microsoft',
type: 'microsoft',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
],
},
},
});
const result = generateAuthorizationUrl('microsoft', 'http://localhost/callback');
expect(result).not.toBeNull();
expect(result!.url).toContain('https://login.microsoftonline.com/common/oauth2/v2.0/authorize');
expect(result!.url).toContain('code_challenge=');
expect(result!.state).toBeDefined();
});
it('should include custom scopes when configured', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'google',
name: 'Google',
type: 'google',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
scopes: ['custom-scope', 'another-scope'],
},
],
},
},
});
const result = generateAuthorizationUrl('google', 'http://localhost/callback');
expect(result).not.toBeNull();
expect(result!.url).toContain('scope=custom-scope+another-scope');
});
});
});