generated from sapphiredev/sapphire-template
/
Auth.ts
260 lines (227 loc) · 6.76 KB
/
Auth.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
import { Awaitable, isThenable } from '@sapphire/utilities';
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
import {
OAuth2Scopes,
RESTGetAPICurrentUserConnectionsResult,
RESTGetAPICurrentUserGuildsResult,
RESTGetAPICurrentUserResult,
RouteBases,
Routes,
Snowflake
} from 'discord.js';
import fetch from 'node-fetch';
export class Auth {
/**
* The client's application id, this can be retrieved in Discord Developer Portal at https://discord.com/developers/applications.
* @since 1.0.0
*/
public id: Snowflake;
/**
* The name for the cookie, this will be used to identify a Secure HttpOnly cookie.
* @since 1.0.0
*/
public cookie: string;
/**
* The scopes defined at https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes.
* @since 1.0.0
*/
public scopes: readonly OAuth2Scopes[];
/**
* The redirect uri.
* @since 1.0.0
*/
public redirect: string | undefined;
/**
* The transformers used for {@link Auth.fetchData}.
* @since 1.4.0
*/
public transformers: LoginDataTransformer[];
public domainOverwrite: string | null = null;
#secret: string;
private constructor(options: ServerOptionsAuth) {
this.id = options.id as Snowflake;
this.cookie = options.cookie ?? 'SAPPHIRE_AUTH';
this.scopes = options.scopes ?? [OAuth2Scopes.Identify];
this.redirect = options.redirect;
this.#secret = options.secret;
this.transformers = options.transformers ?? [];
this.domainOverwrite = options.domainOverwrite ?? null;
}
/**
* The client secret, this can be retrieved in Discord Developer Portal at https://discord.com/developers/applications.
* @since 1.0.0
*/
public get secret() {
return this.#secret;
}
/**
* Encrypts an object with aes-256-cbc to use as a token.
* @since 1.0.0
* @param data An object to encrypt
* @param secret The secret to encrypt the data with
*/
public encrypt(data: AuthData): string {
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-cbc', this.#secret, iv);
return `${cipher.update(JSON.stringify(data), 'utf8', 'base64') + cipher.final('base64')}.${iv.toString('base64')}`;
}
/**
* Decrypts an object with aes-256-cbc to use as a token.
* @since 1.0.0
* @param token An data to decrypt
* @param secret The secret to decrypt the data with
*/
public decrypt(token: string): AuthData | null {
const [data, iv] = token.split('.');
const decipher = createDecipheriv('aes-256-cbc', this.#secret, Buffer.from(iv, 'base64'));
try {
const parsed = JSON.parse(decipher.update(data, 'base64', 'utf8') + decipher.final('utf8')) as AuthData;
// If the token expired, return null:
return parsed.expires >= Date.now() ? parsed : null;
} catch {
return null;
}
}
/**
* Retrieves the data for a specific user.
* @since 1.4.0
* @param token The access token from the user.
*/
public async fetchData(token: string): Promise<LoginData> {
// Fetch the information:
const [user, guilds, connections] = await Promise.all([
this.fetchInformation<RESTGetAPICurrentUserResult>(OAuth2Scopes.Identify, token, `${RouteBases.api}${Routes.user()}`),
this.fetchInformation<RESTGetAPICurrentUserGuildsResult>(OAuth2Scopes.Guilds, token, `${RouteBases.api}${Routes.userGuilds()}`),
this.fetchInformation<RESTGetAPICurrentUserConnectionsResult>(
OAuth2Scopes.Connections,
token,
`${RouteBases.api}${Routes.userConnections()}`
)
]);
// Transform the information:
let data: LoginData = { user, guilds, connections };
for (const transformer of this.transformers) {
const result = transformer(data);
if (isThenable(result)) data = await result;
else data = result as LoginData;
}
return data;
}
private async fetchInformation<T>(scope: OAuth2Scopes, token: string, url: string): Promise<T | null | undefined> {
if (!this.scopes.includes(scope)) return undefined;
const result = await fetch(url, {
headers: {
authorization: `Bearer ${token}`
}
});
return result.ok ? ((await result.json()) as T) : null;
}
public static create(options?: ServerOptionsAuth): Auth | null {
if (!options?.secret || !options.id) return null;
return new Auth(options);
}
}
/**
* Defines the authentication data, this is to be encrypted and decrypted by the server.
* @since 1.0.0
*/
export interface AuthData {
/**
* The user ID.
* @since 1.0.0
*/
id: string;
/**
* The timestamp at which the token expires.
* @since 1.0.0
*/
expires: number;
/**
* The refresh token.
* @since 1.0.0
*/
refresh: string;
/**
* The access token.
* @since 1.0.0
*/
token: string;
}
/**
* Defines the authentication options.
* @since 1.0.0
*/
export interface ServerOptionsAuth {
/**
* The client's application id, this can be retrieved in Discord Developer Portal at https://discord.com/developers/applications.
* @since 1.0.0
*/
id: string;
/**
* The name for the cookie, this will be used to identify a Secure HttpOnly cookie.
* @since 1.0.0
* @default 'SAPPHIRE_AUTH'
*/
cookie?: string;
/**
* The client secret, this can be retrieved in Discord Developer Portal at https://discord.com/developers/applications.
* @since 1.0.0
*/
secret: string;
/**
* The scopes defined at https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes.
* @since 1.0.0
* @default [OAuth2Scopes.Identify]
*/
scopes?: OAuth2Scopes[];
/**
* The redirect uri. This will default to {@link OAuth2BodyData.redirectUri} if missing.
* @since 1.0.0
*/
redirect?: string;
/**
* The login data transformers used for {@link Auth.fetchData}.
* @since 1.4.0
* @default []
*/
transformers?: LoginDataTransformer[];
/**
* The domain that should be used for the cookie. This overwrites the automatic detection of the domain.
* @remark if you want to support subdomains (`one.example.two` and `two.example.com`) then you need to use prefix your domain with a `.`, for example `.example.com`
* @since 2.1.0
* @default undefined
*/
domainOverwrite?: string;
}
/**
* The login data sent when fetching data from a user.
* @since 1.4.0
*/
export interface LoginData {
/**
* The user data, defined when the `'identify'` scope is defined.
* @since 1.4.0
*/
user?: RESTGetAPICurrentUserResult | null;
/**
* The guilds data, defined when the `'guilds'` scope is defined.
* @since 1.4.0
*/
guilds?: RESTGetAPICurrentUserGuildsResult | null;
/**
* The connections data, defined when the `'connections'` scope is defined.
* @since 1.4.0
*/
connections?: RESTGetAPICurrentUserConnectionsResult | null;
}
/**
* A login data transformer.
* @since 1.4.0
*/
export interface LoginDataTransformer<T extends LoginData = LoginData> {
/**
* Transforms the object by mutating its properties or adding new ones.
* @since 1.4.0
*/
(data: LoginData): Awaitable<T>;
}