-
-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #32 from OpenNBS/feature/discord-passport-oauth, F…
…ixes #30 Feature/discord passport OAuth2 implementation
- Loading branch information
Showing
18 changed files
with
756 additions
and
51 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
60 changes: 60 additions & 0 deletions
60
server/src/auth/strategies/discord.strategy/DiscordStrategyConfig.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
178
server/src/auth/strategies/discord.strategy/Strategy.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.