Skip to content
This repository was archived by the owner on Feb 27, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
"nodir",
"nomount",
"notcollected",
"oauths",
"offlinetraining",
"oldnames",
"oncreate",
Expand Down Expand Up @@ -167,6 +168,7 @@
"quintenary",
"raceid",
"remainingdailytournamentplaytime",
"Repliable",
"requirepass",
"restrictedstore",
"retrohardcore",
Expand Down
13 changes: 12 additions & 1 deletion apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,15 @@ MAILER_SMTP_PASS=your_email_password
#MAILER_GOOGLE_USER=your_email@example.com
#MAILER_GOOGLE_CLIENT_ID=your_google_client_id
#MAILER_GOOGLE_CLIENT_SECRET=your_google_client_secret
#MAILER_GOOGLE_REFRESH_TOKEN=your_google_refresh_token
#MAILER_GOOGLE_REFRESH_TOKEN=your_google_refresh_token



# Discord Configuration
# If you enable Discord integration, make sure to fill all the fields below.
DISCORD_ENABLED=false
#DISCORD_TOKEN=your_discord_bot_token_here
#DISCORD_CLIENT_ID=your_discord_client_id_here
#DISCORD_CLIENT_SECRET=your_discord_client_secret_here
#DISCORD_REDIRECT_URI=https://yoursite.com/v1/accounts/oauth/discord/callback
#DISCORD_GUILD_ID=your_discord_guild_id_here
3 changes: 3 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@
"@orpc/zod": "catalog:orpc",
"@prisma/adapter-mariadb": "^6.19.0",
"@prisma/client": "^6.19.0",
"axios": "^1.13.2",
"axios-retry": "^4.5.0",
"bullmq": "^5.65.1",
"discord.js": "^14.25.1",
"dotenv-flow": "^4.1.0",
"fast-xml-parser": "^5.3.2",
"hono": "^4.10.7",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE `miforge_account_oauths` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`provider` ENUM('DISCORD') NOT NULL,
`provider_account_id` VARCHAR(255) NOT NULL,
`username` VARCHAR(255) NULL,
`display_name` VARCHAR(255) NULL,
`email` VARCHAR(255) NULL,
`avatar_url` VARCHAR(512) NULL,
`access_token` TEXT NULL,
`refresh_token` TEXT NULL,
`expires_at` DATETIME(3) NULL,
`scope` VARCHAR(255) NULL,
`raw_profile` JSON NULL,
`account_id` INTEGER UNSIGNED NOT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),

INDEX `oauth_account_provider_idx`(`account_id`, `provider`),
UNIQUE INDEX `miforge_account_oauths_provider_provider_account_id_key`(`provider`, `provider_account_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- AddForeignKey
ALTER TABLE `miforge_account_oauths` ADD CONSTRAINT `miforge_account_oauths_account_id_fkey` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `miforge_account_confirmations` MODIFY `type` ENUM('EMAIL_VERIFICATION', 'PASSWORD_RESET', 'EMAIL_CHANGE', 'LOST_PASSWORD_RESET', 'DISCORD_OAUTH_LINK') NOT NULL;
1 change: 1 addition & 0 deletions apps/api/prisma/models/base.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ model accounts {
registrations miforge_account_registration?
audits miforge_account_audit[]
confirmations miforge_account_confirmations[]
oauths miforge_account_oauths[]

two_factor_enabled Boolean @default(false)
two_factor_secret String? @db.VarChar(64)
Expand Down
1 change: 1 addition & 0 deletions apps/api/prisma/models/confirmations.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ enum MiforgeAccountConfirmationType {
PASSWORD_RESET
EMAIL_CHANGE
LOST_PASSWORD_RESET
DISCORD_OAUTH_LINK
}

enum MiforgeAccountConfirmationChannel {
Expand Down
34 changes: 34 additions & 0 deletions apps/api/prisma/models/oauths.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
enum OAuthProvider {
DISCORD
}

model miforge_account_oauths {
id Int @id @default(autoincrement())

provider OAuthProvider

providerAccountId String @map("provider_account_id") @db.VarChar(255)

username String? @db.VarChar(255)
displayName String? @map("display_name") @db.VarChar(255)
email String? @db.VarChar(255)
avatarUrl String? @map("avatar_url") @db.VarChar(512)

accessToken String? @map("access_token") @db.Text
refreshToken String? @map("refresh_token") @db.Text
expiresAt DateTime? @map("expires_at")
scope String? @db.VarChar(255)

rawProfile Json? @map("raw_profile")

accountId Int @map("account_id") @db.UnsignedInt
account accounts @relation(fields: [accountId], references: [id], onDelete: Cascade)

created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt

/// Um mesmo (provider, providerAccountId) não pode estar em dois usuários
@@unique([provider, providerAccountId], name: "oauth_provider_account_unique")
/// Index pra listar conexões de uma conta
@@index([accountId, provider], name: "oauth_account_provider_idx")
}
3 changes: 3 additions & 0 deletions apps/api/prisma/seed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ const miforgeConfig = MiforgeConfigSchema.decode({
mailer: {
enabled: Boolean(env.MAILER_PROVIDER)
},
discord: {
enabled: Boolean(env.DISCORD_ENABLED)
},
account: {
emailConfirmationRequired: Boolean(env.MAILER_PROVIDER),
emailChangeConfirmationRequired: Boolean(env.MAILER_PROVIDER),
Expand Down
188 changes: 188 additions & 0 deletions apps/api/src/application/services/accountOauth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { ORPCError } from "@orpc/client";
import { inject, injectable } from "tsyringe";
import { Catch } from "@/application/decorators/Catch";
import type { DiscordApiClient } from "@/domain/clients";
import type { ExecutionContext } from "@/domain/context";
import type {
AccountConfirmationsRepository,
AccountOauthRepository,
AccountRepository,
} from "@/domain/repositories";
import { TOKENS } from "@/infra/di/tokens";
import { env } from "@/infra/env";
import type { AccountConfirmationsService } from "../accountConfirmations";

@injectable()
export class AccountOauthService {
constructor(
@inject(TOKENS.AccountConfirmationsService)
private readonly accountConfirmationsService: AccountConfirmationsService,
@inject(TOKENS.AccountConfirmationsRepository)
private readonly accountConfirmationsRepository: AccountConfirmationsRepository,
@inject(TOKENS.ExecutionContext)
private readonly executionContext: ExecutionContext,
@inject(TOKENS.AccountRepository)
private readonly accountRepository: AccountRepository,
@inject(TOKENS.AccountOauthRepository)
private readonly accountOauthRepository: AccountOauthRepository,
@inject(TOKENS.DiscordApiClient)
private readonly discordApiClient: DiscordApiClient,
) {}

@Catch()
async requestDiscordLink() {
if (!env.DISCORD_ENABLED) {
throw new ORPCError("UNAVAILABLE", {
message: "[Discord] integration is disabled",
});
}

const session = this.executionContext.session();

const account = await this.accountRepository.findByEmail(session.email);

if (!account) {
throw new ORPCError("NOT_FOUND", {
message: "Account not found",
});
}

const alreadyLink =
await this.accountOauthRepository.findByProviderAccountId(
"DISCORD",
account.id,
);

if (alreadyLink) {
throw new ORPCError("CONFLICT", {
message: "This account is already linked to a Discord account",
});
}

const alreadyHasConfirmation =
await this.accountConfirmationsRepository.findByAccountAndType(
account.id,
"DISCORD_OAUTH_LINK",
);

if (alreadyHasConfirmation) {
throw new ORPCError("CONFLICT", {
message:
"There is already a pending Discord link confirmation for this account",
data: {
expiresAt: alreadyHasConfirmation.expires_at,
},
});
}

const { token, tokenHash, expiresAt } =
await this.accountConfirmationsService.generateTokenAndHash(5);

await this.accountConfirmationsRepository.create(account.id, {
channel: "CODE",
type: "DISCORD_OAUTH_LINK",
tokenHash,
expiresAt,
});

if (!env.DISCORD_CLIENT_ID || !env.DISCORD_REDIRECT_URI) {
throw new ORPCError("UNAVAILABLE", {
message: "Discord OAuth configuration is missing",
});
}

const params = new URLSearchParams({
client_id: env.DISCORD_CLIENT_ID,
redirect_uri: env.DISCORD_REDIRECT_URI,
response_type: "code",
scope: ["identify", "email"].join(" "),
state: token,
prompt: "consent",
});

const discordBaseUrl = this.discordApiClient.getRouteOauthRoute();

return {
url: `${discordBaseUrl}?${params.toString()}`,
};
}

@Catch()
async confirmDiscordLink(code: string, state: string) {
if (!env.DISCORD_ENABLED) {
throw new ORPCError("UNAVAILABLE", {
message: "[Discord] integration is disabled",
});
}

const session = this.executionContext.session();

const account = await this.accountRepository.findByEmail(session.email);

if (!account) {
throw new ORPCError("NOT_FOUND", {
message: "Account not found",
});
}

const confirmation = await this.accountConfirmationsService.isValid(state);

const token = await this.discordApiClient.exchangeCodeForToken(code);
const discordUser = await this.discordApiClient.getUserInfo(
token.access_token,
);

await this.accountOauthRepository.upsert(
{
accessToken: token.access_token,
provider: "DISCORD",
avatarUrl: null,
displayName: discordUser.username,
email: discordUser.email ?? null,
refreshToken: token.refresh_token ?? null,
expiresAt: new Date(Date.now() + token.expires_in * 1000),
username: discordUser.username,
},
{
accountId: account.id,
providerAccountId: discordUser.id,
},
);

await this.accountConfirmationsService.verifyConfirmation(
confirmation,
state,
);

return {
url: `${env.FRONTEND_URL}/account/details`,
};
}

@Catch()
async unlinkDiscord() {
const session = this.executionContext.session();

const account = await this.accountRepository.findByEmail(session.email);

if (!account) {
throw new ORPCError("NOT_FOUND", {
message: "Account not found",
});
}

const oauthAccount =
await this.accountOauthRepository.findByProviderAccountId(
"DISCORD",
account.id,
);

if (!oauthAccount) {
throw new ORPCError("NOT_FOUND", {
message: "No linked Discord account found",
});
}

await this.accountOauthRepository.deleteById(oauthAccount.id);
}
}
11 changes: 11 additions & 0 deletions apps/api/src/application/services/accounts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,21 @@ export class AccountsService {
});
}

const oauths = account.oauths.reduce(
(acc, oauth) => {
acc[oauth.provider] = true;
return acc;
},
{} as Record<string, boolean>,
);

return {
...account,
sessions: account.sessions,
registration: account.registrations,
oauths: {
discord: Boolean(oauths?.DISCORD),
},
};
}

Expand Down
1 change: 1 addition & 0 deletions apps/api/src/application/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./accountConfirmations";
export * from "./accountOauth";
export * from "./accounts";
export * from "./accountTwoFactor";
export * from "./audit";
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/application/usecases/account/details/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export const AccountDetailsContractSchema = {
recoveryKey: true,
accountId: true,
}).nullable(),
oauths: z.object({
discord: z.boolean(),
}),
}),
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import z from "zod";

export const AccountDiscordOauthConfirmLinkContractSchema = {
input: z.object({
state: z.string(),
code: z.string(),
}),
output: z.object({
status: z.literal(302),
headers: z.object({
Location: z.url(),
}),
body: z.null(),
}),
};

export type AccountDiscordOauthConfirmLinkContractInput = z.infer<
typeof AccountDiscordOauthConfirmLinkContractSchema.input
>;

export type AccountDiscordOauthConfirmLinkContractOutput = z.infer<
typeof AccountDiscordOauthConfirmLinkContractSchema.output
>;
Loading