Added connections files
This commit is contained in:
parent
9addb4ad35
commit
d8e69ba1bd
22 changed files with 1695 additions and 0 deletions
5
src/connections/BattleNet/BattleNetSettings.ts
Normal file
5
src/connections/BattleNet/BattleNetSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export class BattleNetSettings {
|
||||
enabled: boolean = false;
|
||||
clientId: string | null = null;
|
||||
clientSecret: string | null = null;
|
||||
}
|
125
src/connections/BattleNet/index.ts
Normal file
125
src/connections/BattleNet/index.ts
Normal file
|
@ -0,0 +1,125 @@
|
|||
import {
|
||||
ConnectedAccount,
|
||||
ConnectedAccountCommonOAuthTokenResponse,
|
||||
Connection,
|
||||
ConnectionCallbackSchema,
|
||||
ConnectionLoader,
|
||||
DiscordApiErrors,
|
||||
} from "@valkyrie/util";
|
||||
import wretch from "wretch";
|
||||
import { BattleNetSettings } from "./BattleNetSettings";
|
||||
|
||||
interface BattleNetConnectionUser {
|
||||
sub: string;
|
||||
id: number;
|
||||
battletag: string;
|
||||
}
|
||||
|
||||
// interface BattleNetErrorResponse {
|
||||
// error: string;
|
||||
// error_description: string;
|
||||
// }
|
||||
|
||||
export default class BattleNetConnection extends Connection {
|
||||
public readonly id = "battlenet";
|
||||
public readonly authorizeUrl = "https://oauth.battle.net/authorize";
|
||||
public readonly tokenUrl = "https://oauth.battle.net/token";
|
||||
public readonly userInfoUrl = "https://us.battle.net/oauth/userinfo";
|
||||
public readonly scopes = [];
|
||||
settings: BattleNetSettings = new BattleNetSettings();
|
||||
|
||||
init(): void {
|
||||
this.settings = ConnectionLoader.getConnectionConfig<BattleNetSettings>(
|
||||
this.id,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
if (
|
||||
this.settings.enabled &&
|
||||
(!this.settings.clientId || !this.settings.clientSecret)
|
||||
)
|
||||
throw new Error(`Invalid settings for connection ${this.id}`);
|
||||
}
|
||||
|
||||
getAuthorizationUrl(userId: string): string {
|
||||
const state = this.createState(userId);
|
||||
const url = new URL(this.authorizeUrl);
|
||||
|
||||
url.searchParams.append("client_id", this.settings.clientId as string);
|
||||
url.searchParams.append("redirect_uri", this.getRedirectUri());
|
||||
url.searchParams.append("scope", this.scopes.join(" "));
|
||||
url.searchParams.append("state", state);
|
||||
url.searchParams.append("response_type", "code");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
getTokenUrl(): string {
|
||||
return this.tokenUrl;
|
||||
}
|
||||
|
||||
async exchangeCode(
|
||||
state: string,
|
||||
code: string,
|
||||
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
|
||||
this.validateState(state);
|
||||
|
||||
const url = this.getTokenUrl();
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Accept: "application/json",
|
||||
})
|
||||
.body(
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
client_id: this.settings.clientId as string,
|
||||
client_secret: this.settings.clientSecret as string,
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
}),
|
||||
)
|
||||
.post()
|
||||
.json<ConnectedAccountCommonOAuthTokenResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async getUser(token: string): Promise<BattleNetConnectionUser> {
|
||||
const url = new URL(this.userInfoUrl);
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Authorization: `Bearer ${token}`,
|
||||
})
|
||||
.get()
|
||||
.json<BattleNetConnectionUser>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
params: ConnectionCallbackSchema,
|
||||
): Promise<ConnectedAccount | null> {
|
||||
const { state, code } = params;
|
||||
if (!code) throw new Error("No code provided");
|
||||
|
||||
const userId = this.getUserId(state);
|
||||
const tokenData = await this.exchangeCode(state, code);
|
||||
const userInfo = await this.getUser(tokenData.access_token);
|
||||
|
||||
const exists = await this.hasConnection(userId, userInfo.id.toString());
|
||||
|
||||
if (exists) return null;
|
||||
|
||||
return await this.createConnection({
|
||||
user_id: userId,
|
||||
external_id: userInfo.id.toString(),
|
||||
friend_sync: params.friend_sync,
|
||||
name: userInfo.battletag,
|
||||
type: this.id,
|
||||
});
|
||||
}
|
||||
}
|
5
src/connections/Discord/DiscordSettings.ts
Normal file
5
src/connections/Discord/DiscordSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export class DiscordSettings {
|
||||
enabled: boolean = false;
|
||||
clientId: string | null = null;
|
||||
clientSecret: string | null = null;
|
||||
}
|
124
src/connections/Discord/index.ts
Normal file
124
src/connections/Discord/index.ts
Normal file
|
@ -0,0 +1,124 @@
|
|||
import {
|
||||
ConnectedAccount,
|
||||
ConnectedAccountCommonOAuthTokenResponse,
|
||||
Connection,
|
||||
ConnectionCallbackSchema,
|
||||
ConnectionLoader,
|
||||
DiscordApiErrors,
|
||||
} from "@valkyrie/util";
|
||||
import wretch from "wretch";
|
||||
import { DiscordSettings } from "./DiscordSettings";
|
||||
|
||||
interface UserResponse {
|
||||
id: string;
|
||||
username: string;
|
||||
discriminator: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
export default class DiscordConnection extends Connection {
|
||||
public readonly id = "discord";
|
||||
public readonly authorizeUrl = "https://discord.com/api/oauth2/authorize";
|
||||
public readonly tokenUrl = "https://discord.com/api/oauth2/token";
|
||||
public readonly userInfoUrl = "https://discord.com/api/users/@me";
|
||||
public readonly scopes = ["identify"];
|
||||
settings: DiscordSettings = new DiscordSettings();
|
||||
|
||||
init(): void {
|
||||
this.settings = ConnectionLoader.getConnectionConfig<DiscordSettings>(
|
||||
this.id,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
if (
|
||||
this.settings.enabled &&
|
||||
(!this.settings.clientId || !this.settings.clientSecret)
|
||||
)
|
||||
throw new Error(`Invalid settings for connection ${this.id}`);
|
||||
}
|
||||
|
||||
getAuthorizationUrl(userId: string): string {
|
||||
const state = this.createState(userId);
|
||||
const url = new URL(this.authorizeUrl);
|
||||
|
||||
url.searchParams.append("state", state);
|
||||
url.searchParams.append("client_id", this.settings.clientId as string);
|
||||
url.searchParams.append("scope", this.scopes.join(" "));
|
||||
url.searchParams.append("response_type", "code");
|
||||
// controls whether, on repeated authorizations, the consent screen is shown
|
||||
url.searchParams.append("consent", "none");
|
||||
url.searchParams.append("redirect_uri", this.getRedirectUri());
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
getTokenUrl(): string {
|
||||
return this.tokenUrl;
|
||||
}
|
||||
|
||||
async exchangeCode(
|
||||
state: string,
|
||||
code: string,
|
||||
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
|
||||
this.validateState(state);
|
||||
const url = this.getTokenUrl();
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
.body(
|
||||
new URLSearchParams({
|
||||
client_id: this.settings.clientId as string,
|
||||
client_secret: this.settings.clientSecret as string,
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
}),
|
||||
)
|
||||
.post()
|
||||
.json<ConnectedAccountCommonOAuthTokenResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async getUser(token: string): Promise<UserResponse> {
|
||||
const url = new URL(this.userInfoUrl);
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Authorization: `Bearer ${token}`,
|
||||
})
|
||||
.get()
|
||||
.json<UserResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
params: ConnectionCallbackSchema,
|
||||
): Promise<ConnectedAccount | null> {
|
||||
const { state, code } = params;
|
||||
if (!code) throw new Error("No code provided");
|
||||
|
||||
const userId = this.getUserId(state);
|
||||
const tokenData = await this.exchangeCode(state, code);
|
||||
const userInfo = await this.getUser(tokenData.access_token);
|
||||
|
||||
const exists = await this.hasConnection(userId, userInfo.id);
|
||||
|
||||
if (exists) return null;
|
||||
|
||||
return await this.createConnection({
|
||||
user_id: userId,
|
||||
external_id: userInfo.id,
|
||||
friend_sync: params.friend_sync,
|
||||
name: `${userInfo.username}#${userInfo.discriminator}`,
|
||||
type: this.id,
|
||||
});
|
||||
}
|
||||
}
|
5
src/connections/EpicGames/EpicGamesSettings.ts
Normal file
5
src/connections/EpicGames/EpicGamesSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export class EpicGamesSettings {
|
||||
enabled: boolean = false;
|
||||
clientId: string | null = null;
|
||||
clientSecret: string | null = null;
|
||||
}
|
137
src/connections/EpicGames/index.ts
Normal file
137
src/connections/EpicGames/index.ts
Normal file
|
@ -0,0 +1,137 @@
|
|||
import {
|
||||
ConnectedAccount,
|
||||
ConnectedAccountCommonOAuthTokenResponse,
|
||||
Connection,
|
||||
ConnectionCallbackSchema,
|
||||
ConnectionLoader,
|
||||
DiscordApiErrors,
|
||||
} from "@valkyrie/util";
|
||||
import wretch from "wretch";
|
||||
import { EpicGamesSettings } from "./EpicGamesSettings";
|
||||
|
||||
export interface UserResponse {
|
||||
accountId: string;
|
||||
displayName: string;
|
||||
preferredLanguage: string;
|
||||
}
|
||||
|
||||
export interface EpicTokenResponse
|
||||
extends ConnectedAccountCommonOAuthTokenResponse {
|
||||
expires_at: string;
|
||||
refresh_expires_in: number;
|
||||
refresh_expires_at: string;
|
||||
account_id: string;
|
||||
client_id: string;
|
||||
application_id: string;
|
||||
}
|
||||
|
||||
export default class EpicGamesConnection extends Connection {
|
||||
public readonly id = "epicgames";
|
||||
public readonly authorizeUrl = "https://www.epicgames.com/id/authorize";
|
||||
public readonly tokenUrl = "https://api.epicgames.dev/epic/oauth/v1/token";
|
||||
public readonly userInfoUrl =
|
||||
"https://api.epicgames.dev/epic/id/v1/accounts";
|
||||
public readonly scopes = ["basic profile"];
|
||||
settings: EpicGamesSettings = new EpicGamesSettings();
|
||||
|
||||
init(): void {
|
||||
this.settings = ConnectionLoader.getConnectionConfig<EpicGamesSettings>(
|
||||
this.id,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
if (
|
||||
this.settings.enabled &&
|
||||
(!this.settings.clientId || !this.settings.clientSecret)
|
||||
)
|
||||
throw new Error(`Invalid settings for connection ${this.id}`);
|
||||
}
|
||||
|
||||
getAuthorizationUrl(userId: string): string {
|
||||
const state = this.createState(userId);
|
||||
const url = new URL(this.authorizeUrl);
|
||||
|
||||
url.searchParams.append("client_id", this.settings.clientId as string);
|
||||
url.searchParams.append("redirect_uri", this.getRedirectUri());
|
||||
url.searchParams.append("response_type", "code");
|
||||
url.searchParams.append("scope", this.scopes.join(" "));
|
||||
url.searchParams.append("state", state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
getTokenUrl(): string {
|
||||
return this.tokenUrl;
|
||||
}
|
||||
|
||||
async exchangeCode(
|
||||
state: string,
|
||||
code: string,
|
||||
): Promise<EpicTokenResponse> {
|
||||
this.validateState(state);
|
||||
|
||||
const url = this.getTokenUrl();
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${this.settings.clientId}:${this.settings.clientSecret}`,
|
||||
).toString("base64")}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
.body(
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
}),
|
||||
)
|
||||
.post()
|
||||
.json<EpicTokenResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async getUser(token: string): Promise<UserResponse[]> {
|
||||
const { sub } = JSON.parse(
|
||||
Buffer.from(token.split(".")[1], "base64").toString("utf8"),
|
||||
);
|
||||
const url = new URL(this.userInfoUrl);
|
||||
url.searchParams.append("accountId", sub);
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Authorization: `Bearer ${token}`,
|
||||
})
|
||||
.get()
|
||||
.json<UserResponse[]>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
params: ConnectionCallbackSchema,
|
||||
): Promise<ConnectedAccount | null> {
|
||||
const { state, code } = params;
|
||||
if (!code) throw new Error("No code provided");
|
||||
|
||||
const userId = this.getUserId(state);
|
||||
const tokenData = await this.exchangeCode(state, code);
|
||||
const userInfo = await this.getUser(tokenData.access_token);
|
||||
|
||||
const exists = await this.hasConnection(userId, userInfo[0].accountId);
|
||||
|
||||
if (exists) return null;
|
||||
|
||||
return await this.createConnection({
|
||||
user_id: userId,
|
||||
external_id: userInfo[0].accountId,
|
||||
friend_sync: params.friend_sync,
|
||||
name: userInfo[0].displayName,
|
||||
type: this.id,
|
||||
});
|
||||
}
|
||||
}
|
5
src/connections/Facebook/FacebookSettings.ts
Normal file
5
src/connections/Facebook/FacebookSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export class FacebookSettings {
|
||||
enabled: boolean = false;
|
||||
clientId: string | null = null;
|
||||
clientSecret: string | null = null;
|
||||
}
|
131
src/connections/Facebook/index.ts
Normal file
131
src/connections/Facebook/index.ts
Normal file
|
@ -0,0 +1,131 @@
|
|||
import {
|
||||
ConnectedAccount,
|
||||
ConnectedAccountCommonOAuthTokenResponse,
|
||||
Connection,
|
||||
ConnectionCallbackSchema,
|
||||
ConnectionLoader,
|
||||
DiscordApiErrors,
|
||||
} from "@valkyrie/util";
|
||||
import wretch from "wretch";
|
||||
import { FacebookSettings } from "./FacebookSettings";
|
||||
|
||||
export interface FacebookErrorResponse {
|
||||
error: {
|
||||
message: string;
|
||||
type: string;
|
||||
code: number;
|
||||
fbtrace_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UserResponse {
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export default class FacebookConnection extends Connection {
|
||||
public readonly id = "facebook";
|
||||
public readonly authorizeUrl =
|
||||
"https://www.facebook.com/v14.0/dialog/oauth";
|
||||
public readonly tokenUrl =
|
||||
"https://graph.facebook.com/v14.0/oauth/access_token";
|
||||
public readonly userInfoUrl = "https://graph.facebook.com/v14.0/me";
|
||||
public readonly scopes = ["public_profile"];
|
||||
settings: FacebookSettings = new FacebookSettings();
|
||||
|
||||
init(): void {
|
||||
this.settings = ConnectionLoader.getConnectionConfig<FacebookSettings>(
|
||||
this.id,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
if (
|
||||
this.settings.enabled &&
|
||||
(!this.settings.clientId || !this.settings.clientSecret)
|
||||
)
|
||||
throw new Error(`Invalid settings for connection ${this.id}`);
|
||||
}
|
||||
|
||||
getAuthorizationUrl(userId: string): string {
|
||||
const state = this.createState(userId);
|
||||
const url = new URL(this.authorizeUrl);
|
||||
|
||||
url.searchParams.append("client_id", this.settings.clientId as string);
|
||||
url.searchParams.append("redirect_uri", this.getRedirectUri());
|
||||
url.searchParams.append("state", state);
|
||||
url.searchParams.append("response_type", "code");
|
||||
url.searchParams.append("scope", this.scopes.join(" "));
|
||||
url.searchParams.append("display", "popup");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
getTokenUrl(code: string): string {
|
||||
const url = new URL(this.tokenUrl);
|
||||
url.searchParams.append("client_id", this.settings.clientId as string);
|
||||
url.searchParams.append(
|
||||
"client_secret",
|
||||
this.settings.clientSecret as string,
|
||||
);
|
||||
url.searchParams.append("code", code);
|
||||
url.searchParams.append("redirect_uri", this.getRedirectUri());
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async exchangeCode(
|
||||
state: string,
|
||||
code: string,
|
||||
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
|
||||
this.validateState(state);
|
||||
|
||||
const url = this.getTokenUrl(code);
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Accept: "application/json",
|
||||
})
|
||||
.get()
|
||||
.json<ConnectedAccountCommonOAuthTokenResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async getUser(token: string): Promise<UserResponse> {
|
||||
const url = new URL(this.userInfoUrl);
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Authorization: `Bearer ${token}`,
|
||||
})
|
||||
.get()
|
||||
.json<UserResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
params: ConnectionCallbackSchema,
|
||||
): Promise<ConnectedAccount | null> {
|
||||
const { state, code } = params;
|
||||
if (!code) throw new Error("No code provided");
|
||||
|
||||
const userId = this.getUserId(state);
|
||||
const tokenData = await this.exchangeCode(state, code);
|
||||
const userInfo = await this.getUser(tokenData.access_token);
|
||||
|
||||
const exists = await this.hasConnection(userId, userInfo.id);
|
||||
|
||||
if (exists) return null;
|
||||
|
||||
return await this.createConnection({
|
||||
user_id: userId,
|
||||
external_id: userInfo.id,
|
||||
friend_sync: params.friend_sync,
|
||||
name: userInfo.name,
|
||||
type: this.id,
|
||||
});
|
||||
}
|
||||
}
|
5
src/connections/GitHub/GitHubSettings.ts
Normal file
5
src/connections/GitHub/GitHubSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export class GitHubSettings {
|
||||
enabled: boolean = false;
|
||||
clientId: string | null = null;
|
||||
clientSecret: string | null = null;
|
||||
}
|
118
src/connections/GitHub/index.ts
Normal file
118
src/connections/GitHub/index.ts
Normal file
|
@ -0,0 +1,118 @@
|
|||
import {
|
||||
ConnectedAccount,
|
||||
ConnectedAccountCommonOAuthTokenResponse,
|
||||
Connection,
|
||||
ConnectionCallbackSchema,
|
||||
ConnectionLoader,
|
||||
DiscordApiErrors,
|
||||
} from "@valkyrie/util";
|
||||
import wretch from "wretch";
|
||||
import { GitHubSettings } from "./GitHubSettings";
|
||||
|
||||
interface UserResponse {
|
||||
login: string;
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default class GitHubConnection extends Connection {
|
||||
public readonly id = "github";
|
||||
public readonly authorizeUrl = "https://github.com/login/oauth/authorize";
|
||||
public readonly tokenUrl = "https://github.com/login/oauth/access_token";
|
||||
public readonly userInfoUrl = "https://api.github.com/user";
|
||||
public readonly scopes = ["read:user"];
|
||||
settings: GitHubSettings = new GitHubSettings();
|
||||
|
||||
init(): void {
|
||||
this.settings = ConnectionLoader.getConnectionConfig<GitHubSettings>(
|
||||
this.id,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
if (
|
||||
this.settings.enabled &&
|
||||
(!this.settings.clientId || !this.settings.clientSecret)
|
||||
)
|
||||
throw new Error(`Invalid settings for connection ${this.id}`);
|
||||
}
|
||||
|
||||
getAuthorizationUrl(userId: string): string {
|
||||
const state = this.createState(userId);
|
||||
const url = new URL(this.authorizeUrl);
|
||||
|
||||
url.searchParams.append("client_id", this.settings.clientId as string);
|
||||
url.searchParams.append("redirect_uri", this.getRedirectUri());
|
||||
url.searchParams.append("scope", this.scopes.join(" "));
|
||||
url.searchParams.append("state", state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
getTokenUrl(code: string): string {
|
||||
const url = new URL(this.tokenUrl);
|
||||
url.searchParams.append("client_id", this.settings.clientId as string);
|
||||
url.searchParams.append(
|
||||
"client_secret",
|
||||
this.settings.clientSecret as string,
|
||||
);
|
||||
url.searchParams.append("code", code);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async exchangeCode(
|
||||
state: string,
|
||||
code: string,
|
||||
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
|
||||
this.validateState(state);
|
||||
|
||||
const url = this.getTokenUrl(code);
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Accept: "application/json",
|
||||
})
|
||||
|
||||
.post()
|
||||
.json<ConnectedAccountCommonOAuthTokenResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async getUser(token: string): Promise<UserResponse> {
|
||||
const url = new URL(this.userInfoUrl);
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Authorization: `Bearer ${token}`,
|
||||
})
|
||||
.get()
|
||||
.json<UserResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
params: ConnectionCallbackSchema,
|
||||
): Promise<ConnectedAccount | null> {
|
||||
const { state, code } = params;
|
||||
if (!code) throw new Error("No code provided");
|
||||
|
||||
const userId = this.getUserId(state);
|
||||
const tokenData = await this.exchangeCode(state, code);
|
||||
const userInfo = await this.getUser(tokenData.access_token);
|
||||
|
||||
const exists = await this.hasConnection(userId, userInfo.id.toString());
|
||||
|
||||
if (exists) return null;
|
||||
|
||||
return await this.createConnection({
|
||||
user_id: userId,
|
||||
external_id: userInfo.id.toString(),
|
||||
friend_sync: params.friend_sync,
|
||||
name: userInfo.login,
|
||||
type: this.id,
|
||||
});
|
||||
}
|
||||
}
|
5
src/connections/Reddit/RedditSettings.ts
Normal file
5
src/connections/Reddit/RedditSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export class RedditSettings {
|
||||
enabled: boolean = false;
|
||||
clientId: string | null = null;
|
||||
clientSecret: string | null = null;
|
||||
}
|
137
src/connections/Reddit/index.ts
Normal file
137
src/connections/Reddit/index.ts
Normal file
|
@ -0,0 +1,137 @@
|
|||
import {
|
||||
ConnectedAccount,
|
||||
ConnectedAccountCommonOAuthTokenResponse,
|
||||
Connection,
|
||||
ConnectionCallbackSchema,
|
||||
ConnectionLoader,
|
||||
DiscordApiErrors,
|
||||
} from "@valkyrie/util";
|
||||
import wretch from "wretch";
|
||||
import { RedditSettings } from "./RedditSettings";
|
||||
|
||||
export interface UserResponse {
|
||||
verified: boolean;
|
||||
coins: number;
|
||||
id: string;
|
||||
is_mod: boolean;
|
||||
has_verified_email: boolean;
|
||||
total_karma: number;
|
||||
name: string;
|
||||
created: number;
|
||||
gold_creddits: number;
|
||||
created_utc: number;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
message: string;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export default class RedditConnection extends Connection {
|
||||
public readonly id = "reddit";
|
||||
public readonly authorizeUrl = "https://www.reddit.com/api/v1/authorize";
|
||||
public readonly tokenUrl = "https://www.reddit.com/api/v1/access_token";
|
||||
public readonly userInfoUrl = "https://oauth.reddit.com/api/v1/me";
|
||||
public readonly scopes = ["identity"];
|
||||
settings: RedditSettings = new RedditSettings();
|
||||
|
||||
init(): void {
|
||||
this.settings = ConnectionLoader.getConnectionConfig<RedditSettings>(
|
||||
this.id,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
if (
|
||||
this.settings.enabled &&
|
||||
(!this.settings.clientId || !this.settings.clientSecret)
|
||||
)
|
||||
throw new Error(`Invalid settings for connection ${this.id}`);
|
||||
}
|
||||
|
||||
getAuthorizationUrl(userId: string): string {
|
||||
const state = this.createState(userId);
|
||||
const url = new URL(this.authorizeUrl);
|
||||
|
||||
url.searchParams.append("client_id", this.settings.clientId as string);
|
||||
url.searchParams.append("redirect_uri", this.getRedirectUri());
|
||||
url.searchParams.append("response_type", "code");
|
||||
url.searchParams.append("scope", this.scopes.join(" "));
|
||||
url.searchParams.append("state", state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
getTokenUrl(): string {
|
||||
return this.tokenUrl;
|
||||
}
|
||||
|
||||
async exchangeCode(
|
||||
state: string,
|
||||
code: string,
|
||||
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
|
||||
this.validateState(state);
|
||||
|
||||
const url = this.getTokenUrl();
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${this.settings.clientId}:${this.settings.clientSecret}`,
|
||||
).toString("base64")}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
.body(
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
}),
|
||||
)
|
||||
.post()
|
||||
.json<ConnectedAccountCommonOAuthTokenResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async getUser(token: string): Promise<UserResponse> {
|
||||
const url = new URL(this.userInfoUrl);
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Authorization: `Bearer ${token}`,
|
||||
})
|
||||
.get()
|
||||
.json<UserResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
params: ConnectionCallbackSchema,
|
||||
): Promise<ConnectedAccount | null> {
|
||||
const { state, code } = params;
|
||||
if (!code) throw new Error("No code provided");
|
||||
|
||||
const userId = this.getUserId(state);
|
||||
const tokenData = await this.exchangeCode(state, code);
|
||||
const userInfo = await this.getUser(tokenData.access_token);
|
||||
|
||||
const exists = await this.hasConnection(userId, userInfo.id.toString());
|
||||
|
||||
if (exists) return null;
|
||||
|
||||
// TODO: connection metadata
|
||||
|
||||
return await this.createConnection({
|
||||
user_id: userId,
|
||||
external_id: userInfo.id.toString(),
|
||||
friend_sync: params.friend_sync,
|
||||
name: userInfo.name,
|
||||
verified: userInfo.has_verified_email,
|
||||
type: this.id,
|
||||
});
|
||||
}
|
||||
}
|
5
src/connections/Spotify/SpotifySettings.ts
Normal file
5
src/connections/Spotify/SpotifySettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export class SpotifySettings {
|
||||
enabled: boolean = false;
|
||||
clientId: string | null = null;
|
||||
clientSecret: string | null = null;
|
||||
}
|
185
src/connections/Spotify/index.ts
Normal file
185
src/connections/Spotify/index.ts
Normal file
|
@ -0,0 +1,185 @@
|
|||
import {
|
||||
ConnectedAccount,
|
||||
ConnectedAccountCommonOAuthTokenResponse,
|
||||
ConnectionCallbackSchema,
|
||||
ConnectionLoader,
|
||||
DiscordApiErrors,
|
||||
RefreshableConnection,
|
||||
} from "@valkyrie/util";
|
||||
import wretch from "wretch";
|
||||
import { SpotifySettings } from "./SpotifySettings";
|
||||
|
||||
export interface UserResponse {
|
||||
display_name: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface TokenErrorResponse {
|
||||
error: string;
|
||||
error_description: string;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: {
|
||||
status: number;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default class SpotifyConnection extends RefreshableConnection {
|
||||
public readonly id = "spotify";
|
||||
public readonly authorizeUrl = "https://accounts.spotify.com/authorize";
|
||||
public readonly tokenUrl = "https://accounts.spotify.com/api/token";
|
||||
public readonly userInfoUrl = "https://api.spotify.com/v1/me";
|
||||
public readonly scopes = [
|
||||
"user-read-private",
|
||||
"user-read-playback-state",
|
||||
"user-modify-playback-state",
|
||||
"user-read-currently-playing",
|
||||
];
|
||||
settings: SpotifySettings = new SpotifySettings();
|
||||
|
||||
init(): void {
|
||||
/**
|
||||
* The way Discord shows the currently playing song is by using Spotifys partner API. This is obviously not possible for us.
|
||||
* So to prevent spamming the spotify api we disable the ability to refresh.
|
||||
*/
|
||||
this.refreshEnabled = false;
|
||||
|
||||
this.settings = ConnectionLoader.getConnectionConfig<SpotifySettings>(
|
||||
this.id,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
if (
|
||||
this.settings.enabled &&
|
||||
(!this.settings.clientId || !this.settings.clientSecret)
|
||||
)
|
||||
throw new Error(`Invalid settings for connection ${this.id}`);
|
||||
}
|
||||
|
||||
getAuthorizationUrl(userId: string): string {
|
||||
const state = this.createState(userId);
|
||||
const url = new URL(this.authorizeUrl);
|
||||
|
||||
url.searchParams.append("client_id", this.settings.clientId as string);
|
||||
url.searchParams.append("redirect_uri", this.getRedirectUri());
|
||||
url.searchParams.append("response_type", "code");
|
||||
url.searchParams.append("scope", this.scopes.join(" "));
|
||||
url.searchParams.append("state", state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
getTokenUrl(): string {
|
||||
return this.tokenUrl;
|
||||
}
|
||||
|
||||
async exchangeCode(
|
||||
state: string,
|
||||
code: string,
|
||||
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
|
||||
this.validateState(state);
|
||||
|
||||
const url = this.getTokenUrl();
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${this.settings.clientId as string}:${
|
||||
this.settings.clientSecret as string
|
||||
}`,
|
||||
).toString("base64")}`,
|
||||
})
|
||||
.body(
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
}),
|
||||
)
|
||||
.post()
|
||||
.json<ConnectedAccountCommonOAuthTokenResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async refreshToken(
|
||||
connectedAccount: ConnectedAccount,
|
||||
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
|
||||
if (!connectedAccount.token_data?.refresh_token)
|
||||
throw new Error("No refresh token available.");
|
||||
const refresh_token = connectedAccount.token_data.refresh_token;
|
||||
const url = this.getTokenUrl();
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${this.settings.clientId as string}:${
|
||||
this.settings.clientSecret as string
|
||||
}`,
|
||||
).toString("base64")}`,
|
||||
})
|
||||
.body(
|
||||
new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token,
|
||||
}),
|
||||
)
|
||||
.post()
|
||||
.unauthorized(async () => {
|
||||
// assume the token was revoked
|
||||
await connectedAccount.revoke();
|
||||
return DiscordApiErrors.CONNECTION_REVOKED;
|
||||
})
|
||||
.json<ConnectedAccountCommonOAuthTokenResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async getUser(token: string): Promise<UserResponse> {
|
||||
const url = new URL(this.userInfoUrl);
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Authorization: `Bearer ${token}`,
|
||||
})
|
||||
.get()
|
||||
.json<UserResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
params: ConnectionCallbackSchema,
|
||||
): Promise<ConnectedAccount | null> {
|
||||
const { state, code } = params;
|
||||
if (!code) throw new Error("No code provided");
|
||||
|
||||
const userId = this.getUserId(state);
|
||||
const tokenData = await this.exchangeCode(state, code);
|
||||
const userInfo = await this.getUser(tokenData.access_token);
|
||||
|
||||
const exists = await this.hasConnection(userId, userInfo.id);
|
||||
|
||||
if (exists) return null;
|
||||
|
||||
return await this.createConnection({
|
||||
token_data: { ...tokenData, fetched_at: Date.now() },
|
||||
user_id: userId,
|
||||
external_id: userInfo.id,
|
||||
friend_sync: params.friend_sync,
|
||||
name: userInfo.display_name,
|
||||
type: this.id,
|
||||
});
|
||||
}
|
||||
}
|
5
src/connections/Twitch/TwitchSettings.ts
Normal file
5
src/connections/Twitch/TwitchSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export class TwitchSettings {
|
||||
enabled: boolean = false;
|
||||
clientId: string | null = null;
|
||||
clientSecret: string | null = null;
|
||||
}
|
172
src/connections/Twitch/index.ts
Normal file
172
src/connections/Twitch/index.ts
Normal file
|
@ -0,0 +1,172 @@
|
|||
import {
|
||||
ConnectedAccount,
|
||||
ConnectedAccountCommonOAuthTokenResponse,
|
||||
ConnectionCallbackSchema,
|
||||
ConnectionLoader,
|
||||
DiscordApiErrors,
|
||||
RefreshableConnection,
|
||||
} from "@valkyrie/util";
|
||||
import wretch from "wretch";
|
||||
import { TwitchSettings } from "./TwitchSettings";
|
||||
|
||||
interface TwitchConnectionUserResponse {
|
||||
data: {
|
||||
id: string;
|
||||
login: string;
|
||||
display_name: string;
|
||||
type: string;
|
||||
broadcaster_type: string;
|
||||
description: string;
|
||||
profile_image_url: string;
|
||||
offline_image_url: string;
|
||||
view_count: number;
|
||||
created_at: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export default class TwitchConnection extends RefreshableConnection {
|
||||
public readonly id = "twitch";
|
||||
public readonly authorizeUrl = "https://id.twitch.tv/oauth2/authorize";
|
||||
public readonly tokenUrl = "https://id.twitch.tv/oauth2/token";
|
||||
public readonly userInfoUrl = "https://api.twitch.tv/helix/users";
|
||||
public readonly scopes = [
|
||||
"channel_subscriptions",
|
||||
"channel_check_subscription",
|
||||
"channel:read:subscriptions",
|
||||
];
|
||||
settings: TwitchSettings = new TwitchSettings();
|
||||
|
||||
init(): void {
|
||||
this.settings = ConnectionLoader.getConnectionConfig<TwitchSettings>(
|
||||
this.id,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
if (
|
||||
this.settings.enabled &&
|
||||
(!this.settings.clientId || !this.settings.clientSecret)
|
||||
)
|
||||
throw new Error(`Invalid settings for connection ${this.id}`);
|
||||
}
|
||||
|
||||
getAuthorizationUrl(userId: string): string {
|
||||
const state = this.createState(userId);
|
||||
const url = new URL(this.authorizeUrl);
|
||||
|
||||
url.searchParams.append("client_id", this.settings.clientId as string);
|
||||
url.searchParams.append("redirect_uri", this.getRedirectUri());
|
||||
url.searchParams.append("response_type", "code");
|
||||
url.searchParams.append("scope", this.scopes.join(" "));
|
||||
url.searchParams.append("state", state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
getTokenUrl(): string {
|
||||
return this.tokenUrl;
|
||||
}
|
||||
|
||||
async exchangeCode(
|
||||
state: string,
|
||||
code: string,
|
||||
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
|
||||
this.validateState(state);
|
||||
|
||||
const url = this.getTokenUrl();
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
.body(
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
client_id: this.settings.clientId as string,
|
||||
client_secret: this.settings.clientSecret as string,
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
}),
|
||||
)
|
||||
.post()
|
||||
.json<ConnectedAccountCommonOAuthTokenResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async refreshToken(
|
||||
connectedAccount: ConnectedAccount,
|
||||
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
|
||||
if (!connectedAccount.token_data?.refresh_token)
|
||||
throw new Error("No refresh token available.");
|
||||
const refresh_token = connectedAccount.token_data.refresh_token;
|
||||
|
||||
const url = this.getTokenUrl();
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
.body(
|
||||
new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: this.settings.clientId as string,
|
||||
client_secret: this.settings.clientSecret as string,
|
||||
refresh_token: refresh_token,
|
||||
}),
|
||||
)
|
||||
.post()
|
||||
.unauthorized(async () => {
|
||||
// assume the token was revoked
|
||||
await connectedAccount.revoke();
|
||||
return DiscordApiErrors.CONNECTION_REVOKED;
|
||||
})
|
||||
.json<ConnectedAccountCommonOAuthTokenResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async getUser(token: string): Promise<TwitchConnectionUserResponse> {
|
||||
const url = new URL(this.userInfoUrl);
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Client-Id": this.settings.clientId as string,
|
||||
})
|
||||
.get()
|
||||
.json<TwitchConnectionUserResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
params: ConnectionCallbackSchema,
|
||||
): Promise<ConnectedAccount | null> {
|
||||
const { state, code } = params;
|
||||
if (!code) throw new Error("No code provided");
|
||||
|
||||
const userId = this.getUserId(state);
|
||||
const tokenData = await this.exchangeCode(state, code);
|
||||
const userInfo = await this.getUser(tokenData.access_token);
|
||||
|
||||
const exists = await this.hasConnection(userId, userInfo.data[0].id);
|
||||
|
||||
if (exists) return null;
|
||||
|
||||
return await this.createConnection({
|
||||
token_data: { ...tokenData, fetched_at: Date.now() },
|
||||
user_id: userId,
|
||||
external_id: userInfo.data[0].id,
|
||||
friend_sync: params.friend_sync,
|
||||
name: userInfo.data[0].display_name,
|
||||
type: this.id,
|
||||
});
|
||||
}
|
||||
}
|
5
src/connections/Twitter/TwitterSettings.ts
Normal file
5
src/connections/Twitter/TwitterSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export class TwitterSettings {
|
||||
enabled: boolean = false;
|
||||
clientId: string | null = null;
|
||||
clientSecret: string | null = null;
|
||||
}
|
178
src/connections/Twitter/index.ts
Normal file
178
src/connections/Twitter/index.ts
Normal file
|
@ -0,0 +1,178 @@
|
|||
import {
|
||||
ConnectedAccount,
|
||||
ConnectedAccountCommonOAuthTokenResponse,
|
||||
ConnectionCallbackSchema,
|
||||
ConnectionLoader,
|
||||
DiscordApiErrors,
|
||||
RefreshableConnection,
|
||||
} from "@valkyrie/util";
|
||||
import wretch from "wretch";
|
||||
import { TwitterSettings } from "./TwitterSettings";
|
||||
|
||||
interface TwitterUserResponse {
|
||||
data: {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
created_at: string;
|
||||
location: string;
|
||||
url: string;
|
||||
description: string;
|
||||
verified: string;
|
||||
};
|
||||
}
|
||||
|
||||
// interface TwitterErrorResponse {
|
||||
// error: string;
|
||||
// error_description: string;
|
||||
// }
|
||||
|
||||
export default class TwitterConnection extends RefreshableConnection {
|
||||
public readonly id = "twitter";
|
||||
public readonly authorizeUrl = "https://twitter.com/i/oauth2/authorize";
|
||||
public readonly tokenUrl = "https://api.twitter.com/2/oauth2/token";
|
||||
public readonly userInfoUrl =
|
||||
"https://api.twitter.com/2/users/me?user.fields=created_at%2Cdescription%2Cid%2Cname%2Cusername%2Cverified%2Clocation%2Curl";
|
||||
public readonly scopes = ["users.read", "tweet.read"];
|
||||
settings: TwitterSettings = new TwitterSettings();
|
||||
|
||||
init(): void {
|
||||
this.settings = ConnectionLoader.getConnectionConfig<TwitterSettings>(
|
||||
this.id,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
if (
|
||||
this.settings.enabled &&
|
||||
(!this.settings.clientId || !this.settings.clientSecret)
|
||||
)
|
||||
throw new Error(`Invalid settings for connection ${this.id}`);
|
||||
}
|
||||
|
||||
getAuthorizationUrl(userId: string): string {
|
||||
const state = this.createState(userId);
|
||||
const url = new URL(this.authorizeUrl);
|
||||
|
||||
url.searchParams.append("client_id", this.settings.clientId as string);
|
||||
url.searchParams.append("redirect_uri", this.getRedirectUri());
|
||||
url.searchParams.append("response_type", "code");
|
||||
url.searchParams.append("scope", this.scopes.join(" "));
|
||||
url.searchParams.append("state", state);
|
||||
url.searchParams.append("code_challenge", "challenge"); // TODO: properly use PKCE challenge
|
||||
url.searchParams.append("code_challenge_method", "plain");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
getTokenUrl(): string {
|
||||
return this.tokenUrl;
|
||||
}
|
||||
|
||||
async exchangeCode(
|
||||
state: string,
|
||||
code: string,
|
||||
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
|
||||
this.validateState(state);
|
||||
|
||||
const url = this.getTokenUrl();
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${this.settings.clientId as string}:${
|
||||
this.settings.clientSecret as string
|
||||
}`,
|
||||
).toString("base64")}`,
|
||||
})
|
||||
.body(
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
client_id: this.settings.clientId as string,
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
code_verifier: "challenge", // TODO: properly use PKCE challenge
|
||||
}),
|
||||
)
|
||||
.post()
|
||||
.json<ConnectedAccountCommonOAuthTokenResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async refreshToken(
|
||||
connectedAccount: ConnectedAccount,
|
||||
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
|
||||
if (!connectedAccount.token_data?.refresh_token)
|
||||
throw new Error("No refresh token available.");
|
||||
const refresh_token = connectedAccount.token_data.refresh_token;
|
||||
|
||||
const url = this.getTokenUrl();
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${this.settings.clientId as string}:${
|
||||
this.settings.clientSecret as string
|
||||
}`,
|
||||
).toString("base64")}`,
|
||||
})
|
||||
.body(
|
||||
new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token,
|
||||
client_id: this.settings.clientId as string,
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
code_verifier: "challenge", // TODO: properly use PKCE challenge
|
||||
}),
|
||||
)
|
||||
.post()
|
||||
.json<ConnectedAccountCommonOAuthTokenResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async getUser(token: string): Promise<TwitterUserResponse> {
|
||||
const url = new URL(this.userInfoUrl);
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Authorization: `Bearer ${token}`,
|
||||
})
|
||||
.get()
|
||||
.json<TwitterUserResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
params: ConnectionCallbackSchema,
|
||||
): Promise<ConnectedAccount | null> {
|
||||
const { state, code } = params;
|
||||
if (!code) throw new Error("No code provided");
|
||||
|
||||
const userId = this.getUserId(state);
|
||||
const tokenData = await this.exchangeCode(state, code);
|
||||
const userInfo = await this.getUser(tokenData.access_token);
|
||||
|
||||
const exists = await this.hasConnection(userId, userInfo.data.id);
|
||||
|
||||
if (exists) return null;
|
||||
|
||||
return await this.createConnection({
|
||||
token_data: { ...tokenData, fetched_at: Date.now() },
|
||||
user_id: userId,
|
||||
external_id: userInfo.data.id,
|
||||
friend_sync: params.friend_sync,
|
||||
name: userInfo.data.name,
|
||||
type: this.id,
|
||||
});
|
||||
}
|
||||
}
|
5
src/connections/Xbox/XboxSettings.ts
Normal file
5
src/connections/Xbox/XboxSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export class XboxSettings {
|
||||
enabled: boolean = false;
|
||||
clientId: string | null = null;
|
||||
clientSecret: string | null = null;
|
||||
}
|
191
src/connections/Xbox/index.ts
Normal file
191
src/connections/Xbox/index.ts
Normal file
|
@ -0,0 +1,191 @@
|
|||
import {
|
||||
ConnectedAccount,
|
||||
ConnectedAccountCommonOAuthTokenResponse,
|
||||
Connection,
|
||||
ConnectionCallbackSchema,
|
||||
ConnectionLoader,
|
||||
DiscordApiErrors,
|
||||
} from "@valkyrie/util";
|
||||
import wretch from "wretch";
|
||||
import { XboxSettings } from "./XboxSettings";
|
||||
|
||||
interface XboxUserResponse {
|
||||
IssueInstant: string;
|
||||
NotAfter: string;
|
||||
Token: string;
|
||||
DisplayClaims: {
|
||||
xui: {
|
||||
gtg: string;
|
||||
xid: string;
|
||||
uhs: string;
|
||||
agg: string;
|
||||
usr: string;
|
||||
utr: string;
|
||||
prv: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
// interface XboxErrorResponse {
|
||||
// error: string;
|
||||
// error_description: string;
|
||||
// }
|
||||
|
||||
export default class XboxConnection extends Connection {
|
||||
public readonly id = "xbox";
|
||||
public readonly authorizeUrl =
|
||||
"https://login.live.com/oauth20_authorize.srf";
|
||||
public readonly tokenUrl = "https://login.live.com/oauth20_token.srf";
|
||||
public readonly userInfoUrl =
|
||||
"https://xsts.auth.xboxlive.com/xsts/authorize";
|
||||
public readonly userAuthUrl =
|
||||
"https://user.auth.xboxlive.com/user/authenticate";
|
||||
public readonly scopes = ["Xboxlive.signin", "Xboxlive.offline_access"];
|
||||
settings: XboxSettings = new XboxSettings();
|
||||
|
||||
init(): void {
|
||||
this.settings = ConnectionLoader.getConnectionConfig<XboxSettings>(
|
||||
this.id,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
if (
|
||||
this.settings.enabled &&
|
||||
(!this.settings.clientId || !this.settings.clientSecret)
|
||||
)
|
||||
throw new Error(`Invalid settings for connection ${this.id}`);
|
||||
}
|
||||
|
||||
getAuthorizationUrl(userId: string): string {
|
||||
const state = this.createState(userId);
|
||||
const url = new URL(this.authorizeUrl);
|
||||
|
||||
url.searchParams.append("client_id", this.settings.clientId as string);
|
||||
url.searchParams.append("redirect_uri", this.getRedirectUri());
|
||||
url.searchParams.append("response_type", "code");
|
||||
url.searchParams.append("scope", this.scopes.join(" "));
|
||||
url.searchParams.append("state", state);
|
||||
url.searchParams.append("approval_prompt", "auto");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
getTokenUrl(): string {
|
||||
return this.tokenUrl;
|
||||
}
|
||||
|
||||
async getUserToken(token: string): Promise<string> {
|
||||
return wretch(this.userAuthUrl)
|
||||
.headers({
|
||||
"x-xbl-contract-version": "3",
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
})
|
||||
.body(
|
||||
JSON.stringify({
|
||||
RelyingParty: "http://auth.xboxlive.com",
|
||||
TokenType: "JWT",
|
||||
Properties: {
|
||||
AuthMethod: "RPS",
|
||||
SiteName: "user.auth.xboxlive.com",
|
||||
RpsTicket: `d=${token}`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.post()
|
||||
.json((res: XboxUserResponse) => res.Token)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async exchangeCode(
|
||||
state: string,
|
||||
code: string,
|
||||
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
|
||||
this.validateState(state);
|
||||
|
||||
const url = this.getTokenUrl();
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
`${this.settings.clientId as string}:${
|
||||
this.settings.clientSecret as string
|
||||
}`,
|
||||
).toString("base64")}`,
|
||||
})
|
||||
.body(
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
client_id: this.settings.clientId as string,
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
scope: this.scopes.join(" "),
|
||||
}),
|
||||
)
|
||||
.post()
|
||||
.json<ConnectedAccountCommonOAuthTokenResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async getUser(token: string): Promise<XboxUserResponse> {
|
||||
const url = new URL(this.userInfoUrl);
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
"x-xbl-contract-version": "3",
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
})
|
||||
.body(
|
||||
JSON.stringify({
|
||||
RelyingParty: "http://xboxlive.com",
|
||||
TokenType: "JWT",
|
||||
Properties: {
|
||||
UserTokens: [token],
|
||||
SandboxId: "RETAIL",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.post()
|
||||
.json<XboxUserResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
params: ConnectionCallbackSchema,
|
||||
): Promise<ConnectedAccount | null> {
|
||||
const { state, code } = params;
|
||||
if (!code) throw new Error("No code provided");
|
||||
|
||||
const userId = this.getUserId(state);
|
||||
const tokenData = await this.exchangeCode(state, code);
|
||||
const userToken = await this.getUserToken(tokenData.access_token);
|
||||
const userInfo = await this.getUser(userToken);
|
||||
|
||||
const exists = await this.hasConnection(
|
||||
userId,
|
||||
userInfo.DisplayClaims.xui[0].xid,
|
||||
);
|
||||
|
||||
if (exists) return null;
|
||||
|
||||
return await this.createConnection({
|
||||
token_data: { ...tokenData, fetched_at: Date.now() },
|
||||
user_id: userId,
|
||||
external_id: userInfo.DisplayClaims.xui[0].xid,
|
||||
friend_sync: params.friend_sync,
|
||||
name: userInfo.DisplayClaims.xui[0].gtg,
|
||||
type: this.id,
|
||||
});
|
||||
}
|
||||
}
|
5
src/connections/Youtube/YoutubeSettings.ts
Normal file
5
src/connections/Youtube/YoutubeSettings.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export class YoutubeSettings {
|
||||
enabled: boolean = false;
|
||||
clientId: string | null = null;
|
||||
clientSecret: string | null = null;
|
||||
}
|
142
src/connections/Youtube/index.ts
Normal file
142
src/connections/Youtube/index.ts
Normal file
|
@ -0,0 +1,142 @@
|
|||
import {
|
||||
ConnectedAccount,
|
||||
ConnectedAccountCommonOAuthTokenResponse,
|
||||
Connection,
|
||||
ConnectionCallbackSchema,
|
||||
ConnectionLoader,
|
||||
DiscordApiErrors,
|
||||
} from "@valkyrie/util";
|
||||
import wretch from "wretch";
|
||||
import { YoutubeSettings } from "./YoutubeSettings";
|
||||
|
||||
interface YouTubeConnectionChannelListResult {
|
||||
items: {
|
||||
snippet: {
|
||||
// thumbnails: Thumbnails;
|
||||
title: string;
|
||||
country: string;
|
||||
publishedAt: string;
|
||||
// localized: Localized;
|
||||
description: string;
|
||||
};
|
||||
kind: string;
|
||||
etag: string;
|
||||
id: string;
|
||||
}[];
|
||||
kind: string;
|
||||
etag: string;
|
||||
pageInfo: {
|
||||
resultsPerPage: number;
|
||||
totalResults: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default class YoutubeConnection extends Connection {
|
||||
public readonly id = "youtube";
|
||||
public readonly authorizeUrl =
|
||||
"https://accounts.google.com/o/oauth2/v2/auth";
|
||||
public readonly tokenUrl = "https://oauth2.googleapis.com/token";
|
||||
public readonly userInfoUrl =
|
||||
"https://www.googleapis.com/youtube/v3/channels?mine=true&part=snippet";
|
||||
public readonly scopes = [
|
||||
"https://www.googleapis.com/auth/youtube.readonly",
|
||||
];
|
||||
settings: YoutubeSettings = new YoutubeSettings();
|
||||
|
||||
init(): void {
|
||||
this.settings = ConnectionLoader.getConnectionConfig<YoutubeSettings>(
|
||||
this.id,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
if (
|
||||
this.settings.enabled &&
|
||||
(!this.settings.clientId || !this.settings.clientSecret)
|
||||
)
|
||||
throw new Error(`Invalid settings for connection ${this.id}`);
|
||||
}
|
||||
|
||||
getAuthorizationUrl(userId: string): string {
|
||||
const state = this.createState(userId);
|
||||
const url = new URL(this.authorizeUrl);
|
||||
|
||||
url.searchParams.append("client_id", this.settings.clientId as string);
|
||||
url.searchParams.append("redirect_uri", this.getRedirectUri());
|
||||
url.searchParams.append("response_type", "code");
|
||||
url.searchParams.append("scope", this.scopes.join(" "));
|
||||
url.searchParams.append("state", state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
getTokenUrl(): string {
|
||||
return this.tokenUrl;
|
||||
}
|
||||
|
||||
async exchangeCode(
|
||||
state: string,
|
||||
code: string,
|
||||
): Promise<ConnectedAccountCommonOAuthTokenResponse> {
|
||||
this.validateState(state);
|
||||
|
||||
const url = this.getTokenUrl();
|
||||
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
})
|
||||
.body(
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
client_id: this.settings.clientId as string,
|
||||
client_secret: this.settings.clientSecret as string,
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
}),
|
||||
)
|
||||
.post()
|
||||
.json<ConnectedAccountCommonOAuthTokenResponse>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async getUser(token: string): Promise<YouTubeConnectionChannelListResult> {
|
||||
const url = new URL(this.userInfoUrl);
|
||||
return wretch(url.toString())
|
||||
.headers({
|
||||
Authorization: `Bearer ${token}`,
|
||||
})
|
||||
.get()
|
||||
.json<YouTubeConnectionChannelListResult>()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
throw DiscordApiErrors.GENERAL_ERROR;
|
||||
});
|
||||
}
|
||||
|
||||
async handleCallback(
|
||||
params: ConnectionCallbackSchema,
|
||||
): Promise<ConnectedAccount | null> {
|
||||
const { state, code } = params;
|
||||
if (!code) throw new Error("No code provided");
|
||||
|
||||
const userId = this.getUserId(state);
|
||||
const tokenData = await this.exchangeCode(state, code);
|
||||
const userInfo = await this.getUser(tokenData.access_token);
|
||||
|
||||
const exists = await this.hasConnection(userId, userInfo.items[0].id);
|
||||
|
||||
if (exists) return null;
|
||||
|
||||
return await this.createConnection({
|
||||
token_data: { ...tokenData, fetched_at: Date.now() },
|
||||
user_id: userId,
|
||||
external_id: userInfo.items[0].id,
|
||||
friend_sync: params.friend_sync,
|
||||
name: userInfo.items[0].snippet.title,
|
||||
type: this.id,
|
||||
});
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue