Skip to content

Commit

Permalink
Merge pull request #32 from OpenNBS/feature/discord-passport-oauth, F…
Browse files Browse the repository at this point in the history
…ixes #30

Feature/discord passport OAuth2 implementation
  • Loading branch information
tomast1337 authored Jan 19, 2025
2 parents 76038a9 + e10b462 commit 3acde54
Show file tree
Hide file tree
Showing 18 changed files with 756 additions and 51 deletions.
31 changes: 9 additions & 22 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/swagger": "^7.4.2",
"@types/passport-discord": "^0.1.14",
"@types/uuid": "^9.0.8",
"axios": "^1.7.9",
"bcryptjs": "^2.4.3",
Expand All @@ -43,11 +42,11 @@
"multer": "1.4.5-lts.1",
"nanoid": "^3.3.8",
"passport": "^0.7.0",
"passport-discord": "^0.1.4",
"passport-github": "^1.1.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"passport-oauth2": "^1.8.0",
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.1",
"uuid": "^9.0.1",
Expand All @@ -63,10 +62,12 @@
"@types/jest": "^29.5.14",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.10",
"@types/passport": "^1.0.17",
"@types/passport-github": "^1.1.12",
"@types/passport-google-oauth20": "^2.0.16",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/passport-oauth2": "^1.4.17",
"@types/supertest": "^2.0.16",
"jest": "^29.7.0",
"source-map-support": "^0.5.21",
Expand Down
4 changes: 2 additions & 2 deletions server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { Logger, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule, MongooseModuleFactoryOptions } from '@nestjs/mongoose';

import { validate } from './config/EnvironmentVariables';
import { AuthModule } from './auth/auth.module';
import { validate } from './config/EnvironmentVariables';
import { FileModule } from './file/file.module';
import { ParseTokenPipe } from './parseToken';
import { SongBrowserModule } from './song-browser/song-browser.module';
import { SongModule } from './song/song.module';
import { SongBrowserModule } from './song-browser/song-browser.module';
import { UserModule } from './user/user.module';

@Module({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
IsArray,
IsBoolean,
IsEnum,
IsNumber,
IsOptional,
IsString,
} from 'class-validator';
import {
StrategyOptions as OAuth2StrategyOptions,
StrategyOptionsWithRequest as OAuth2StrategyOptionsWithRequest,
} from 'passport-oauth2';

import { ScopeType } from './types';

type MergedOAuth2StrategyOptions =
| OAuth2StrategyOptions
| OAuth2StrategyOptionsWithRequest;

type DiscordStrategyOptions = Pick<
MergedOAuth2StrategyOptions,
'clientID' | 'clientSecret' | 'scope'
>;

export class DiscordStrategyConfig implements DiscordStrategyOptions {
// The client ID assigned by Discord.
@IsString()
clientID: string;

// The client secret assigned by Discord.
@IsString()
clientSecret: string;

// The URL to which Discord will redirect the user after granting authorization.
@IsString()
callbackUrl: string;

// An array of permission scopes to request.
@IsArray()
@IsString({ each: true })
scope: ScopeType;

// The delay in milliseconds between requests for the same scope.
@IsOptional()
@IsNumber()
scopeDelay?: number;

// Whether to fetch data for the specified scope.
@IsOptional()
@IsBoolean()
fetchScope?: boolean;

@IsEnum(['none', 'consent'])
prompt: 'consent' | 'none';

// The separator for the scope values.
@IsOptional()
@IsString()
scopeSeparator?: string;
}
178 changes: 178 additions & 0 deletions server/src/auth/strategies/discord.strategy/Strategy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { VerifyFunction } from 'passport-oauth2';

import { DiscordStrategyConfig } from './DiscordStrategyConfig';
import DiscordStrategy from './Strategy';
import { DiscordPermissionScope, Profile } from './types';

describe('DiscordStrategy', () => {
let strategy: DiscordStrategy;
const verify: VerifyFunction = jest.fn();

beforeEach(() => {
const config: DiscordStrategyConfig = {
clientID: 'test-client-id',
clientSecret: 'test-client-secret',
callbackUrl: 'http://localhost:3000/callback',
scope: [
DiscordPermissionScope.Email,
DiscordPermissionScope.Identify,
DiscordPermissionScope.Connections,
// DiscordPermissionScope.Bot, // Not allowed scope
],
prompt: 'consent',
};

strategy = new DiscordStrategy(config, verify);
});

it('should be defined', () => {
expect(strategy).toBeDefined();
});

it('should have the correct name', () => {
expect(strategy.name).toBe('discord');
});

it('should validate config', async () => {
const config: DiscordStrategyConfig = {
clientID: 'test-client-id',
clientSecret: 'test-client-secret',
callbackUrl: 'http://localhost:3000/callback',
scope: [DiscordPermissionScope.Email, DiscordPermissionScope.Identify],
prompt: 'consent',
};

await expect(strategy['validateConfig'](config)).resolves.toBeUndefined();
});

it('should make API request', async () => {
const mockGet = jest.fn((url, accessToken, callback) => {
callback(null, JSON.stringify({ id: '123' }));
});

strategy['_oauth2'].get = mockGet;

const result = await strategy['makeApiRequest']<{ id: string }>(
'https://discord.com/api/users/@me',
'test-access-token',
);

expect(result).toEqual({ id: '123' });
});

it('should fetch user data', async () => {
const mockMakeApiRequest = jest.fn().mockResolvedValue({ id: '123' });
strategy['makeApiRequest'] = mockMakeApiRequest;

const result = await strategy['fetchUserData']('test-access-token');

expect(result).toEqual({ id: '123' });
});

it('should build profile', () => {
const profileData = {
id: '123',
username: 'testuser',
displayName: 'Test User',
avatar: 'avatar.png',
banner: 'banner.png',
email: 'test@example.com',
verified: true,
mfa_enabled: true,
public_flags: 1,
flags: 1,
locale: 'en-US',
global_name: 'testuser#1234',
premium_type: 1,
connections: [],
guilds: [],
} as unknown as Profile;

const profile = strategy['buildProfile'](profileData, 'test-access-token');

expect(profile).toMatchObject({
provider: 'discord',
id: '123',
username: 'testuser',
displayName: 'Test User',
avatar: 'avatar.png',
banner: 'banner.png',
email: 'test@example.com',
verified: true,
mfa_enabled: true,
public_flags: 1,
flags: 1,
locale: 'en-US',
global_name: 'testuser#1234',
premium_type: 1,
connections: [],
guilds: [],
access_token: 'test-access-token',
fetchedAt: expect.any(Date),
createdAt: expect.any(Date),
_raw: JSON.stringify(profileData),
_json: profileData,
});
});

it('should fetch scope data', async () => {
const mockMakeApiRequest = jest.fn().mockResolvedValue([{ id: '123' }]);
strategy['makeApiRequest'] = mockMakeApiRequest;

const result = await strategy['fetchScopeData'](
DiscordPermissionScope.Connections,
'test-access-token',
);

expect(result).toEqual([{ id: '123' }]);
});

it('should no fetch out of scope data', async () => {
const mockMakeApiRequest = jest.fn().mockResolvedValue([{ id: '123' }]);
strategy['makeApiRequest'] = mockMakeApiRequest;

const result = await strategy['fetchScopeData'](
DiscordPermissionScope.Bot,
'test-access-token',
);

expect(result).toEqual(null);
});

it('should enrich profile with scopes', async () => {
const profile = {
id: '123',
connections: [],
guilds: [],
} as unknown as Profile;

const mockFetchScopeData = jest
.fn()
.mockResolvedValueOnce([{ id: 'connection1' }])
.mockResolvedValueOnce([{ id: 'guild1' }]);

strategy['fetchScopeData'] = mockFetchScopeData;

await strategy['enrichProfileWithScopes'](profile, 'test-access-token');

expect(profile.connections).toEqual([{ id: 'connection1' }]);
expect(profile.guilds).toEqual([{ id: 'guild1' }]);
expect(profile.fetchedAt).toBeInstanceOf(Date);
});

it('should calculate creation date', () => {
const id = '123456789012345678';
const date = strategy['calculateCreationDate'](id);

expect(date).toBeInstanceOf(Date);
});

it('should return authorization params', () => {
const options = { prompt: 'consent' };
const params = strategy.authorizationParams(options);

expect(params).toMatchObject({
prompt: 'consent',
});
});
});
Loading

0 comments on commit 3acde54

Please sign in to comment.