diff --git a/.githooks/pre-commit b/.githooks/pre-commit
deleted file mode 100755
index b06bebc4..00000000
--- a/.githooks/pre-commit
+++ /dev/null
@@ -1,4 +0,0 @@
-#!/bin/sh
-
-# lint & format visualizer
-cd frontend && pnpm exec lint-staged --allow-empty
diff --git a/api/.env.example b/api/.env.example
index 1a398cbf..9a68457c 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -8,3 +8,16 @@ FRONTEND_SECRET=FJIOERJGOERHGOIREHGIREHGROIEHGEORIGHOIOI3HRIO3232
API_SECRET_ENCRYPTION_KEY=c5o43icm5i43jio5j34p5
RABBITMQ_URL=amqp://guest:guest@localhost:5672
K8S_SERVICE_URL=http://localhost:9000
+
+
+JWT_SECRET=gerg43g3g34g
+GITHUB_CLIENT_ID=
+GITHUB_CLIENT_SECRET=
+GITHUB_CALLBACK_URL=http://localhost:4000/auth/github/callback
+OAUTH_SUCCESS_REDIRECT_URL=http://localhost:3000/auth/sso
+42_OAUTH_SUCCESS_REDIRECT_URL=http://localhost:3000/profile
+CORS_ORIGIN=http://localhost:3000
+
+FORTYTWO_CALLBACK_URL=http://localhost:4000/auth/42/callback
+FORTYTWO_CLIENT_ID=
+FORTYTWO_CLIENT_SECRET=
\ No newline at end of file
diff --git a/api/README.md b/api/README.md
index 5eb14883..bba65ef8 100644
--- a/api/README.md
+++ b/api/README.md
@@ -106,3 +106,21 @@ This service runs as both:
* **Microservice** - RabbitMQ message consumer for:
* `game_results` - Match result processing
* `github-service-results` - GitHub operation results
+
+## Environment Variables
+
+### GitHub OAuth (Required)
+
+1. Go to GitHub → Settings → Developer settings → OAuth Apps
+2. Register a new OAuth app
+3. Set Authorization callback URL: `http://localhost:4000/auth/github/callback`
+4. Add Client ID and Secret to `.env.local`
+
+### 42 School OAuth (Optional)
+
+For testing account linking functionality:
+
+1. Go to 42 School → Settings → API → Applications
+2. Create a new application
+3. Set Redirect URI: `http://localhost:4000/auth/42/callback`
+4. Add Client ID and Client Secret to `.env.local`
diff --git a/api/package.json b/api/package.json
index 7b9db33a..454f3dfa 100644
--- a/api/package.json
+++ b/api/package.json
@@ -31,6 +31,7 @@
"@nestjs/common": "^11.1.7",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.7",
+ "@nestjs/jwt": "^11.0.0",
"@nestjs/microservices": "^11.1.7",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.7",
@@ -45,6 +46,8 @@
"crypto-js": "^4.2.0",
"passport": "^0.7.0",
"passport-github2": "^0.1.12",
+ "passport-jwt": "^4.0.1",
+ "passport-oauth2": "^1.8.0",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml
index 20aac125..94d0bfc1 100644
--- a/api/pnpm-lock.yaml
+++ b/api/pnpm-lock.yaml
@@ -20,6 +20,9 @@ importers:
'@nestjs/core':
specifier: ^11.1.7
version: 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.7)(@nestjs/platform-express@11.1.7)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/jwt':
+ specifier: ^11.0.0
+ version: 11.0.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/microservices':
specifier: ^11.1.7
version: 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -62,6 +65,12 @@ importers:
passport-github2:
specifier: ^0.1.12
version: 0.1.12
+ passport-jwt:
+ specifier: ^4.0.1
+ version: 4.0.1
+ passport-oauth2:
+ specifier: ^1.8.0
+ version: 1.8.0
pg:
specifier: ^8.16.3
version: 8.16.3
@@ -853,6 +862,11 @@ packages:
'@nestjs/websockets':
optional: true
+ '@nestjs/jwt@11.0.1':
+ resolution: {integrity: sha512-HXSsc7SAnCnjA98TsZqrE7trGtHDnYXWp4Ffy6LwSmck1QvbGYdMzBquXofX5l6tIRpeY4Qidl2Ti2CVG77Pdw==}
+ peerDependencies:
+ '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
+
'@nestjs/mapped-types@2.1.0':
resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==}
peerDependencies:
@@ -1183,6 +1197,9 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+ '@types/jsonwebtoken@9.0.10':
+ resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
+
'@types/luxon@3.7.1':
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
@@ -1192,6 +1209,9 @@ packages:
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
+ '@types/ms@2.1.0':
+ resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
+
'@types/node@24.9.1':
resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==}
@@ -1710,6 +1730,9 @@ packages:
buffer-crc32@0.2.13:
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
+ buffer-equal-constant-time@1.0.1:
+ resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
+
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -2013,6 +2036,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
+ ecdsa-sig-formatter@1.0.11:
+ resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+
edmonds-blossom-fixed@1.0.1:
resolution: {integrity: sha512-wtpraSt4yJeUpNU8RGC4q2JBxsJbHFxI7/htm/mS4FgxSN90WBwmgE7QZwpcY70KJRqmfLCNxU49UIc+DfzLKQ==}
@@ -2722,6 +2748,16 @@ packages:
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
+ jsonwebtoken@9.0.2:
+ resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
+ engines: {node: '>=12', npm: '>=6'}
+
+ jwa@1.4.2:
+ resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
+
+ jws@3.2.2:
+ resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -2759,12 +2795,33 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
+ lodash.includes@4.3.0:
+ resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
+
+ lodash.isboolean@3.0.3:
+ resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
+
+ lodash.isinteger@4.0.4:
+ resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
+
+ lodash.isnumber@3.0.3:
+ resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
+
+ lodash.isplainobject@4.0.6:
+ resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
+
+ lodash.isstring@4.0.1:
+ resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
+
lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+ lodash.once@4.1.1:
+ resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
+
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@@ -3013,6 +3070,9 @@ packages:
resolution: {integrity: sha512-3nPUCc7ttF/3HSP/k9sAXjz3SkGv5Nki84I05kSQPo01Jqq1NzJACgMblCK0fGcv9pKCG/KXU3AJRDGLqHLoIw==}
engines: {node: '>= 0.8.0'}
+ passport-jwt@4.0.1:
+ resolution: {integrity: sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==}
+
passport-oauth2@1.8.0:
resolution: {integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==}
engines: {node: '>= 0.4.0'}
@@ -4707,6 +4767,12 @@ snapshots:
'@nestjs/microservices': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/platform-express': 11.1.7(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.7)
+ '@nestjs/jwt@11.0.1(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
+ dependencies:
+ '@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@types/jsonwebtoken': 9.0.10
+ jsonwebtoken: 9.0.2
+
'@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.1.7(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -5022,12 +5088,19 @@ snapshots:
'@types/json-schema@7.0.15': {}
+ '@types/jsonwebtoken@9.0.10':
+ dependencies:
+ '@types/ms': 2.1.0
+ '@types/node': 24.9.1
+
'@types/luxon@3.7.1': {}
'@types/methods@1.1.4': {}
'@types/mime@1.3.5': {}
+ '@types/ms@2.1.0': {}
+
'@types/node@24.9.1':
dependencies:
undici-types: 7.16.0
@@ -5650,6 +5723,8 @@ snapshots:
buffer-crc32@0.2.13: {}
+ buffer-equal-constant-time@1.0.1: {}
+
buffer-from@1.1.2: {}
buffer-more-ints@1.0.0: {}
@@ -5905,6 +5980,10 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
+ ecdsa-sig-formatter@1.0.11:
+ dependencies:
+ safe-buffer: 5.2.1
+
edmonds-blossom-fixed@1.0.1: {}
ee-first@1.1.1: {}
@@ -6833,6 +6912,30 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
+ jsonwebtoken@9.0.2:
+ dependencies:
+ jws: 3.2.2
+ lodash.includes: 4.3.0
+ lodash.isboolean: 3.0.3
+ lodash.isinteger: 4.0.4
+ lodash.isnumber: 3.0.3
+ lodash.isplainobject: 4.0.6
+ lodash.isstring: 4.0.1
+ lodash.once: 4.1.1
+ ms: 2.1.3
+ semver: 7.7.3
+
+ jwa@1.4.2:
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+
+ jws@3.2.2:
+ dependencies:
+ jwa: 1.4.2
+ safe-buffer: 5.2.1
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -6862,10 +6965,24 @@ snapshots:
dependencies:
p-locate: 5.0.0
+ lodash.includes@4.3.0: {}
+
+ lodash.isboolean@3.0.3: {}
+
+ lodash.isinteger@4.0.4: {}
+
+ lodash.isnumber@3.0.3: {}
+
+ lodash.isplainobject@4.0.6: {}
+
+ lodash.isstring@4.0.1: {}
+
lodash.memoize@4.1.2: {}
lodash.merge@4.6.2: {}
+ lodash.once@4.1.1: {}
+
lodash@4.17.21: {}
log-symbols@4.1.0:
@@ -7078,6 +7195,11 @@ snapshots:
dependencies:
passport-oauth2: 1.8.0
+ passport-jwt@4.0.1:
+ dependencies:
+ jsonwebtoken: 9.0.2
+ passport-strategy: 1.0.0
+
passport-oauth2@1.8.0:
dependencies:
base64url: 3.0.1
diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts
index 438edde2..5cb51701 100644
--- a/api/src/auth/auth.controller.ts
+++ b/api/src/auth/auth.controller.ts
@@ -1,4 +1,126 @@
-import { Controller } from "@nestjs/common";
+import {
+ BadRequestException,
+ Body,
+ Controller,
+ Get,
+ Post,
+ Query,
+ Req,
+ Res,
+ UseGuards,
+} from "@nestjs/common";
+import { AuthGuard } from "@nestjs/passport";
+import { Request, Response } from "express";
+import { AuthService } from "./auth.service";
+import { JwtAuthGuard } from "./jwt-auth.guard";
+import { ConfigService } from "@nestjs/config";
+import { UserService } from "../user/user.service";
+import { UserId } from "../guards/UserGuard";
+import * as CryptoJS from "crypto-js";
+import { SocialAccountService } from "../user/social-account.service";
+import { SocialPlatform } from "../user/entities/social-account.entity";
@Controller("auth")
-export class AuthController {}
+export class AuthController {
+ constructor(
+ private auth: AuthService,
+ private configService: ConfigService,
+ private userService: UserService,
+ private socialAccountService: SocialAccountService,
+ ) {}
+
+ @Get("/github/callback")
+ @UseGuards(AuthGuard("github"))
+ githubCallback(@Req() req: Request, @Res() res: Response) {
+ const user: any = (req as any).user;
+ const token = this.auth.signToken(user);
+ const redirectUrl = this.configService.getOrThrow(
+ "OAUTH_SUCCESS_REDIRECT_URL",
+ );
+ if (redirectUrl) {
+ res.cookie("token", token, {
+ httpOnly: true,
+ secure: true,
+ sameSite: "none",
+ maxAge: 30 * 24 * 60 * 60 * 1000,
+ });
+ return res.redirect(redirectUrl);
+ }
+ return res.json({ token });
+ }
+
+ @Get("/42/getUrl")
+ @UseGuards(JwtAuthGuard)
+ getFortyTwoAuthUrl(@UserId() userId: string) {
+ const encryptedUserId = CryptoJS.AES.encrypt(
+ userId,
+ this.configService.getOrThrow("API_SECRET_ENCRYPTION_KEY"),
+ ).toString();
+
+ const base64EncodedEncryptedUserId =
+ Buffer.from(encryptedUserId).toString("base64");
+
+ return `https://api.intra.42.fr/oauth/authorize?client_id=${this.configService.getOrThrow("FORTYTWO_CLIENT_ID")}&redirect_uri=${encodeURIComponent(this.configService.getOrThrow("FORTYTWO_CALLBACK_URL"))}&response_type=code&state=${base64EncodedEncryptedUserId}`;
+ }
+
+ @Get("/42/callback")
+ @UseGuards(AuthGuard("42"))
+ async fortyTwoCallback(
+ @Req()
+ request: Request & {
+ user: {
+ fortyTwoAccount: {
+ platformUserId: string;
+ username: string;
+ email: string;
+ };
+ };
+ },
+ @Res() res: Response,
+ @Query("state") encryptedUserId: string,
+ ) {
+ try {
+ const base64DecodedEncryptedUserId = Buffer.from(
+ encryptedUserId,
+ "base64",
+ ).toString("utf-8");
+
+ const userId = CryptoJS.AES.decrypt(
+ base64DecodedEncryptedUserId,
+ this.configService.getOrThrow("API_SECRET_ENCRYPTION_KEY"),
+ ).toString(CryptoJS.enc.Utf8);
+ if (!userId) throw new BadRequestException("Invalid state parameter.");
+
+ await this.socialAccountService.upsertSocialAccountForUser({
+ userId,
+ platform: SocialPlatform.FORTYTWO,
+ platformUserId: request.user.fortyTwoAccount.platformUserId,
+ username: request.user.fortyTwoAccount.username,
+ });
+
+ const redirectUrl = this.configService.getOrThrow(
+ "42_OAUTH_SUCCESS_REDIRECT_URL",
+ );
+
+ return res.redirect(redirectUrl);
+ } catch (e) {
+ // Use a more detailed log, and preserve specific error messages for BadRequestException
+ console.error("Error in FortyTwo callback:", e);
+ if (e instanceof BadRequestException) {
+ throw e;
+ }
+ throw new BadRequestException(
+ e && typeof e.message === "string"
+ ? `Invalid state parameter: ${e.message}`
+ : "Invalid state parameter."
+ );
+ }
+ }
+
+ @Get("/me")
+ @UseGuards(JwtAuthGuard)
+ me(@Req() req: Request) {
+ const user: any = (req as any).user;
+ return this.userService.getUserById(user.id);
+ }
+}
diff --git a/api/src/auth/auth.module.ts b/api/src/auth/auth.module.ts
index a7eac273..9dc7effc 100644
--- a/api/src/auth/auth.module.ts
+++ b/api/src/auth/auth.module.ts
@@ -1,9 +1,37 @@
import { Module } from "@nestjs/common";
+import { PassportModule } from "@nestjs/passport";
+import { JwtModule } from "@nestjs/jwt";
+import { ConfigModule, ConfigService } from "@nestjs/config";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller";
+import { JwtStrategy } from "./jwt.strategy";
+import { GithubOAuthStrategy } from "./github.strategy";
+import { FortyTwoOAuthStrategy } from "./fortytwo.strategy";
+import { UserModule } from "../user/user.module";
@Module({
- providers: [AuthService],
+ imports: [
+ ConfigModule,
+ UserModule,
+ PassportModule.register({ session: false }),
+ JwtModule.registerAsync({
+ imports: [ConfigModule],
+ inject: [ConfigService],
+ useFactory: (config: ConfigService) => ({
+ secret: config.getOrThrow("JWT_SECRET"),
+ signOptions: {
+ expiresIn: "30d",
+ },
+ }),
+ }),
+ ],
+ providers: [
+ AuthService,
+ JwtStrategy,
+ GithubOAuthStrategy,
+ FortyTwoOAuthStrategy,
+ ],
controllers: [AuthController],
+ exports: [PassportModule, JwtModule],
})
export class AuthModule {}
diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts
index 58b57505..63097206 100644
--- a/api/src/auth/auth.service.ts
+++ b/api/src/auth/auth.service.ts
@@ -1,4 +1,17 @@
import { Injectable } from "@nestjs/common";
+import { JwtService } from "@nestjs/jwt";
+import { UserEntity } from "../user/entities/user.entity";
@Injectable()
-export class AuthService {}
+export class AuthService {
+ constructor(private jwt: JwtService) {}
+
+ signToken(user: UserEntity) {
+ const payload = {
+ sub: user.id,
+ email: user.email,
+ username: user.username,
+ };
+ return this.jwt.sign(payload);
+ }
+}
diff --git a/api/src/auth/fortytwo.strategy.ts b/api/src/auth/fortytwo.strategy.ts
new file mode 100644
index 00000000..76bbc58d
--- /dev/null
+++ b/api/src/auth/fortytwo.strategy.ts
@@ -0,0 +1,64 @@
+import { Injectable } from "@nestjs/common";
+import { PassportStrategy } from "@nestjs/passport";
+import { ConfigService } from "@nestjs/config";
+import { UserService } from "../user/user.service";
+import { SocialAccountService } from "../user/social-account.service";
+import { Strategy } from "passport-oauth2";
+
+interface FortyTwoProfile {
+ id: number;
+ login: string;
+ email: string | null;
+ image_url: string | null;
+ displayname: string | null;
+}
+
+@Injectable()
+export class FortyTwoOAuthStrategy extends PassportStrategy(Strategy, "42") {
+ constructor(
+ config: ConfigService,
+ private readonly users: UserService,
+ private readonly socialAccounts: SocialAccountService,
+ ) {
+ super({
+ authorizationURL: "https://api.intra.42.fr/oauth/authorize",
+ tokenURL: "https://api.intra.42.fr/oauth/token",
+ clientID: config.getOrThrow("FORTYTWO_CLIENT_ID"),
+ clientSecret: config.getOrThrow("FORTYTWO_CLIENT_SECRET"),
+ callbackURL: config.getOrThrow("FORTYTWO_CALLBACK_URL"),
+ });
+ }
+
+ async validate(
+ accessToken: string,
+ refreshToken: string,
+ _params: any,
+ done: (err: any, user?: any) => void,
+ ) {
+ try {
+ const res = await fetch("https://api.intra.42.fr/v2/me", {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+
+ if (!res.ok) {
+ console.error("42 API error:", res.status, await res.text());
+ return done(new Error(`42 API error: ${res.status}`));
+ }
+ const data = (await res.json()) as FortyTwoProfile;
+
+ const platformUserId = String(data.id);
+ const username = data.login;
+ const email = data.email ?? undefined;
+
+ done(null, {
+ fortyTwoAccount: {
+ platformUserId,
+ username,
+ email,
+ },
+ });
+ } catch (err) {
+ done(err, undefined);
+ }
+ }
+}
diff --git a/api/src/auth/github.strategy.ts b/api/src/auth/github.strategy.ts
new file mode 100644
index 00000000..30531c3c
--- /dev/null
+++ b/api/src/auth/github.strategy.ts
@@ -0,0 +1,68 @@
+import { Injectable } from "@nestjs/common";
+import { PassportStrategy } from "@nestjs/passport";
+import { Strategy as GitHubStrategy, Profile } from "passport-github2";
+import { ConfigService } from "@nestjs/config";
+import { UserService } from "../user/user.service";
+
+@Injectable()
+export class GithubOAuthStrategy extends PassportStrategy(
+ GitHubStrategy,
+ "github",
+) {
+ constructor(
+ config: ConfigService,
+ private users: UserService,
+ ) {
+ super({
+ clientID: config.getOrThrow("GITHUB_CLIENT_ID"),
+ clientSecret: config.getOrThrow("GITHUB_CLIENT_SECRET"),
+ callbackURL: config.getOrThrow("GITHUB_CALLBACK_URL"),
+ scope: ["user:email"],
+ });
+ }
+
+ async validate(
+ accessToken: string,
+ refreshToken: string,
+ profile: Profile,
+ done: (err: any, user?: any) => void,
+ ) {
+ try {
+ const githubId = profile.id;
+ const email = profile.emails && profile.emails[0]?.value;
+ const username =
+ profile.username || profile.displayName || email || githubId;
+ const name = profile.displayName || username;
+ const profilePicture = profile.photos && profile.photos[0]?.value;
+
+ let user = await this.users.getUserByGithubId(githubId);
+
+ if (!user) {
+ user = await this.users.createUser(
+ email || `${githubId}@users.noreply.github.com`,
+ username,
+ name,
+ profilePicture,
+ githubId,
+ accessToken,
+ false,
+ );
+ } else {
+ await this.users.updateUser(
+ user.id,
+ email || user.email,
+ username || user.username,
+ name || user.name,
+ profilePicture || user.profilePicture,
+ githubId,
+ accessToken,
+ );
+ user = await this.users.getUserById(user.id);
+ }
+
+ done(null, user);
+ } catch (err) {
+ done(err, undefined);
+ }
+ }
+}
diff --git a/api/src/auth/jwt-auth.guard.ts b/api/src/auth/jwt-auth.guard.ts
new file mode 100644
index 00000000..2e81dba6
--- /dev/null
+++ b/api/src/auth/jwt-auth.guard.ts
@@ -0,0 +1,5 @@
+import { Injectable } from "@nestjs/common";
+import { AuthGuard } from "@nestjs/passport";
+
+@Injectable()
+export class JwtAuthGuard extends AuthGuard("jwt") {}
diff --git a/api/src/auth/jwt.strategy.ts b/api/src/auth/jwt.strategy.ts
new file mode 100644
index 00000000..250dd2ba
--- /dev/null
+++ b/api/src/auth/jwt.strategy.ts
@@ -0,0 +1,34 @@
+import { Injectable, UnauthorizedException } from "@nestjs/common";
+import { PassportStrategy } from "@nestjs/passport";
+import { ExtractJwt, Strategy } from "passport-jwt";
+import { ConfigService } from "@nestjs/config";
+import { UserService } from "../user/user.service";
+
+export interface JwtPayload {
+ sub: string; // user id
+ email?: string;
+ username?: string;
+}
+
+@Injectable()
+export class JwtStrategy extends PassportStrategy(Strategy) {
+ constructor(
+ private config: ConfigService,
+ private users: UserService,
+ ) {
+ super({
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
+ ignoreExpiration: false,
+ secretOrKey: config.getOrThrow("JWT_SECRET"),
+ });
+ }
+
+ async validate(payload: JwtPayload) {
+ const userId = payload.sub;
+ if (!userId) throw new UnauthorizedException();
+ // Load current user; if missing, reject token
+ const user = await this.users.getUserById(userId).catch(() => null);
+ if (!user) throw new UnauthorizedException();
+ return user; // attaches to request.user
+ }
+}
diff --git a/api/src/event/event.controller.ts b/api/src/event/event.controller.ts
index 8f56ffe7..3ae01cfd 100644
--- a/api/src/event/event.controller.ts
+++ b/api/src/event/event.controller.ts
@@ -15,7 +15,8 @@ import { TeamService } from "../team/team.service";
import { UserService } from "../user/user.service";
import { CreateEventDto } from "./dtos/createEventDto";
import { SetLockTeamsDateDto } from "./dtos/setLockTeamsDateDto";
-import { UserGuard, UserId } from "../guards/UserGuard";
+import { UserId } from "../guards/UserGuard";
+import { JwtAuthGuard } from "../auth/jwt-auth.guard";
@Controller("event")
export class EventController {
@@ -25,7 +26,7 @@ export class EventController {
private readonly userService: UserService,
) {}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Get("my")
async getMyEvents(@UserId() userId: string) {
return this.eventService.getEventsForUser(userId);
@@ -51,7 +52,7 @@ export class EventController {
return await this.eventService.getCurrentLiveEvent();
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Post()
createEvent(
@UserId() userId: string,
@@ -94,7 +95,7 @@ export class EventController {
return this.userService.getUserCountOfEvent(eventId);
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Get(":id/isUserRegistered")
getEventByUserId(
@Param("id", new ParseUUIDPipe()) eventId: string,
@@ -103,7 +104,7 @@ export class EventController {
return this.eventService.isUserRegisteredForEvent(eventId, userId);
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Get(":id/isEventAdmin")
isEventAdmin(
@Param("id", new ParseUUIDPipe()) eventId: string,
@@ -112,7 +113,7 @@ export class EventController {
return this.eventService.isEventAdmin(eventId, userId);
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Put(":id/join")
async joinEvent(
@Param("id", new ParseUUIDPipe()) eventId: string,
@@ -136,7 +137,7 @@ export class EventController {
return this.userService.joinEvent(userId, eventId);
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Put(":id/lock")
async lockEvent(
@Param("id", new ParseUUIDPipe()) eventId: string,
@@ -150,7 +151,7 @@ export class EventController {
return this.eventService.lockEvent(eventId);
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Put(":id/lockTeamsDate")
async lockTeamsDate(
@Param("id", new ParseUUIDPipe()) eventId: string,
diff --git a/api/src/guards/GuardConstants.ts b/api/src/guards/GuardConstants.ts
index bf52bf6c..72e55cc9 100644
--- a/api/src/guards/GuardConstants.ts
+++ b/api/src/guards/GuardConstants.ts
@@ -1,3 +1,2 @@
-export const USER_ID_KEY = "userid";
export const EVENT_ID_PARAM = "eventId";
export const TEAM_ID_PARAM = "teamId";
diff --git a/api/src/guards/TeamGuard.ts b/api/src/guards/TeamGuard.ts
index 01e40656..1db0d5d5 100644
--- a/api/src/guards/TeamGuard.ts
+++ b/api/src/guards/TeamGuard.ts
@@ -9,7 +9,7 @@ import {
ParseUUIDPipe,
} from "@nestjs/common";
import { TeamService } from "../team/team.service";
-import { EVENT_ID_PARAM, TEAM_ID_PARAM, USER_ID_KEY } from "./GuardConstants";
+import { EVENT_ID_PARAM, TEAM_ID_PARAM } from "./GuardConstants";
import { TeamEntity } from "../team/entities/team.entity";
export const TeamId = Param(TEAM_ID_PARAM, new ParseUUIDPipe());
@@ -40,7 +40,7 @@ export class MyTeamGuards implements CanActivate {
async canActivate(context: ExecutionContext): Promise {
const request = context.switchToHttp().getRequest();
const eventId: string | undefined = request.params[EVENT_ID_PARAM];
- const userId: string | undefined = request.headers[USER_ID_KEY];
+ const userId: string | undefined = request.user?.id;
if (!eventId || !userId) return false;
diff --git a/api/src/guards/UserGuard.ts b/api/src/guards/UserGuard.ts
index 04542ad2..00ea4d20 100644
--- a/api/src/guards/UserGuard.ts
+++ b/api/src/guards/UserGuard.ts
@@ -1,42 +1,8 @@
-import {
- CanActivate,
- createParamDecorator,
- ExecutionContext,
- Injectable,
- UnauthorizedException,
-} from "@nestjs/common";
-import { Observable } from "rxjs";
-import { ConfigService } from "@nestjs/config";
-import { USER_ID_KEY } from "./GuardConstants";
+import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export const UserId = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
- return request.headers[USER_ID_KEY];
+ return request.user?.id;
},
);
-
-@Injectable()
-export class UserGuard implements CanActivate {
- FRONTEND_SECRET: string;
-
- constructor(config: ConfigService) {
- this.FRONTEND_SECRET = config.getOrThrow("FRONTEND_SECRET");
- }
-
- canActivate(
- context: ExecutionContext,
- ): boolean | Promise | Observable {
- const request = context.switchToHttp().getRequest();
-
- const authorization = request.headers.authorization;
- if (authorization !== this.FRONTEND_SECRET)
- throw new UnauthorizedException();
-
- const userId = request.headers[USER_ID_KEY];
- if (!userId) throw new UnauthorizedException("User ID is required");
- request.userId = userId;
-
- return true;
- }
-}
diff --git a/api/src/main.ts b/api/src/main.ts
index a7bd8d36..99ec37e3 100644
--- a/api/src/main.ts
+++ b/api/src/main.ts
@@ -26,12 +26,17 @@ export const getRabbitmqConfig: any = (
async function bootstrap() {
const app = await NestFactory.create(AppModule);
+ const configService = app.get(ConfigService);
app.useGlobalPipes(new ValidationPipe());
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
app.useGlobalFilters(new TypeormExceptionFilter());
- app.enableCors();
-
- const configService = app.get(ConfigService);
+ app.enableCors({
+ origin: configService.getOrThrow("CORS_ORIGIN"),
+ methods: "GET,HEAD,PUT,PATCH,POST,DELETE",
+ credentials: true,
+ allowedHeaders:
+ "Content-Type, Accept, Authorization, X-Requested-With, X-HTTP-Method-Override, X-Auth-Token, X-Refresh-Token",
+ });
app.connectMicroservice(
getRabbitmqConfig(configService, "game_results"),
diff --git a/api/src/match/match.controller.ts b/api/src/match/match.controller.ts
index 98ac8971..e4b11be5 100644
--- a/api/src/match/match.controller.ts
+++ b/api/src/match/match.controller.ts
@@ -13,8 +13,9 @@ import {
import { MatchService } from "./match.service";
import { EventService } from "../event/event.service";
import { EventState } from "../event/entities/event.entity";
-import { UserGuard, UserId } from "../guards/UserGuard";
+import { UserId } from "../guards/UserGuard";
import { MatchEntity } from "./entites/match.entity";
+import { JwtAuthGuard } from "../auth/jwt-auth.guard";
@Controller("match")
export class MatchController {
@@ -25,7 +26,7 @@ export class MatchController {
private logger = new Logger("MatchController");
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Get("swiss/:eventId")
getSwissMatches(
@Param("eventId", ParseUUIDPipe) eventId: string,
@@ -39,7 +40,7 @@ export class MatchController {
);
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Put("swiss/:eventId")
async startSwissMatches(
@Param("eventId", ParseUUIDPipe) eventId: string,
@@ -57,7 +58,7 @@ export class MatchController {
return await this.matchService.createNextSwissMatches(eventId);
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Put("tournament/:eventId")
async startTournamentMatches(
@Param("eventId", ParseUUIDPipe) eventId: string,
@@ -75,7 +76,7 @@ export class MatchController {
return this.matchService.getTournamentTeamCount(eventId);
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Get("tournament/:eventId")
getTournamentMatches(
@Param("eventId", ParseUUIDPipe) eventId: string,
@@ -89,7 +90,7 @@ export class MatchController {
);
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Get("queue/:eventId")
async getQueueMatches(
@Param("eventId", ParseUUIDPipe) eventId: string,
@@ -98,7 +99,7 @@ export class MatchController {
return await this.matchService.getQueueMatches(eventId, userId);
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Get("queue/:eventId/admin")
async getAllQueueMatches(
@Param("eventId", ParseUUIDPipe) eventId: string,
@@ -111,7 +112,7 @@ export class MatchController {
return await this.matchService.getAllQueueMatches(eventId);
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Get("logs/:matchId")
async getMatchLogs(
@Param("matchId", ParseUUIDPipe) matchId: string,
@@ -125,7 +126,7 @@ export class MatchController {
return logs;
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Put("reveal/:matchId")
async revealMatch(
@Param("matchId", ParseUUIDPipe) matchId: string,
@@ -155,7 +156,7 @@ export class MatchController {
return await this.matchService.getMatchById(matchId);
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Get("queue/:eventId/timeseries")
async getQueueMatchesTimeSeries(
@Param("eventId") eventId: string,
diff --git a/api/src/team/team.controller.ts b/api/src/team/team.controller.ts
index 430945ed..b91ad9c3 100644
--- a/api/src/team/team.controller.ts
+++ b/api/src/team/team.controller.ts
@@ -17,7 +17,6 @@ import { CreateTeamDto } from "./dtos/createTeamDto";
import { InviteUserDto } from "./dtos/inviteUserDto";
import { UserService } from "../user/user.service";
import { EventService } from "../event/event.service";
-import { UserGuard } from "../guards/UserGuard";
import { PermissionRole } from "../user/entities/user.entity";
import {
EventId,
@@ -28,6 +27,7 @@ import {
} from "../guards/TeamGuard";
import { EVENT_ID_PARAM, TEAM_ID_PARAM } from "../guards/GuardConstants";
import { TeamEntity } from "./entities/team.entity";
+import { JwtAuthGuard } from "../auth/jwt-auth.guard";
@Controller("team")
export class TeamController {
@@ -57,13 +57,13 @@ export class TeamController {
);
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Get(`event/:${EVENT_ID_PARAM}/my`)
getMyTeamForEvent(@EventId eventId: string, @UserId("id") userId: string) {
return this.teamService.getTeamOfUserForEvent(eventId, userId);
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Post(`event/:${EVENT_ID_PARAM}/create`)
async createTeam(
@UserId() userId: string,
@@ -87,7 +87,7 @@ export class TeamController {
return this.teamService.createTeam(createTeamDto.name, userId, eventId);
}
- @UseGuards(UserGuard, MyTeamGuards, TeamNotLockedGuard)
+ @UseGuards(JwtAuthGuard, MyTeamGuards, TeamNotLockedGuard)
@Put(`event/:${EVENT_ID_PARAM}/leave`)
async leaveTeam(@UserId() userId: string, @Team() team: TeamEntity) {
return this.teamService.leaveTeam(team.id, userId);
@@ -123,7 +123,7 @@ export class TeamController {
});
}
- @UseGuards(UserGuard, MyTeamGuards, TeamNotLockedGuard)
+ @UseGuards(JwtAuthGuard, MyTeamGuards, TeamNotLockedGuard)
@Post(`event/:${EVENT_ID_PARAM}/sendInvite`)
async sendInviteToTeam(
@EventId eventId: string,
@@ -156,7 +156,7 @@ export class TeamController {
);
}
- @UseGuards(UserGuard, MyTeamGuards, TeamNotLockedGuard)
+ @UseGuards(JwtAuthGuard, MyTeamGuards, TeamNotLockedGuard)
@Get(`event/:${EVENT_ID_PARAM}/searchInviteUsers/:searchQuery`)
async searchUsersForInvite(
@EventId eventId: string,
@@ -166,7 +166,7 @@ export class TeamController {
return this.userService.searchUsersForInvite(eventId, searchQuery, team.id);
}
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Get(`event/:${EVENT_ID_PARAM}/pending`)
async getUserPendingInvites(
@UserId() userId: string,
@@ -175,7 +175,7 @@ export class TeamController {
return this.teamService.getTeamsUserIsInvitedTo(userId, eventId);
}
- @UseGuards(UserGuard, TeamNotLockedGuard)
+ @UseGuards(JwtAuthGuard, TeamNotLockedGuard)
@Put(`event/:${EVENT_ID_PARAM}/acceptInvite/:${TEAM_ID_PARAM}`)
async acceptTeamInvite(
@UserId() userId: string,
@@ -193,7 +193,7 @@ export class TeamController {
}
// TODO: eventId is not used here, should be removed
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Delete(`event/:${EVENT_ID_PARAM}/declineInvite/:${TEAM_ID_PARAM}`)
async declineTeamInvite(@UserId() userId: string, @TeamId teamId: string) {
if (!(await this.teamService.isUserInvitedToTeam(userId, teamId)))
@@ -202,7 +202,7 @@ export class TeamController {
return this.teamService.declineTeamInvite(userId, teamId);
}
- @UseGuards(UserGuard, MyTeamGuards, TeamNotLockedGuard)
+ @UseGuards(JwtAuthGuard, MyTeamGuards, TeamNotLockedGuard)
@Put(`event/:${EVENT_ID_PARAM}/queue/join`)
async joinQueue(@Team() team: TeamEntity) {
if (team.inQueue)
@@ -211,7 +211,7 @@ export class TeamController {
return this.teamService.joinQueue(team.id);
}
- @UseGuards(UserGuard, MyTeamGuards)
+ @UseGuards(JwtAuthGuard, MyTeamGuards)
@Get(`event/:${EVENT_ID_PARAM}/queue/state`)
async getQueueState(@Team() team: TeamEntity) {
return this.teamService.getQueueState(team.id);
diff --git a/api/src/types/passport-oauth2.d.ts b/api/src/types/passport-oauth2.d.ts
new file mode 100644
index 00000000..a1a5892f
--- /dev/null
+++ b/api/src/types/passport-oauth2.d.ts
@@ -0,0 +1,15 @@
+declare module "passport-oauth2" {
+ import { Strategy as PassportStrategy } from "passport";
+
+ export interface StrategyOptions {
+ authorizationURL: string;
+ tokenURL: string;
+ clientID: string;
+ clientSecret: string;
+ callbackURL: string;
+ }
+
+ export class Strategy extends PassportStrategy {
+ constructor(options: StrategyOptions, verify?: any);
+ }
+}
diff --git a/api/src/user/social-account.controller.ts b/api/src/user/social-account.controller.ts
index fa847319..95bdbb35 100644
--- a/api/src/user/social-account.controller.ts
+++ b/api/src/user/social-account.controller.ts
@@ -15,7 +15,8 @@ import {
SocialAccountEntity,
SocialPlatform,
} from "./entities/social-account.entity";
-import { UserGuard, UserId } from "../guards/UserGuard";
+import { UserId } from "../guards/UserGuard";
+import { JwtAuthGuard } from "../auth/jwt-auth.guard";
class LinkSocialAccountDto {
platform: SocialPlatform;
@@ -23,34 +24,12 @@ class LinkSocialAccountDto {
platformUserId: string;
}
-@UseGuards(UserGuard)
+@UseGuards(JwtAuthGuard)
@ApiTags("social-accounts")
@Controller("social-accounts")
export class SocialAccountController {
constructor(private readonly socialAccountService: SocialAccountService) {}
- @Post("link")
- @ApiOperation({ summary: "Link a social account to the authenticated user" })
- @ApiResponse({
- status: 200,
- description: "Social account linked successfully",
- })
- @ApiResponse({
- status: 409,
- description: "Social account already linked to another user",
- })
- async linkSocialAccount(
- @UserId() userId: string,
- @Body() linkDto: LinkSocialAccountDto,
- ): Promise {
- return await this.socialAccountService.linkSocialAccount(
- userId,
- linkDto.platform,
- linkDto.username,
- linkDto.platformUserId,
- );
- }
-
@Delete(":platform")
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
diff --git a/api/src/user/social-account.service.ts b/api/src/user/social-account.service.ts
index 25b65fec..37313e62 100644
--- a/api/src/user/social-account.service.ts
+++ b/api/src/user/social-account.service.ts
@@ -1,6 +1,5 @@
import {
Injectable,
- ConflictException,
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
@@ -20,52 +19,6 @@ export class SocialAccountService {
private userRepository: Repository,
) {}
- async linkSocialAccount(
- userId: string,
- platform: SocialPlatform,
- username: string,
- platformUserId: string,
- ): Promise {
- // Check if user exists
- const user = await this.userRepository.findOne({ where: { id: userId } });
- if (!user) {
- throw new NotFoundException("User not found");
- }
-
- // Check if this platform account is already linked to another user
- const existingAccount = await this.socialAccountRepository.findOne({
- where: { platform, platformUserId },
- });
-
- if (existingAccount && existingAccount.userId !== userId) {
- throw new ConflictException(
- "This social account is already linked to another user",
- );
- }
-
- // Check if user already has an account for this platform
- const existingUserAccount = await this.socialAccountRepository.findOne({
- where: { userId, platform },
- });
-
- if (existingUserAccount) {
- // Update existing account
- existingUserAccount.username = username;
- existingUserAccount.platformUserId = platformUserId;
- return await this.socialAccountRepository.save(existingUserAccount);
- }
-
- // Create new social account link
- const socialAccount = this.socialAccountRepository.create({
- userId,
- platform,
- username,
- platformUserId,
- });
-
- return await this.socialAccountRepository.save(socialAccount);
- }
-
async unlinkSocialAccount(
userId: string,
platform: SocialPlatform,
@@ -95,4 +48,29 @@ export class SocialAccountService {
where: { userId, platform },
});
}
+
+ async upsertSocialAccountForUser(params: {
+ userId: string;
+ platform: SocialPlatform;
+ username: string;
+ platformUserId: string;
+ }): Promise {
+ const existing = await this.socialAccountRepository.findOne({
+ where: { userId: params.userId, platform: params.platform },
+ });
+
+ if (existing) {
+ existing.username = params.username;
+ existing.platformUserId = params.platformUserId;
+ return this.socialAccountRepository.save(existing);
+ }
+
+ const entity = this.socialAccountRepository.create({
+ userId: params.userId,
+ platform: params.platform,
+ username: params.username,
+ platformUserId: params.platformUserId,
+ });
+ return this.socialAccountRepository.save(entity);
+ }
}
diff --git a/api/src/user/user.controller.ts b/api/src/user/user.controller.ts
index 97b05c02..13ec89fd 100644
--- a/api/src/user/user.controller.ts
+++ b/api/src/user/user.controller.ts
@@ -1,50 +1,18 @@
import {
- Body,
Controller,
Get,
Param,
- ParseUUIDPipe,
- Post,
- Put,
UseGuards,
} from "@nestjs/common";
import { UserService } from "./user.service";
-import { CreateUserDto } from "./dtos/user.dto";
-import { UserGuard, UserId } from "../guards/UserGuard";
+import { UserId } from "../guards/UserGuard";
+import { JwtAuthGuard } from "../auth/jwt-auth.guard";
@Controller("user")
export class UserController {
constructor(private readonly userService: UserService) {}
- @Post()
- async createUser(@Body() user: CreateUserDto) {
- return this.userService.createUser(
- user.email,
- user.username,
- user.name,
- user.profilePicture,
- user.githubId,
- user.githubAccessToken,
- );
- }
-
- @Put(":id")
- async updateUser(
- @Body() user: CreateUserDto,
- @Param("id", new ParseUUIDPipe()) id: string,
- ) {
- return this.userService.updateUser(
- id,
- user.email,
- user.username,
- user.name,
- user.profilePicture,
- user.githubId,
- user.githubAccessToken,
- );
- }
-
- @UseGuards(UserGuard)
+ @UseGuards(JwtAuthGuard)
@Get("canCreateEvent")
async canCreateEvent(@UserId() id: string) {
return this.userService.canCreateEvent(id);
diff --git a/frontend/.env.example b/frontend/.env.example
index 156a54a4..0419ea47 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -1,17 +1,8 @@
# Authentication Settings
-# GitHub Authentication (Required for website sign-ins)
-# These are used for user login with NextAuth.js
-CLIENT_ID_GITHUB=xxx
-CLIENT_SECRET_GITHUB=xxx
NEXTAUTH_SECRET=ci5u439cnu59843uc53894
NEXTAUTH_URL=http://localhost:3000
-# 42 Intra Authentication (Optional for testing 42 account linking)
-# Only needed if you want to enable 42 school login
-NEXT_PUBLIC_FORTY_TWO_CLIENT_ID=xx
-FORTY_TWO_CLIENT_SECRET=xxx
-
# Backend Connection
diff --git a/frontend/README.md b/frontend/README.md
index 263a416f..d4d00ae5 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -44,21 +44,3 @@ brew install pnpm
- **Build:** `pnpm build` (auto-updates wiki and changelog)
- **Start:** `pnpm start`
-
-## Environment Variables
-
-### GitHub OAuth (Required)
-
-1. Go to GitHub → Settings → Developer settings → OAuth Apps
-2. Register a new OAuth app
-3. Set Authorization callback URL: `http://localhost:3000/api/auth/callback`
-4. Add Client ID and Secret to `.env.local`
-
-### 42 School OAuth (Optional)
-
-For testing account linking functionality:
-
-1. Go to 42 School → Settings → API → Applications
-2. Create a new application
-3. Set Redirect URI: `http://localhost:3000/auth/callback/42`
-4. Add Client ID and Client Secret to `.env.local`
diff --git a/frontend/app/about/aboutPage.tsx b/frontend/app/about/aboutPage.tsx
index c3a01b56..73fea7b5 100644
--- a/frontend/app/about/aboutPage.tsx
+++ b/frontend/app/about/aboutPage.tsx
@@ -34,9 +34,9 @@ const team: TeamMember[] = [
},
{
name: "Emil Ebert",
- role: "Website",
+ role: "Website System",
imgSrc: "/team/eebert.png",
- linkUrl: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
+ linkUrl: "https://www.linkedin.com/in/emil-ebert/",
linkType: "linkedin",
},
{
diff --git a/frontend/app/actions/axios.ts b/frontend/app/actions/axios.ts
index a34cbfd7..d1582ed5 100644
--- a/frontend/app/actions/axios.ts
+++ b/frontend/app/actions/axios.ts
@@ -1,33 +1,46 @@
import type { AxiosResponse } from "axios";
import type { ServerActionResponse } from "@/app/actions/errors";
import axios from "axios";
-import { getServerSession } from "next-auth/next";
-import { authOptions } from "@/app/utils/authOptions";
const axiosInstance = axios.create({
- baseURL: process.env.BACKEND_URL,
+ baseURL:
+ process.env.NEXT_PUBLIC_BACKEND_PUBLIC_URL || process.env.BACKEND_URL,
headers: {
"Content-Type": "application/json",
- "Authorization": `${process.env.BACKEND_SECRET}`,
},
});
+function getTokenFromCookieString(cookieString: string): string | null {
+ const parts = cookieString.split(";");
+ for (const part of parts) {
+ const [k, ...rest] = part.trim().split("=");
+ if (k === "token")
+ return decodeURIComponent(rest.join("="));
+ }
+ return null;
+}
+
axiosInstance.interceptors.request.use(
async (config) => {
- if (!process.env.BACKEND_SECRET) {
- config.baseURL = process.env.NEXT_PUBLIC_BACKEND_PUBLIC_URL;
+ if (process.env.BACKEND_URL) {
+ // eslint-disable-next-line ts/no-require-imports
+ const cookieData = await require("next/headers").cookies();
+ const token = cookieData.get("token");
+ if (token)
+ config.headers.Authorization = `Bearer ${token.value}`;
+
+ config.baseURL = process.env.BACKEND_URL;
return config;
}
- if (config.url && config.url.startsWith("user/email/")) {
- return config;
+ config.baseURL = process.env.NEXT_PUBLIC_BACKEND_PUBLIC_URL;
+ const token = getTokenFromCookieString(document.cookie || "");
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`;
}
- const session = await getServerSession(authOptions);
- config.headers.userId = session?.user?.id || "";
return config;
},
(error) => {
- // Handle request errors
return Promise.reject(error);
},
);
diff --git a/frontend/app/actions/social-accounts.ts b/frontend/app/actions/social-accounts.ts
index a5ae1411..f24d9270 100644
--- a/frontend/app/actions/social-accounts.ts
+++ b/frontend/app/actions/social-accounts.ts
@@ -60,3 +60,16 @@ export async function getSocialAccountByPlatform(
);
}
}
+
+export async function getFortyTwoAuthUrl(): Promise {
+ try {
+ const response = await axiosInstance.get("/auth/42/getUrl");
+ return response.data;
+ }
+ catch (error: any) {
+ console.error("Error fetching 42 auth URL:", error);
+ throw new Error(
+ error.response?.data?.message || "Failed to fetch 42 auth URL",
+ );
+ }
+}
diff --git a/frontend/app/auth/sso/page.tsx b/frontend/app/auth/sso/page.tsx
new file mode 100644
index 00000000..e86b653f
--- /dev/null
+++ b/frontend/app/auth/sso/page.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { signIn } from "next-auth/react";
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+
+export default function SsoPage() {
+ const router = useRouter();
+ useEffect(() => {
+ let cancelled = false;
+ (async () => {
+ try {
+ await signIn("backend", { redirect: false });
+ if (!cancelled) {
+ router.replace("/");
+ router.refresh();
+ }
+ }
+ catch (e) {
+ console.error("Failed to finalize SSO:", e);
+ if (!cancelled)
+ router.replace("/auth/error");
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [router]);
+ return (
+
+
+
+
Finishing sign-in...
+
+
+ );
+}
diff --git a/frontend/app/events/[id]/dashboard/dashboard.tsx b/frontend/app/events/[id]/dashboard/dashboard.tsx
index 5cb57c64..a547d119 100644
--- a/frontend/app/events/[id]/dashboard/dashboard.tsx
+++ b/frontend/app/events/[id]/dashboard/dashboard.tsx
@@ -221,9 +221,11 @@ export function DashboardPage({ eventId }: DashboardPageProps) {
setStartingGroupPhase(true);
startSwissMatches(eventId)
.then(() => {
+ // eslint-disable-next-line no-alert
alert("started group phase");
})
.catch(() => {
+ // eslint-disable-next-line no-alert
alert("error occurred");
setStartingGroupPhase(false);
})
@@ -243,9 +245,11 @@ export function DashboardPage({ eventId }: DashboardPageProps) {
setStartingTournament(true);
startTournamentMatches(eventId)
.then(() => {
+ // eslint-disable-next-line no-alert
alert("started tournament phase");
})
.catch(() => {
+ // eslint-disable-next-line no-alert
alert("error occurred");
setStartingTournament(false);
})
@@ -274,6 +278,7 @@ export function DashboardPage({ eventId }: DashboardPageProps) {
eventId,
new Date(teamAutoLockTime).getTime(),
).then(() => {
+ // eslint-disable-next-line no-alert
alert("set team auto lock date");
})}
>
@@ -283,6 +288,7 @@ export function DashboardPage({ eventId }: DashboardPageProps) {
variant="secondary"
onClick={() => {
setEventTeamsLockDate(eventId, null).then(() => {
+ // eslint-disable-next-line no-alert
alert("reset team auto lock date");
setTeamAutoLockTime("");
});
diff --git a/frontend/app/events/page.tsx b/frontend/app/events/page.tsx
index 298e7797..74203e5d 100644
--- a/frontend/app/events/page.tsx
+++ b/frontend/app/events/page.tsx
@@ -9,7 +9,6 @@ import {
import EventsTabs from "@/app/events/EventsTabs";
import { authOptions } from "@/app/utils/authOptions";
import { title } from "@/components/primitives";
-import { Button } from "@/components/ui/button";
export const metadata: Metadata = {
title: "Events",
@@ -50,8 +49,8 @@ export default async function EventsPage() {
Discover and join upcoming coding competitions
{canCreate && (
-
-
+
+ Create Event
)}
diff --git a/frontend/app/profile/ProfileClient.tsx b/frontend/app/profile/ProfileClient.tsx
index bf375bb3..f914c561 100644
--- a/frontend/app/profile/ProfileClient.tsx
+++ b/frontend/app/profile/ProfileClient.tsx
@@ -47,7 +47,7 @@ function ProfileContent() {
name={session!.user?.name}
description={session!.user?.email}
avatarProps={{
- src: session!.user?.image || "/placeholder-avatar.png",
+ src: session!.user?.profilePicture || "/placeholder-avatar.png",
size: "lg",
isBordered: true,
}}
diff --git a/frontend/app/utils/authOptions.ts b/frontend/app/utils/authOptions.ts
index 2cd56aa3..f7339a7a 100644
--- a/frontend/app/utils/authOptions.ts
+++ b/frontend/app/utils/authOptions.ts
@@ -1,73 +1,76 @@
import type { NextAuthOptions } from "next-auth";
-import GithubProvider from "next-auth/providers/github";
+import CredentialsProvider from "next-auth/providers/credentials";
import axiosInstance from "@/app/actions/axios";
+const BACKEND_BASE_URL
+ = process.env.BACKEND_URL || process.env.NEXT_PUBLIC_BACKEND_PUBLIC_URL;
+
export const authOptions: NextAuthOptions = {
+ session: {
+ strategy: "jwt",
+ },
providers: [
- GithubProvider({
- clientId: process.env.CLIENT_ID_GITHUB!,
- clientSecret: process.env.CLIENT_SECRET_GITHUB!,
- authorization: {
- params: {
- scope: "read:user user:email repo:invite",
- },
- },
- }),
- ],
- callbacks: {
- async signIn({ user, account, profile }) {
- if (account?.provider === "github") {
- const githubProfile = profile as any;
-
+ CredentialsProvider({
+ id: "backend",
+ name: "Backend",
+ credentials: {},
+ async authorize(_credentials, _) {
try {
- const existingUser = (
- await axiosInstance.get(`user/github/${account.providerAccountId}`)
- ).data;
- if (!existingUser) {
- if (!account?.access_token) {
- throw new Error("No access token found");
- }
-
- await axiosInstance.post(`user/`, {
- email: user.email!,
- username: githubProfile?.login || user.name!,
- name: user.name! || githubProfile?.name,
- profilePicture: user.image! || githubProfile?.avatar_url,
- githubId: account.providerAccountId,
- githubAccessToken: account.access_token,
- });
- }
- else {
- await axiosInstance.put(`user/${existingUser.id}`, {
- email: user.email!,
- username: githubProfile?.login || existingUser.username,
- name: githubProfile?.name || existingUser.name,
- profilePicture:
- githubProfile?.avatar_url || existingUser.profilePicture,
- githubId: account.providerAccountId,
- githubAccessToken: account.access_token,
- });
+ if (!BACKEND_BASE_URL) {
+ console.error("Missing BACKEND URL env");
+ return null;
}
+
+ const res = await axiosInstance.get<{
+ id: string;
+ username: string;
+ email: string;
+ profilePicture: string;
+ }>(`/auth/me`);
+
+ return {
+ id: res.data.id,
+ name: res.data.username,
+ email: res.data.email,
+ profilePicture: res.data.profilePicture,
+ };
}
- catch (e: any) {
- console.error("Error during sign in:", e);
- console.error("response:", e?.response);
- return false;
+ catch (e) {
+ console.error("Authorize failed:", e);
+ return null;
}
- }
-
+ },
+ }),
+ ],
+ callbacks: {
+ async signIn() {
return true;
},
- async session({ session }) {
- if (!session.user?.email) {
- throw new Error("User email is not available in session");
+ async jwt({ token, user }) {
+ if (user) {
+ token.sub = (user as any).id || token.sub;
+ token.name = user.name || token.name;
+ token.email = user.email || token.email;
}
- const dbUser = (
- await axiosInstance.get(`user/email/${session.user?.email}`)
- ).data;
+ return token;
+ },
+ async session({ session }) {
+ try {
+ const res = await axiosInstance.get<{
+ id: string;
+ username: string;
+ email: string;
+ profilePicture: string;
+ }>(`/auth/me`);
- if (dbUser)
- session.user.id = dbUser.id;
+ session.user.id = res.data.id;
+ session.user.email = res.data.email;
+ session.user.name = res.data.username;
+ session.user.profilePicture = res.data.profilePicture;
+ }
+ catch {
+ session.user.id = "";
+ }
return session;
},
diff --git a/frontend/components/github.tsx b/frontend/components/github.tsx
index e9b9ee79..482dfb27 100644
--- a/frontend/components/github.tsx
+++ b/frontend/components/github.tsx
@@ -1,14 +1,15 @@
-import { signIn } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { GithubIcon } from "./icons";
export default function GithubLoginButton() {
async function githubLogin() {
try {
- await signIn("github");
+ const base
+ = process.env.NEXT_PUBLIC_BACKEND_PUBLIC_URL;
+ window.location.href = `${base?.replace(/\/$/, "")}/auth/github/callback`;
}
catch (error) {
- console.error("error while logging in:", error);
+ console.error("error while redirecting to login:", error);
}
}
diff --git a/frontend/components/social-accounts-display.tsx b/frontend/components/social-accounts-display.tsx
index 67d8398e..2f447f1e 100644
--- a/frontend/components/social-accounts-display.tsx
+++ b/frontend/components/social-accounts-display.tsx
@@ -71,6 +71,7 @@ export default function SocialAccountsDisplay() {
});
if (
!session?.user?.id
+ // eslint-disable-next-line no-alert
|| !confirm("Are you sure you want to unlink this account?")
) {
return;
@@ -85,6 +86,7 @@ export default function SocialAccountsDisplay() {
}
catch (error) {
console.error("Error unlinking account:", error);
+ // eslint-disable-next-line no-alert
alert("Failed to unlink account. Please try again.");
}
finally {
diff --git a/frontend/components/team/TeamInviteModal.tsx b/frontend/components/team/TeamInviteModal.tsx
index eb3a3546..60c54a7f 100644
--- a/frontend/components/team/TeamInviteModal.tsx
+++ b/frontend/components/team/TeamInviteModal.tsx
@@ -3,7 +3,7 @@ import type {
} from "@/app/actions/team";
import { usePlausible } from "next-plausible";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import {
searchUsersForInvite,
sendTeamInvite,
@@ -21,7 +21,6 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
-import { useEffect } from "react";
interface TeamInviteModalProps {
isOpen: boolean;
@@ -71,7 +70,7 @@ export function TeamInviteModal({
);
}
catch (error: any) {
- // You can customize this error message as needed
+ // eslint-disable-next-line no-alert
alert(
error?.response?.data?.message
|| error?.message
diff --git a/frontend/hooks/use42Linking.ts b/frontend/hooks/use42Linking.ts
index c64c1020..91b70704 100644
--- a/frontend/hooks/use42Linking.ts
+++ b/frontend/hooks/use42Linking.ts
@@ -1,10 +1,7 @@
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
-import {
- OAUTH_CONFIG,
- OAUTH_PROVIDERS,
- OAUTH_URLS,
-} from "@/lib/constants/oauth";
+import { getFortyTwoAuthUrl } from "@/app/actions/social-accounts";
+import { OAUTH_CONFIG } from "@/lib/constants/oauth";
/**
* Simplified hook for handling 42 School OAuth integration
@@ -43,48 +40,32 @@ export function use42Linking(onSuccess?: () => void): Use42LinkingReturn {
}, []);
const initiate42OAuth = useCallback(() => {
- // Clear any previous messages and show immediate loading feedback
setMessage(null);
setIsInitiating(true);
processedRef.current = null;
- // Check if environment variable is available
- const clientId = process.env.NEXT_PUBLIC_FORTY_TWO_CLIENT_ID;
- if (!clientId) {
- console.error("NEXT_PUBLIC_FORTY_TWO_CLIENT_ID is not set");
- setMessage({
- type: "error",
- text: "OAuth configuration is missing. Please contact support.",
- });
- setIsInitiating(false);
- return;
- }
-
// Small delay to show the loading state before redirect
- setTimeout(() => {
- // Generate a cryptographically secure random state string
- const array = new Uint8Array(OAUTH_CONFIG.STATE_LENGTH);
- window.crypto.getRandomValues(array);
- const state = Array.from(array, b =>
- b.toString(16).padStart(2, "0")).join("");
- const authUrl = new URL(OAUTH_URLS.FORTY_TWO_AUTHORIZE);
-
- authUrl.searchParams.set("client_id", clientId);
- authUrl.searchParams.set(
- "redirect_uri",
- `${window.location.origin}/auth/callback/${OAUTH_PROVIDERS.FORTY_TWO}`,
- );
- authUrl.searchParams.set("response_type", "code");
- authUrl.searchParams.set("scope", "public");
- authUrl.searchParams.set("state", state);
+ setTimeout(async () => {
+ try {
+ // Ask backend for the 42 auth URL (which includes the encrypted user id in state)
+ const authUrl = await getFortyTwoAuthUrl();
- // Store state in sessionStorage for verification
- sessionStorage.setItem(
- OAUTH_CONFIG.SESSION_STORAGE_KEYS.OAUTH_STATE,
- state,
- );
+ if (!authUrl) {
+ throw new Error("No auth URL returned from backend");
+ }
- window.location.href = authUrl.toString();
+ window.location.href = authUrl;
+ }
+ catch (err: any) {
+ console.error("Failed to initiate 42 OAuth via backend:", err);
+ setMessage({
+ type: "error",
+ text:
+ err?.message
+ || "Failed to start 42 authentication. Please try again later.",
+ });
+ setIsInitiating(false);
+ }
}, OAUTH_CONFIG.LOADING_DELAY);
}, []);
diff --git a/frontend/layouts/basic-navbar.tsx b/frontend/layouts/basic-navbar.tsx
index 32b94f35..7ba79289 100644
--- a/frontend/layouts/basic-navbar.tsx
+++ b/frontend/layouts/basic-navbar.tsx
@@ -190,7 +190,7 @@ export const Navbar = forwardRef(