Skip to content

Commit 5903cd0

Browse files
committed
feat: add Discord strategy configuration and permission scopes
1 parent 5ed1b79 commit 5903cd0

File tree

3 files changed

+527
-0
lines changed

3 files changed

+527
-0
lines changed
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {
2+
IsArray,
3+
IsBoolean,
4+
IsNumber,
5+
IsOptional,
6+
IsString,
7+
} from 'class-validator';
8+
import {
9+
StrategyOptions as OAuth2StrategyOptions,
10+
StrategyOptionsWithRequest as OAuth2StrategyOptionsWithRequest,
11+
} from 'passport-oauth2';
12+
13+
import { ScopeType } from './types';
14+
15+
type MergedOAuth2StrategyOptions =
16+
| OAuth2StrategyOptions
17+
| OAuth2StrategyOptionsWithRequest;
18+
19+
type DiscordStrategyOptions = Pick<
20+
MergedOAuth2StrategyOptions,
21+
'clientID' | 'clientSecret' | 'scope'
22+
>;
23+
24+
export class DiscordStrategyConfig implements DiscordStrategyOptions {
25+
// The client ID assigned by Discord.
26+
@IsString()
27+
clientID: string;
28+
29+
// The client secret assigned by Discord.
30+
@IsString()
31+
clientSecret: string;
32+
33+
// The URL to which Discord will redirect the user after granting authorization.
34+
@IsString()
35+
callbackUrl: string;
36+
37+
// An array of permission scopes to request.
38+
@IsArray()
39+
@IsString({ each: true })
40+
scope: ScopeType;
41+
42+
// The delay in milliseconds between requests for the same scope.
43+
@IsOptional()
44+
@IsNumber()
45+
scopeDelay?: number;
46+
47+
// Whether to fetch data for the specified scope.
48+
@IsOptional()
49+
@IsBoolean()
50+
fetchScope?: boolean;
51+
52+
// The separator for the scope values.
53+
@IsOptional()
54+
@IsString()
55+
scopeSeparator?: string;
56+
}
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { Logger } from '@nestjs/common';
2+
import { plainToClass } from 'class-transformer';
3+
import { validateOrReject } from 'class-validator';
4+
import {
5+
InternalOAuthError,
6+
Strategy as OAuth2Strategy,
7+
StrategyOptions as OAuth2StrategyOptions,
8+
VerifyCallback,
9+
} from 'passport-oauth2';
10+
11+
import { DiscordStrategyConfig } from './DiscordStrategyConfig';
12+
import {
13+
Profile,
14+
ProfileConnection,
15+
ProfileGuild,
16+
ScopeType,
17+
SingleScopeType,
18+
} from './types';
19+
20+
type VerifyFunction = (
21+
accessToken: string,
22+
refreshToken: string,
23+
profile: Profile,
24+
verified: VerifyCallback,
25+
) => void;
26+
27+
interface AuthorizationParams {
28+
permissions?: string;
29+
prompt?: string;
30+
disable_guild_select?: string;
31+
guild_id?: string;
32+
}
33+
34+
export default class Strategy extends OAuth2Strategy {
35+
// Static properties
36+
public static DISCORD_EPOCH = 1420070400000;
37+
public static DISCORD_SHIFT = 1 << 22;
38+
39+
public static DISCORD_API_BASE = 'https://discord.com/api';
40+
41+
private readonly logger = new Logger('DiscordStrategy');
42+
private scope: ScopeType;
43+
private scopeDelay: number;
44+
private fetchScopeEnabled: boolean;
45+
public override name = 'discord';
46+
47+
public constructor(options: DiscordStrategyConfig, verify: VerifyFunction) {
48+
super(
49+
{
50+
scopeSeparator: ' ',
51+
...options,
52+
authorizationURL: 'https://discord.com/api/oauth2/authorize',
53+
tokenURL: 'https://discord.com/api/oauth2/token',
54+
} as OAuth2StrategyOptions,
55+
verify,
56+
);
57+
58+
this.validateConfig(options);
59+
this.scope = options.scope;
60+
this.scopeDelay = options.scopeDelay ?? 0;
61+
this.fetchScopeEnabled = options.fetchScope ?? true;
62+
this._oauth2.useAuthorizationHeaderforGET(true);
63+
}
64+
65+
private async validateConfig(config: DiscordStrategyConfig): Promise<void> {
66+
try {
67+
const validatedConfig = plainToClass(DiscordStrategyConfig, config);
68+
await validateOrReject(validatedConfig);
69+
} catch (errors) {
70+
this.logger.error(errors);
71+
throw new Error(`Configuration validation failed: ${errors}`);
72+
}
73+
}
74+
75+
private async makeApiRequest<T>(
76+
url: string,
77+
accessToken: string,
78+
): Promise<T> {
79+
return new Promise((resolve, reject) => {
80+
this._oauth2.get(url, accessToken, (err, body) => {
81+
if (err) {
82+
reject(new InternalOAuthError(`Failed to fetch from ${url}`, err));
83+
return;
84+
}
85+
86+
try {
87+
resolve(JSON.parse(body as string) as T);
88+
} catch (parseError) {
89+
reject(new Error(`Failed to parse response from ${url}`));
90+
}
91+
});
92+
});
93+
}
94+
95+
private async fetchUserData(accessToken: string): Promise<Profile> {
96+
return this.makeApiRequest<Profile>(
97+
`${Strategy.DISCORD_API_BASE}/users/@me`,
98+
accessToken,
99+
);
100+
}
101+
102+
public override async userProfile(accessToken: string, done: VerifyCallback) {
103+
try {
104+
const userData = await this.fetchUserData(accessToken);
105+
const profile = this.buildProfile(userData, accessToken);
106+
107+
if (this.fetchScopeEnabled) {
108+
await this.enrichProfileWithScopes(profile, accessToken);
109+
}
110+
111+
done(null, profile);
112+
} catch (error) {
113+
this.logger.error('Failed to fetch user profile', error);
114+
done(error);
115+
}
116+
}
117+
118+
private async enrichProfileWithScopes(
119+
profile: Profile,
120+
accessToken: string,
121+
): Promise<void> {
122+
await Promise.all([
123+
this.fetchScopeData('connections', accessToken).then(
124+
(data) => (profile.connections = data as ProfileConnection[]),
125+
),
126+
this.fetchScopeData('guilds', accessToken).then(
127+
(data) => (profile.guilds = data as ProfileGuild[]),
128+
),
129+
]);
130+
131+
profile.fetchedAt = new Date();
132+
}
133+
134+
private async fetchScopeData(
135+
scope: SingleScopeType,
136+
accessToken: string,
137+
): Promise<unknown[] | null> {
138+
if (!this.scope.includes(scope)) {
139+
return null;
140+
}
141+
142+
if (this.scopeDelay > 0) {
143+
await new Promise((resolve) => setTimeout(resolve, this.scopeDelay));
144+
}
145+
146+
return this.makeApiRequest<unknown[]>(
147+
`${Strategy.DISCORD_API_BASE}/users/@me/${scope}`,
148+
accessToken,
149+
);
150+
}
151+
152+
private calculateCreationDate(id: string) {
153+
return new Date(+id / Strategy.DISCORD_SHIFT + Strategy.DISCORD_EPOCH);
154+
}
155+
156+
private buildProfile(data: Profile, accessToken: string): Profile {
157+
const { id } = data;
158+
return {
159+
provider: 'discord',
160+
id: id,
161+
username: data.username,
162+
displayName: data.displayName,
163+
avatar: data.avatar,
164+
banner: data.banner,
165+
email: data.email,
166+
verified: data.verified,
167+
mfa_enabled: data.mfa_enabled,
168+
public_flags: data.public_flags,
169+
flags: data.flags,
170+
locale: data.locale,
171+
global_name: data.global_name,
172+
premium_type: data.premium_type,
173+
connections: data.connections,
174+
guilds: data.guilds,
175+
access_token: accessToken,
176+
fetchedAt: new Date(),
177+
createdAt: this.calculateCreationDate(id),
178+
_raw: JSON.stringify(data),
179+
_json: data as unknown as Record<string, unknown>,
180+
};
181+
}
182+
183+
public async fetchScope(
184+
scope: SingleScopeType,
185+
accessToken: string,
186+
): Promise<Record<string, unknown> | null> {
187+
if (!this.scope.includes(scope)) return null;
188+
189+
await new Promise((resolve) => setTimeout(resolve, this.scopeDelay ?? 0));
190+
191+
return new Promise((resolve, reject) => {
192+
this._oauth2.get(
193+
`https://discord.com/api/users/@me/${scope}`,
194+
accessToken,
195+
(err, body) => {
196+
if (err) {
197+
return reject(
198+
new InternalOAuthError(
199+
`Failed to fetch the scope: ${scope}`,
200+
err,
201+
),
202+
);
203+
}
204+
205+
try {
206+
if (typeof body !== 'string') {
207+
return reject(
208+
new Error(`Failed to parse the returned scope data: ${scope}`),
209+
);
210+
}
211+
212+
const json = JSON.parse(body) as Record<string, unknown>;
213+
resolve(json);
214+
} catch (err) {
215+
this.logger.error(err);
216+
217+
reject(
218+
new Error(`Failed to parse the returned scope data: ${scope}`),
219+
);
220+
}
221+
},
222+
);
223+
});
224+
}
225+
226+
public override authorizationParams(
227+
options: AuthorizationParams,
228+
): AuthorizationParams & Record<string, unknown> {
229+
const params: AuthorizationParams & Record<string, unknown> =
230+
super.authorizationParams(options) as Record<string, unknown>;
231+
232+
const { permissions, prompt, disable_guild_select, guild_id } = options;
233+
234+
if (permissions) params.permissions = permissions;
235+
if (prompt) params.prompt = prompt;
236+
if (guild_id) params.guild_id = guild_id;
237+
if (disable_guild_select)
238+
params.disable_guild_select = disable_guild_select;
239+
240+
return params;
241+
}
242+
}

0 commit comments

Comments
 (0)