Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fbac2d2
feat(database/schema): cfx & discord id columns in adminUsers for OAuth2
Maximus7474 Jul 21, 2026
dc10e09
chore(package): added better-sqlite3 to use drizzle studio
Maximus7474 Jul 21, 2026
e9c43af
feat(panel): discord OAuth2 login handling
Maximus7474 Jul 22, 2026
281fed1
feat(panel): allow admin management perm to update identifiers of a
Maximus7474 Jul 22, 2026
d1b8798
fix(packages): missing methods in db & missing settings types /
Maximus7474 Jul 22, 2026
31d9028
feat(core/auth): tests
Maximus7474 Jul 22, 2026
4237ed9
chore(webpanel/settings): added guide link to oauth page
Maximus7474 Jul 22, 2026
e2bc687
feat(webpanel): profile page for connected user
Maximus7474 Jul 22, 2026
f73633b
refactor(webpanel/admins): automatically link player when updating admin
Maximus7474 Jul 22, 2026
11089e5
fix(database/players): link admin account to new player if oauth IDs
Maximus7474 Jul 22, 2026
0eff252
fix(database/players): checking for admin identifiers using type:value
Maximus7474 Jul 23, 2026
5a088bd
chore(database/players.test): added test to check admin account linking
Maximus7474 Jul 23, 2026
503c456
Merge branch 'feat/oauth2' into feat/personal-profile-page
Maximus7474 Jul 23, 2026
4821dbd
fix(panel/settings): profile page not allowing perm editor to overflow
Maximus7474 Jul 23, 2026
d3bf9bf
fix(panel/settings): admin view page not allowing perm editor to
Maximus7474 Jul 23, 2026
84fc5a6
fix(panel/settings): audit log not scrollable in admin & profile view
Maximus7474 Jul 23, 2026
f129a2d
chore(webpanel/pages): removed unused imports
Maximus7474 Jul 23, 2026
1c263a6
fix(core/auth): catch error on settings.getMultiple on first start of
Maximus7474 Jul 23, 2026
1c3a219
fix(webpanel/aduit): saved content when identifiers are changed for an
Maximus7474 Jul 23, 2026
8b920d1
chore(panel/auth): improve error responses when failing to update an
Maximus7474 Jul 23, 2026
9ff8846
Merge branch 'feat/oauth2' into feat/personal-profile-page
Maximus7474 Jul 23, 2026
10553b7
chore(panel/profile): improve error responses when failing to update an
Maximus7474 Jul 23, 2026
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
84 changes: 84 additions & 0 deletions apps/core/src/modules/auth/manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it, mock } from 'bun:test';
import type { IOAuthProvider } from '../../types';

// Mock database to prevent SQLite errors when DiscordOAuthProvider initializes at top-level
mock.module('@fxmanager/database', () => ({
repo: {
settings: {
getMultiple: mock(() => ({
'oauth.discordClientId': 'mock_client_id',
'oauth.discordSecret': 'mock_client_secret',
'oauth.discordEnabled': 'true',
})),
},
},
}));

// Dynamically import manager after mocking the database module
const { OAuthManager, oauthManager } = await import('./manager');

describe('OAuthManager', () => {
it('should register a provider and return `this` for chaining', () => {
const manager = new OAuthManager();
const mockProvider = { name: 'google' } as IOAuthProvider;

const result = manager.registerProvider(mockProvider);

expect(result).toBe(manager);
expect(manager.hasProvider('google')).toBe(true);
});

it('should retrieve a registered provider', () => {
const manager = new OAuthManager();
const mockProvider = { name: 'github' } as IOAuthProvider;

manager.registerProvider(mockProvider);

const retrieved = manager.getProvider('github');
expect(retrieved).toBe(mockProvider);
});

it('should throw an error when attempting to retrieve an unregistered provider', () => {
const manager = new OAuthManager();

expect(() => manager.getProvider('unregistered')).toThrow(
"OAuth provider 'unregistered' is not registered.",
);
});

it('should return false for hasProvider when provider is missing', () => {
const manager = new OAuthManager();

expect(manager.hasProvider('nonexistent')).toBe(false);
});

it('should reload providers with initialize() AND handle providers without initialize()', () => {
const manager = new OAuthManager();
const mockInit = mock();

const providerWithInit = {
name: 'with-init',
initialize: mockInit,
} as unknown as IOAuthProvider;

const providerWithoutInit = {
name: 'without-init',
} as unknown as IOAuthProvider;

manager.registerProvider(providerWithInit);
manager.registerProvider(providerWithoutInit);

const result = manager.reload();

expect(result).toBe(manager);
expect(mockInit).toHaveBeenCalledTimes(1);
});

describe('Exported oauthManager Singleton', () => {
it('should export a default oauthManager instance pre-registered with Discord', () => {
expect(oauthManager).toBeInstanceOf(OAuthManager);
expect(oauthManager.hasProvider('discord')).toBe(true);
expect(oauthManager.getProvider('discord').name).toBe('discord');
});
});
});
37 changes: 37 additions & 0 deletions apps/core/src/modules/auth/manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { IOAuthProvider } from '../../types';
import { DiscordOAuthProvider } from './providers/discord';

export class OAuthManager {
private providers = new Map<string, IOAuthProvider>();

registerProvider(provider: IOAuthProvider): this {
this.providers.set(provider.name, provider);
return this;
}

getProvider(name: string): IOAuthProvider {
const provider = this.providers.get(name);
if (!provider) {
throw new Error(`OAuth provider '${name}' is not registered.`);
}
return provider;
}

hasProvider(name: string): boolean {
return this.providers.has(name);
}

reload(): this {
for (const provider of this.providers.values()) {
if (provider.initialize) {
provider.initialize();
}
}
return this;
}
}

const oauthManager = new OAuthManager();
oauthManager.registerProvider(new DiscordOAuthProvider());

export { oauthManager };
218 changes: 218 additions & 0 deletions apps/core/src/modules/auth/providers/discord.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { beforeEach, describe, expect, it, mock } from 'bun:test';

const mockGetMultipleSettings = mock();

mock.module('@fxmanager/database', () => ({
repo: {
settings: {
getMultiple: mockGetMultipleSettings,
},
},
}));

const { DiscordOAuthProvider } = await import('./discord');

describe('DiscordOAuthProvider', () => {
let provider: InstanceType<typeof DiscordOAuthProvider>;

beforeEach(() => {
mockGetMultipleSettings.mockClear();
mockGetMultipleSettings.mockImplementation(() => ({
'oauth.discordClientId': 'mock_client_id_123',
'oauth.discordSecret': 'mock_client_secret_xyz',
'oauth.discordEnabled': 'true',
}));

provider = new DiscordOAuthProvider();
});

describe('initialize & isConfigured', () => {
it('should load configuration settings from the database repository', () => {
expect(mockGetMultipleSettings).toHaveBeenCalledWith([
'oauth.discordClientId',
'oauth.discordSecret',
'oauth.discordEnabled',
]);
expect(provider.isConfigured()).toBe(true);
});

it('should return false for isConfigured when clientId or clientSecret are missing', () => {
mockGetMultipleSettings.mockImplementation(() => ({
'oauth.discordClientId': undefined,
'oauth.discordSecret': 'mock_client_secret_xyz',
'oauth.discordEnabled': 'true',
}));

const unconfigured = new DiscordOAuthProvider();
expect(unconfigured.isConfigured()).toBe(false);
});
});

describe('getAuthUrl', () => {
it('should construct a valid Discord authorization URL with query parameters', () => {
const state = 'csrf_state_token';
const redirectUri =
'http://localhost:3000/api/auth/oauth/discord/callback';

const urlStr = provider.getAuthUrl(state, redirectUri);
const url = new URL(urlStr);

expect(url.origin + url.pathname).toBe(
'https://discord.com/oauth2/authorize',
);
expect(url.searchParams.get('client_id')).toBe('mock_client_id_123');
expect(url.searchParams.get('redirect_uri')).toBe(redirectUri);
expect(url.searchParams.get('response_type')).toBe('code');
expect(url.searchParams.get('scope')).toBe('identify email');
expect(url.searchParams.get('state')).toBe(state);
});

it('should throw an error if OAuth is disabled', () => {
mockGetMultipleSettings.mockImplementation(() => ({
'oauth.discordClientId': 'mock_client_id_123',
'oauth.discordSecret': 'mock_client_secret_xyz',
'oauth.discordEnabled': 'false',
}));

const disabledProvider = new DiscordOAuthProvider();
expect(() =>
disabledProvider.getAuthUrl('state', 'http://localhost/callback'),
).toThrow('Discord OAuth is not enabled.');
});

it('should throw an error if OAuth credentials are missing', () => {
mockGetMultipleSettings.mockImplementation(() => ({
'oauth.discordClientId': undefined,
'oauth.discordSecret': undefined,
'oauth.discordEnabled': 'true',
}));

const unconfiguredProvider = new DiscordOAuthProvider();
expect(() =>
unconfiguredProvider.getAuthUrl('state', 'http://localhost/callback'),
).toThrow('Discord OAuth is not configured on the server.');
});
});

describe('handleCallback', () => {
const redirectUri = 'http://localhost:3000/callback';
const code = 'valid_oauth_code';

it('should throw an error if OAuth is disabled', async () => {
mockGetMultipleSettings.mockImplementation(() => ({
'oauth.discordEnabled': 'false',
}));

const disabledProvider = new DiscordOAuthProvider();
expect(
disabledProvider.handleCallback(code, redirectUri),
).rejects.toThrow('Discord OAuth is not enabled.');
});

it('should throw an error when token exchange endpoint returns an error response', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response('Invalid Authorization Code', {
status: 400,
statusText: 'Bad Request',
}),
),
) as unknown as typeof fetch;

expect(provider.handleCallback(code, redirectUri)).rejects.toThrow(
'Failed to exchange authorization code: Invalid Authorization Code',
);
});

it('should throw an error when user profile retrieval fails', async () => {
const mockFetch = mock()
// Token exchange succeeds
.mockImplementationOnce(() =>
Promise.resolve(
new Response(JSON.stringify({ access_token: 'mock_token_123' }), {
status: 200,
}),
),
)
// User profile lookup fails
.mockImplementationOnce(() =>
Promise.resolve(new Response('Unauthorized', { status: 401 })),
);

globalThis.fetch = mockFetch as unknown as typeof fetch;

expect(provider.handleCallback(code, redirectUri)).rejects.toThrow(
'Failed to retrieve user profile from Discord.',
);
});

it('should complete OAuth exchange and map the profile to OAuthUser format', async () => {
const mockFetch = mock()
.mockImplementationOnce(() =>
Promise.resolve(
new Response(
JSON.stringify({ access_token: 'mock_access_token' }),
{ status: 200 },
),
),
)
.mockImplementationOnce(() =>
Promise.resolve(
new Response(
JSON.stringify({
id: '123456789012345678',
username: 'discord_user',
global_name: 'Display Name',
email: 'user@example.com',
avatar: 'a_1234567890',
}),
{ status: 200 },
),
),
);

globalThis.fetch = mockFetch as unknown as typeof fetch;

const user = await provider.handleCallback(code, redirectUri);

expect(user).toEqual({
id: '123456789012345678',
username: 'Display Name',
email: 'user@example.com',
avatarUrl:
'https://cdn.discordapp.com/avatars/123456789012345678/a_1234567890.png',
});
});

it('should fallback to username when global_name is missing and handle null avatar', async () => {
const mockFetch = mock()
.mockImplementationOnce(() =>
Promise.resolve(
new Response(
JSON.stringify({ access_token: 'mock_access_token' }),
{ status: 200 },
),
),
)
.mockImplementationOnce(() =>
Promise.resolve(
new Response(
JSON.stringify({
id: '999888777',
username: 'legacy_username',
avatar: null,
}),
{ status: 200 },
),
),
);

globalThis.fetch = mockFetch as unknown as typeof fetch;

const user = await provider.handleCallback(code, redirectUri);

expect(user.username).toBe('legacy_username');
expect(user.avatarUrl).toBeUndefined();
});
});
});
Loading