@oneunit/auth 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +465 -0
- package/CHANGELOG.md +214 -0
- package/LICENSE +21 -0
- package/README.md +647 -0
- package/dist/adapters.d.ts +51 -0
- package/dist/adapters.d.ts.map +1 -0
- package/dist/adapters.js +301 -0
- package/dist/adapters.js.map +1 -0
- package/dist/auth.d.ts +59 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +560 -0
- package/dist/auth.js.map +1 -0
- package/dist/errors.d.ts +39 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +65 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/jwt.d.ts +11 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +125 -0
- package/dist/jwt.js.map +1 -0
- package/dist/oauth.d.ts +31 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +178 -0
- package/dist/oauth.js.map +1 -0
- package/dist/password.d.ts +5 -0
- package/dist/password.d.ts.map +1 -0
- package/dist/password.js +90 -0
- package/dist/password.js.map +1 -0
- package/dist/providers.d.ts +37 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +471 -0
- package/dist/providers.js.map +1 -0
- package/dist/rbac.d.ts +40 -0
- package/dist/rbac.d.ts.map +1 -0
- package/dist/rbac.js +240 -0
- package/dist/rbac.js.map +1 -0
- package/dist/roles.d.ts +2 -0
- package/dist/roles.d.ts.map +1 -0
- package/dist/roles.js +2 -0
- package/dist/roles.js.map +1 -0
- package/dist/token.d.ts +2 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +2 -0
- package/dist/token.js.map +1 -0
- package/dist/types.d.ts +383 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +35 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +192 -0
- package/dist/utils.js.map +1 -0
- package/examples/express.ts +111 -0
- package/examples/fastify.ts +59 -0
- package/examples/oauth-social.ts +83 -0
- package/examples/standalone.ts +67 -0
- package/examples/uwebsockets.ts +143 -0
- package/package.json +86 -0
- package/src/adapters.ts +333 -0
- package/src/auth.ts +684 -0
- package/src/errors.ts +76 -0
- package/src/index.ts +124 -0
- package/src/jwt.ts +159 -0
- package/src/oauth.ts +226 -0
- package/src/password.ts +111 -0
- package/src/providers.ts +551 -0
- package/src/rbac.ts +285 -0
- package/src/roles.ts +1 -0
- package/src/token.ts +1 -0
- package/src/types.ts +432 -0
- package/src/utils.ts +231 -0
package/src/providers.ts
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { ProviderError, ValidationError } from "./errors.js";
|
|
3
|
+
import type {
|
|
4
|
+
AuthorizationUrlOptions,
|
|
5
|
+
AuthorizationUrlResult,
|
|
6
|
+
ExchangeCodeOptions,
|
|
7
|
+
OAuthProfile,
|
|
8
|
+
OAuthProvider,
|
|
9
|
+
OAuthProviderConfig,
|
|
10
|
+
OAuthTokens,
|
|
11
|
+
ProfileMapValue,
|
|
12
|
+
ProviderDefinition,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
import { buildUrl, formEncode, requestJson } from "./utils.js";
|
|
15
|
+
|
|
16
|
+
export function pkceVerifier(): string {
|
|
17
|
+
return randomBytes(32).toString("base64url");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function pkceChallenge(verifier: string): string {
|
|
21
|
+
return createHash("sha256").update(verifier).digest("base64url");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function requireConfig(config: OAuthProviderConfig, keys: Array<keyof OAuthProviderConfig>): void {
|
|
25
|
+
for (const key of keys) {
|
|
26
|
+
if (!config[key]) {
|
|
27
|
+
throw new ValidationError(`OAuth provider is missing ${String(key)}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mapProfile(source: unknown, mapping: Record<string, ProfileMapValue>): Record<string, unknown> {
|
|
33
|
+
const profile: Record<string, unknown> = {};
|
|
34
|
+
const record = isRecord(source) ? source : {};
|
|
35
|
+
for (const [key, path] of Object.entries(mapping)) {
|
|
36
|
+
profile[key] = typeof path === "function" ? path(record) : getPath(record, path);
|
|
37
|
+
}
|
|
38
|
+
return profile;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
42
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getPath(object: unknown, path: string): unknown {
|
|
46
|
+
if (!path) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
return String(path).split(".").reduce<unknown>((acc, key) => {
|
|
50
|
+
if (!isRecord(acc) && !Array.isArray(acc)) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
return (acc as Record<string, unknown>)[key];
|
|
54
|
+
}, object);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function errorMessage(body: unknown, fallback: string): string {
|
|
58
|
+
if (isRecord(body)) {
|
|
59
|
+
if (typeof body.error_description === "string") return body.error_description;
|
|
60
|
+
if (typeof body.error === "string") return body.error;
|
|
61
|
+
}
|
|
62
|
+
return fallback;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createProvider(definition: ProviderDefinition): OAuthProvider {
|
|
66
|
+
const {
|
|
67
|
+
id,
|
|
68
|
+
name = id,
|
|
69
|
+
authorizationUrl,
|
|
70
|
+
tokenUrl,
|
|
71
|
+
userInfoUrl,
|
|
72
|
+
scopes = [],
|
|
73
|
+
profileMap = {},
|
|
74
|
+
tokenAuthMethod = "body",
|
|
75
|
+
pkce = false,
|
|
76
|
+
extraAuthParams = {},
|
|
77
|
+
extraTokenParams = {},
|
|
78
|
+
userInfoHeaders = {},
|
|
79
|
+
parseProfile,
|
|
80
|
+
} = definition;
|
|
81
|
+
|
|
82
|
+
if (!id || !authorizationUrl || !tokenUrl) {
|
|
83
|
+
throw new ValidationError("Provider requires id, authorizationUrl, and tokenUrl");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
id,
|
|
88
|
+
name,
|
|
89
|
+
authorizationUrl,
|
|
90
|
+
tokenUrl,
|
|
91
|
+
userInfoUrl,
|
|
92
|
+
scopes,
|
|
93
|
+
pkce,
|
|
94
|
+
tokenAuthMethod,
|
|
95
|
+
|
|
96
|
+
createAuthorizationUrl(
|
|
97
|
+
config: OAuthProviderConfig,
|
|
98
|
+
{ state, redirectUri, codeVerifier, extraParams = {} }: AuthorizationUrlOptions = {},
|
|
99
|
+
): AuthorizationUrlResult {
|
|
100
|
+
requireConfig(config, ["clientId"]);
|
|
101
|
+
const params: Record<string, unknown> = {
|
|
102
|
+
client_id: config.clientId,
|
|
103
|
+
redirect_uri: redirectUri ?? config.redirectUri,
|
|
104
|
+
response_type: "code",
|
|
105
|
+
scope: (config.scopes ?? scopes).join(" "),
|
|
106
|
+
state,
|
|
107
|
+
...extraAuthParams,
|
|
108
|
+
...(config.extraAuthParams ?? {}),
|
|
109
|
+
...extraParams,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
let verifier: string | undefined;
|
|
113
|
+
if (pkce || config.pkce) {
|
|
114
|
+
verifier = codeVerifier ?? pkceVerifier();
|
|
115
|
+
params.code_challenge = pkceChallenge(verifier);
|
|
116
|
+
params.code_challenge_method = "S256";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
url: buildUrl(authorizationUrl, "", params),
|
|
121
|
+
state,
|
|
122
|
+
codeVerifier: verifier,
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async exchangeCode(
|
|
127
|
+
config: OAuthProviderConfig,
|
|
128
|
+
{ code, redirectUri, codeVerifier }: ExchangeCodeOptions = {},
|
|
129
|
+
): Promise<OAuthTokens> {
|
|
130
|
+
requireConfig(config, ["clientId"]);
|
|
131
|
+
if (!code) {
|
|
132
|
+
throw new ValidationError("Authorization code is required");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const body: Record<string, unknown> = {
|
|
136
|
+
grant_type: "authorization_code",
|
|
137
|
+
code,
|
|
138
|
+
redirect_uri: redirectUri ?? config.redirectUri,
|
|
139
|
+
...extraTokenParams,
|
|
140
|
+
...(config.extraTokenParams ?? {}),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
if (codeVerifier) {
|
|
144
|
+
body.code_verifier = codeVerifier;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const headers: Record<string, string> = {
|
|
148
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
149
|
+
accept: "application/json",
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if ((config.tokenAuthMethod ?? tokenAuthMethod) === "basic") {
|
|
153
|
+
const credentials = Buffer.from(`${config.clientId}:${config.clientSecret ?? ""}`).toString("base64");
|
|
154
|
+
headers.authorization = `Basic ${credentials}`;
|
|
155
|
+
} else {
|
|
156
|
+
body.client_id = config.clientId;
|
|
157
|
+
if (config.clientSecret) {
|
|
158
|
+
body.client_secret = config.clientSecret;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const response = await requestJson(tokenUrl, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers,
|
|
165
|
+
body: formEncode(body),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
throw new ProviderError(errorMessage(response.body, `${name} token exchange failed`), {
|
|
170
|
+
status: response.status,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return (isRecord(response.body) ? response.body : {}) as OAuthTokens;
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
async fetchProfile(config: OAuthProviderConfig, tokens: OAuthTokens): Promise<OAuthProfile> {
|
|
178
|
+
if (typeof parseProfile === "function") {
|
|
179
|
+
return parseProfile(tokens, config);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!userInfoUrl) {
|
|
183
|
+
return { provider: id, ...mapProfile(tokens, profileMap) };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const accessToken = tokens?.access_token;
|
|
187
|
+
if (!accessToken) {
|
|
188
|
+
throw new ProviderError(`${name} did not return an access token`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const response = await requestJson(userInfoUrl, {
|
|
192
|
+
method: "GET",
|
|
193
|
+
headers: {
|
|
194
|
+
authorization: `Bearer ${accessToken}`,
|
|
195
|
+
...userInfoHeaders,
|
|
196
|
+
...(config.userInfoHeaders ?? {}),
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
throw new ProviderError(errorMessage(response.body, `${name} profile fetch failed`), {
|
|
202
|
+
status: response.status,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
raw: response.body,
|
|
208
|
+
provider: id,
|
|
209
|
+
...mapProfile(response.body, profileMap),
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const google = createProvider({
|
|
216
|
+
id: "google",
|
|
217
|
+
name: "Google",
|
|
218
|
+
authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
219
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
220
|
+
userInfoUrl: "https://openidconnect.googleapis.com/v1/userinfo",
|
|
221
|
+
scopes: ["openid", "email", "profile"],
|
|
222
|
+
extraAuthParams: { access_type: "offline", prompt: "consent" },
|
|
223
|
+
profileMap: {
|
|
224
|
+
id: "sub",
|
|
225
|
+
email: "email",
|
|
226
|
+
emailVerified: "email_verified",
|
|
227
|
+
name: "name",
|
|
228
|
+
firstName: "given_name",
|
|
229
|
+
lastName: "family_name",
|
|
230
|
+
avatar: "picture",
|
|
231
|
+
locale: "locale",
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
export const github = createProvider({
|
|
236
|
+
id: "github",
|
|
237
|
+
name: "GitHub",
|
|
238
|
+
authorizationUrl: "https://github.com/login/oauth/authorize",
|
|
239
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
240
|
+
userInfoUrl: "https://api.github.com/user",
|
|
241
|
+
scopes: ["read:user", "user:email"],
|
|
242
|
+
userInfoHeaders: { "user-agent": "@oneunit/auth" },
|
|
243
|
+
async parseProfile(tokens) {
|
|
244
|
+
const accessToken = tokens?.access_token;
|
|
245
|
+
if (!accessToken) {
|
|
246
|
+
throw new ProviderError("GitHub did not return an access token");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const headers = {
|
|
250
|
+
authorization: `Bearer ${accessToken}`,
|
|
251
|
+
"user-agent": "@oneunit/auth",
|
|
252
|
+
accept: "application/vnd.github+json",
|
|
253
|
+
};
|
|
254
|
+
const userRes = await requestJson("https://api.github.com/user", { headers });
|
|
255
|
+
if (!userRes.ok) {
|
|
256
|
+
throw new ProviderError("GitHub profile fetch failed", { status: userRes.status });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const data = isRecord(userRes.body) ? userRes.body : {};
|
|
260
|
+
let email = typeof data.email === "string" ? data.email : undefined;
|
|
261
|
+
if (!email) {
|
|
262
|
+
const emailsRes = await requestJson("https://api.github.com/user/emails", { headers });
|
|
263
|
+
if (emailsRes.ok && Array.isArray(emailsRes.body)) {
|
|
264
|
+
const emails = emailsRes.body as Array<{ email?: string; primary?: boolean; verified?: boolean }>;
|
|
265
|
+
const primary = emails.find((item) => item.primary && item.verified)
|
|
266
|
+
?? emails.find((item) => item.verified)
|
|
267
|
+
?? emails[0];
|
|
268
|
+
email = primary?.email;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
provider: "github",
|
|
274
|
+
id: data.id as string | number | undefined,
|
|
275
|
+
username: data.login as string | undefined,
|
|
276
|
+
name: data.name as string | undefined,
|
|
277
|
+
email,
|
|
278
|
+
avatar: data.avatar_url as string | undefined,
|
|
279
|
+
profileUrl: data.html_url as string | undefined,
|
|
280
|
+
raw: data,
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
export const instagram = createProvider({
|
|
286
|
+
id: "instagram",
|
|
287
|
+
name: "Instagram",
|
|
288
|
+
authorizationUrl: "https://api.instagram.com/oauth/authorize",
|
|
289
|
+
tokenUrl: "https://api.instagram.com/oauth/access_token",
|
|
290
|
+
userInfoUrl: "https://graph.instagram.com/me?fields=id,username,account_type,name",
|
|
291
|
+
scopes: ["user_profile"],
|
|
292
|
+
profileMap: {
|
|
293
|
+
id: "id",
|
|
294
|
+
username: "username",
|
|
295
|
+
name: "name",
|
|
296
|
+
accountType: "account_type",
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
export const facebook = createProvider({
|
|
301
|
+
id: "facebook",
|
|
302
|
+
name: "Facebook",
|
|
303
|
+
authorizationUrl: "https://www.facebook.com/v21.0/dialog/oauth",
|
|
304
|
+
tokenUrl: "https://graph.facebook.com/v21.0/oauth/access_token",
|
|
305
|
+
userInfoUrl: "https://graph.facebook.com/me?fields=id,name,email,picture.type(large)",
|
|
306
|
+
scopes: ["email", "public_profile"],
|
|
307
|
+
profileMap: {
|
|
308
|
+
id: "id",
|
|
309
|
+
name: "name",
|
|
310
|
+
email: "email",
|
|
311
|
+
avatar: (data) => {
|
|
312
|
+
const picture = data.picture;
|
|
313
|
+
if (isRecord(picture) && isRecord(picture.data)) {
|
|
314
|
+
return picture.data.url;
|
|
315
|
+
}
|
|
316
|
+
return undefined;
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
export const twitter = createProvider({
|
|
322
|
+
id: "twitter",
|
|
323
|
+
name: "X",
|
|
324
|
+
authorizationUrl: "https://twitter.com/i/oauth2/authorize",
|
|
325
|
+
tokenUrl: "https://api.twitter.com/2/oauth2/token",
|
|
326
|
+
userInfoUrl: "https://api.twitter.com/2/users/me?user.fields=profile_image_url,name,username",
|
|
327
|
+
scopes: ["tweet.read", "users.read", "offline.access"],
|
|
328
|
+
pkce: true,
|
|
329
|
+
tokenAuthMethod: "basic",
|
|
330
|
+
extraAuthParams: { code_challenge_method: "S256" },
|
|
331
|
+
profileMap: {
|
|
332
|
+
id: "data.id",
|
|
333
|
+
username: "data.username",
|
|
334
|
+
name: "data.name",
|
|
335
|
+
avatar: "data.profile_image_url",
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
export const discord = createProvider({
|
|
340
|
+
id: "discord",
|
|
341
|
+
name: "Discord",
|
|
342
|
+
authorizationUrl: "https://discord.com/oauth2/authorize",
|
|
343
|
+
tokenUrl: "https://discord.com/api/oauth2/token",
|
|
344
|
+
userInfoUrl: "https://discord.com/api/users/@me",
|
|
345
|
+
scopes: ["identify", "email"],
|
|
346
|
+
profileMap: {
|
|
347
|
+
id: "id",
|
|
348
|
+
username: "username",
|
|
349
|
+
email: "email",
|
|
350
|
+
emailVerified: "verified",
|
|
351
|
+
avatar: (data) =>
|
|
352
|
+
typeof data.avatar === "string"
|
|
353
|
+
? `https://cdn.discordapp.com/avatars/${String(data.id)}/${data.avatar}.png`
|
|
354
|
+
: undefined,
|
|
355
|
+
locale: "locale",
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
const APPLE_ISSUER = "https://appleid.apple.com";
|
|
360
|
+
|
|
361
|
+
export const apple = createProvider({
|
|
362
|
+
id: "apple",
|
|
363
|
+
name: "Apple",
|
|
364
|
+
authorizationUrl: "https://appleid.apple.com/auth/authorize",
|
|
365
|
+
tokenUrl: "https://appleid.apple.com/auth/token",
|
|
366
|
+
scopes: ["name", "email"],
|
|
367
|
+
extraAuthParams: { response_mode: "form_post" },
|
|
368
|
+
parseProfile(tokens, config) {
|
|
369
|
+
const idToken = tokens?.id_token;
|
|
370
|
+
if (!idToken) {
|
|
371
|
+
return { provider: "apple", raw: tokens };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Apple is the only built-in provider whose profile comes from a token in
|
|
375
|
+
// the response rather than a server-side userinfo call, so the claims are
|
|
376
|
+
// checked here instead. See ARCHITECTURE.md for the signature caveat.
|
|
377
|
+
const payload = JSON.parse(Buffer.from(idToken.split(".")[1], "base64url").toString("utf8")) as Record<string, unknown>;
|
|
378
|
+
|
|
379
|
+
const iss = typeof payload.iss === "string" ? payload.iss : undefined;
|
|
380
|
+
if (iss !== APPLE_ISSUER) {
|
|
381
|
+
throw new ProviderError(`Apple id_token has an unexpected issuer: ${String(iss)}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const aud = payload.aud;
|
|
385
|
+
const clientId = config.clientId;
|
|
386
|
+
if (clientId) {
|
|
387
|
+
const matches = Array.isArray(aud) ? aud.includes(clientId) : aud === clientId;
|
|
388
|
+
if (!matches) {
|
|
389
|
+
throw new ProviderError("Apple id_token audience does not match the configured clientId");
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (typeof payload.exp === "number" && payload.exp <= Math.floor(Date.now() / 1000)) {
|
|
394
|
+
throw new ProviderError("Apple id_token has expired");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
provider: "apple",
|
|
399
|
+
id: payload.sub as string | undefined,
|
|
400
|
+
email: payload.email as string | undefined,
|
|
401
|
+
emailVerified: payload.email_verified === "true" || payload.email_verified === true,
|
|
402
|
+
raw: { tokens, claims: payload },
|
|
403
|
+
};
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
export const linkedin = createProvider({
|
|
408
|
+
id: "linkedin",
|
|
409
|
+
name: "LinkedIn",
|
|
410
|
+
authorizationUrl: "https://www.linkedin.com/oauth/v2/authorization",
|
|
411
|
+
tokenUrl: "https://www.linkedin.com/oauth/v2/accessToken",
|
|
412
|
+
userInfoUrl: "https://api.linkedin.com/v2/userinfo",
|
|
413
|
+
scopes: ["openid", "profile", "email"],
|
|
414
|
+
profileMap: {
|
|
415
|
+
id: "sub",
|
|
416
|
+
name: "name",
|
|
417
|
+
email: "email",
|
|
418
|
+
emailVerified: "email_verified",
|
|
419
|
+
avatar: "picture",
|
|
420
|
+
locale: "locale",
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
export const microsoft = createProvider({
|
|
425
|
+
id: "microsoft",
|
|
426
|
+
name: "Microsoft",
|
|
427
|
+
authorizationUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
|
428
|
+
tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
|
429
|
+
userInfoUrl: "https://graph.microsoft.com/oidc/userinfo",
|
|
430
|
+
scopes: ["openid", "profile", "email", "offline_access"],
|
|
431
|
+
profileMap: {
|
|
432
|
+
id: "sub",
|
|
433
|
+
email: "email",
|
|
434
|
+
name: "name",
|
|
435
|
+
firstName: "given_name",
|
|
436
|
+
lastName: "family_name",
|
|
437
|
+
avatar: "picture",
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
export const reddit = createProvider({
|
|
442
|
+
id: "reddit",
|
|
443
|
+
name: "Reddit",
|
|
444
|
+
authorizationUrl: "https://www.reddit.com/api/v1/authorize",
|
|
445
|
+
tokenUrl: "https://www.reddit.com/api/v1/access_token",
|
|
446
|
+
userInfoUrl: "https://oauth.reddit.com/api/v1/me",
|
|
447
|
+
scopes: ["identity"],
|
|
448
|
+
tokenAuthMethod: "basic",
|
|
449
|
+
extraAuthParams: { duration: "permanent" },
|
|
450
|
+
userInfoHeaders: { "user-agent": "@oneunit/auth" },
|
|
451
|
+
profileMap: {
|
|
452
|
+
id: "id",
|
|
453
|
+
username: "name",
|
|
454
|
+
avatar: "icon_img",
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
export const twitch = createProvider({
|
|
459
|
+
id: "twitch",
|
|
460
|
+
name: "Twitch",
|
|
461
|
+
authorizationUrl: "https://id.twitch.tv/oauth2/authorize",
|
|
462
|
+
tokenUrl: "https://id.twitch.tv/oauth2/token",
|
|
463
|
+
userInfoUrl: "https://api.twitch.tv/helix/users",
|
|
464
|
+
scopes: ["user:read:email"],
|
|
465
|
+
profileMap: {
|
|
466
|
+
id: "data.0.id",
|
|
467
|
+
username: "data.0.login",
|
|
468
|
+
name: "data.0.display_name",
|
|
469
|
+
email: "data.0.email",
|
|
470
|
+
avatar: "data.0.profile_image_url",
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
export const slack = createProvider({
|
|
475
|
+
id: "slack",
|
|
476
|
+
name: "Slack",
|
|
477
|
+
authorizationUrl: "https://slack.com/oauth/v2/authorize",
|
|
478
|
+
tokenUrl: "https://slack.com/api/oauth.v2.access",
|
|
479
|
+
userInfoUrl: "https://slack.com/api/users.identity",
|
|
480
|
+
scopes: ["identity.basic", "identity.email", "identity.avatar"],
|
|
481
|
+
profileMap: {
|
|
482
|
+
id: "user.id",
|
|
483
|
+
name: "user.name",
|
|
484
|
+
email: "user.email",
|
|
485
|
+
avatar: "user.image_512",
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
export const spotify = createProvider({
|
|
490
|
+
id: "spotify",
|
|
491
|
+
name: "Spotify",
|
|
492
|
+
authorizationUrl: "https://accounts.spotify.com/authorize",
|
|
493
|
+
tokenUrl: "https://accounts.spotify.com/api/token",
|
|
494
|
+
userInfoUrl: "https://api.spotify.com/v1/me",
|
|
495
|
+
scopes: ["user-read-email", "user-read-private"],
|
|
496
|
+
tokenAuthMethod: "basic",
|
|
497
|
+
profileMap: {
|
|
498
|
+
id: "id",
|
|
499
|
+
email: "email",
|
|
500
|
+
name: "display_name",
|
|
501
|
+
avatar: (data) => {
|
|
502
|
+
const images = data.images;
|
|
503
|
+
if (Array.isArray(images) && isRecord(images[0])) {
|
|
504
|
+
return images[0].url;
|
|
505
|
+
}
|
|
506
|
+
return undefined;
|
|
507
|
+
},
|
|
508
|
+
profileUrl: "external_urls.spotify",
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
export const tiktok = createProvider({
|
|
513
|
+
id: "tiktok",
|
|
514
|
+
name: "TikTok",
|
|
515
|
+
authorizationUrl: "https://www.tiktok.com/v2/auth/authorize/",
|
|
516
|
+
tokenUrl: "https://open.tiktokapis.com/v2/oauth/token/",
|
|
517
|
+
userInfoUrl: "https://open.tiktokapis.com/v2/user/info/?fields=open_id,union_id,avatar_url,display_name",
|
|
518
|
+
scopes: ["user.info.basic"],
|
|
519
|
+
extraAuthParams: { response_type: "code" },
|
|
520
|
+
profileMap: {
|
|
521
|
+
id: "data.user.open_id",
|
|
522
|
+
name: "data.user.display_name",
|
|
523
|
+
avatar: "data.user.avatar_url",
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
export const builtinProviders = Object.freeze({
|
|
528
|
+
google,
|
|
529
|
+
github,
|
|
530
|
+
instagram,
|
|
531
|
+
facebook,
|
|
532
|
+
twitter,
|
|
533
|
+
x: twitter,
|
|
534
|
+
discord,
|
|
535
|
+
apple,
|
|
536
|
+
linkedin,
|
|
537
|
+
microsoft,
|
|
538
|
+
reddit,
|
|
539
|
+
twitch,
|
|
540
|
+
slack,
|
|
541
|
+
spotify,
|
|
542
|
+
tiktok,
|
|
543
|
+
}) satisfies Record<string, OAuthProvider>;
|
|
544
|
+
|
|
545
|
+
export function getProvider(id: string): OAuthProvider {
|
|
546
|
+
const provider = builtinProviders[String(id).toLowerCase() as keyof typeof builtinProviders];
|
|
547
|
+
if (!provider) {
|
|
548
|
+
throw new ValidationError(`Unknown OAuth provider: ${id}`);
|
|
549
|
+
}
|
|
550
|
+
return provider;
|
|
551
|
+
}
|