From 9c706c2d8f94a380162330ab108f3f800022d6f8 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Mon, 27 Oct 2025 23:11:49 +0100 Subject: [PATCH 01/29] add JWT auth and GitHub OAuth strategies to auth module --- api/src/auth/auth.controller.ts | 59 +++++++++++++++++++++++++++++++-- api/src/auth/auth.module.ts | 24 +++++++++++++- api/src/auth/auth.service.ts | 11 +++++- frontend/app/actions/axios.ts | 1 - 4 files changed, 90 insertions(+), 5 deletions(-) diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index 438edde2..3fcc9c62 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -1,4 +1,59 @@ -import { Controller } from "@nestjs/common"; +import { + BadRequestException, + Body, + Controller, + Get, + Post, + Req, + Res, + UseGuards, +} from "@nestjs/common"; +import { AuthGuard } from "@nestjs/passport"; +import { Response, Request } from "express"; +import { AuthService } from "./auth.service"; +import { JwtAuthGuard } from "./jwt-auth.guard"; +import { ConfigService } from "@nestjs/config"; @Controller("auth") -export class AuthController {} +export class AuthController { + constructor( + private auth: AuthService, + private configService: ConfigService, + ) {} + + @UseGuards(JwtAuthGuard) + @Post("login") + login(@Body() body: { token?: string }, @Res() res: Response) { + if (!body.token) + throw new BadRequestException("Token is required for login."); + + res.cookie("token", body.token); + res.json({ message: "Login successful" }); + } + + @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: 7 * 24 * 60 * 60 * 1000 + }); + return res.redirect(redirectUrl); + } + return res.json({ token }); + } + + @Get("/me") + @UseGuards(JwtAuthGuard) + me(@Req() req: Request) { + return (req as any).user; + } +} diff --git a/api/src/auth/auth.module.ts b/api/src/auth/auth.module.ts index a7eac273..296fc574 100644 --- a/api/src/auth/auth.module.ts +++ b/api/src/auth/auth.module.ts @@ -1,9 +1,31 @@ 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 { 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") || "dev-secret", + signOptions: { + expiresIn: "7d", + }, + }), + }), + ], + providers: [AuthService, JwtStrategy, GithubOAuthStrategy], 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..966d3f38 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -1,4 +1,13 @@ 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/frontend/app/actions/axios.ts b/frontend/app/actions/axios.ts index 2dcdbccd..3b7368a2 100644 --- a/frontend/app/actions/axios.ts +++ b/frontend/app/actions/axios.ts @@ -7,7 +7,6 @@ const axiosInstance = axios.create({ baseURL: process.env.BACKEND_URL, headers: { "Content-Type": "application/json", - Authorization: `${process.env.BACKEND_SECRET}`, }, }); From 8d7d9894d0e7afd6e1a80d983b74747e5ddcc7a6 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Mon, 27 Oct 2025 23:12:04 +0100 Subject: [PATCH 02/29] replace UserGuard with JwtAuthGuard and add GitHub OAuth & JWT strategies to auth module --- api/src/auth/github.strategy.ts | 68 +++++++++++++++++++++++++++++++ api/src/auth/jwt-auth.guard.ts | 5 +++ api/src/auth/jwt.strategy.ts | 31 ++++++++++++++ api/src/event/event.controller.ts | 17 ++++---- 4 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 api/src/auth/github.strategy.ts create mode 100644 api/src/auth/jwt-auth.guard.ts create mode 100644 api/src/auth/jwt.strategy.ts 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..5a704e72 --- /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..c94b4704 --- /dev/null +++ b/api/src/auth/jwt.strategy.ts @@ -0,0 +1,31 @@ +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, From c9d1e819ed12ad6a74b8a3fa52c0e91618b5e244 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Mon, 27 Oct 2025 23:12:26 +0100 Subject: [PATCH 03/29] update CORS configuration to use dynamic origin and extend supported headers --- api/src/main.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/api/src/main.ts b/api/src/main.ts index a7bd8d36..bdacba07 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -26,12 +26,18 @@ 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(); + 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", + }); - const configService = app.get(ConfigService); app.connectMicroservice( getRabbitmqConfig(configService, "game_results"), From a867a5ccc607d425d1f1d6795175ef58768aade6 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Mon, 27 Oct 2025 23:12:46 +0100 Subject: [PATCH 04/29] replace `UserGuard` with `JwtAuthGuard` across controllers and update dependencies for JWT authentication --- api/package.json | 2 + api/pnpm-lock.yaml | 119 ++++++++++++++++++++++ api/src/guards/UserGuard.ts | 35 +------ api/src/match/match.controller.ts | 21 ++-- api/src/team/team.controller.ts | 22 ++-- api/src/user/social-account.controller.ts | 5 +- api/src/user/user.controller.ts | 5 +- 7 files changed, 151 insertions(+), 58 deletions(-) diff --git a/api/package.json b/api/package.json index 7b9db33a..45f250f5 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,7 @@ "crypto-js": "^4.2.0", "passport": "^0.7.0", "passport-github2": "^0.1.12", + "passport-jwt": "^4.0.1", "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..474ad1ab 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,9 @@ importers: passport-github2: specifier: ^0.1.12 version: 0.1.12 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 pg: specifier: ^8.16.3 version: 8.16.3 @@ -853,6 +859,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 +1194,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 +1206,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 +1727,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 +2033,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 +2745,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 +2792,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 +3067,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 +4764,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 +5085,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 +5720,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 +5977,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 +6909,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 +6962,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 +7192,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/guards/UserGuard.ts b/api/src/guards/UserGuard.ts index 04542ad2..25ba6cb8 100644 --- a/api/src/guards/UserGuard.ts +++ b/api/src/guards/UserGuard.ts @@ -1,42 +1,11 @@ import { - CanActivate, createParamDecorator, ExecutionContext, - Injectable, - UnauthorizedException, } from "@nestjs/common"; -import { Observable } from "rxjs"; -import { ConfigService } from "@nestjs/config"; -import { USER_ID_KEY } from "./GuardConstants"; 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; - } -} +); \ No newline at end of file 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/user/social-account.controller.ts b/api/src/user/social-account.controller.ts index fa847319..0384abc6 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,7 +24,7 @@ class LinkSocialAccountDto { platformUserId: string; } -@UseGuards(UserGuard) +@UseGuards(JwtAuthGuard) @ApiTags("social-accounts") @Controller("social-accounts") export class SocialAccountController { diff --git a/api/src/user/user.controller.ts b/api/src/user/user.controller.ts index 97b05c02..3c33bab8 100644 --- a/api/src/user/user.controller.ts +++ b/api/src/user/user.controller.ts @@ -10,7 +10,8 @@ import { } 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 { @@ -44,7 +45,7 @@ export class UserController { ); } - @UseGuards(UserGuard) + @UseGuards(JwtAuthGuard) @Get("canCreateEvent") async canCreateEvent(@UserId() id: string) { return this.userService.canCreateEvent(id); From 187b6b082f3ba07690d879d9a8daa9fdd164ee69 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sat, 1 Nov 2025 17:00:32 +0100 Subject: [PATCH 05/29] migrate authentication to backend with JWT and CredentialsProvider --- frontend/app/utils/authOptions.ts | 124 ++++++++++++++++-------------- 1 file changed, 67 insertions(+), 57 deletions(-) diff --git a/frontend/app/utils/authOptions.ts b/frontend/app/utils/authOptions.ts index 0198a902..8e2df078 100644 --- a/frontend/app/utils/authOptions.ts +++ b/frontend/app/utils/authOptions.ts @@ -1,71 +1,81 @@ import { NextAuthOptions } from "next-auth"; -import GithubProvider from "next-auth/providers/github"; -import axiosInstance from "@/app/actions/axios"; +import CredentialsProvider from "next-auth/providers/credentials"; + +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", - }, + CredentialsProvider({ + id: "backend", + name: "Backend", + credentials: {}, + async authorize(_credentials, req) { + try { + if (!BACKEND_BASE_URL) { + console.error("Missing BACKEND URL env"); + return null; + } + + const cookieHeader = req.headers?.cookie || ""; + + // Try to extract the JWT set by the backend from cookies to also send as Bearer + const match = cookieHeader.match(/(?:^|; )token=([^;]+)/); + const token = match ? decodeURIComponent(match[1]) : undefined; + + const res = await fetch(`${BACKEND_BASE_URL}/auth/me`, { + method: "GET", + headers: { + // Forward cookies so backend can read its own cookie if configured for shared domain + cookie: cookieHeader, + // Also send as Bearer since JwtStrategy extracts from Authorization header + ...(token ? { Authorization: `Bearer ${token}` } : {}), + Accept: "application/json", + }, + }); + + if (!res.ok) return null; + + const user = await res.json(); + if (!user?.id) return null; + + return { + id: user.id, + name: user.name || user.username, + email: user.email, + image: user.profilePicture, + } as any; + } catch (e) { + console.error("Authorize failed:", e); + return null; + } }, }), ], callbacks: { - async signIn({ user, account, profile }) { - if (account?.provider === "github") { - const githubProfile = profile as any; - - 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, - }); - } - } catch (e: any) { - console.error("Error during sign in:", e); - console.debug("response:", e?.response); - return false; - } - } - + 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; + (token as any).profilePicture = (user as any).profilePicture; } - const dbUser = ( - await axiosInstance.get(`user/email/${session.user?.email}`) - ).data; - - if (dbUser) session.user.id = dbUser.id; - + return token; + }, + async session({ session, token }) { + if (!session.user) session.user = { id: "", email: "", name: "" } as any; + (session.user as any).id = + (token.sub as string) || (session.user as any).id; + if (token.email) session.user.email = token.email as string; + if (token.name) session.user.name = token.name as string; + if ((token as any).profilePicture) + session.user.profilePicture = (token as any).profilePicture as string; return session; }, }, From 459129716fadce09fcc89305e29c6190810d696c Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sat, 1 Nov 2025 17:00:50 +0100 Subject: [PATCH 06/29] update axios instance to support dynamic backend URL and include token in request headers --- frontend/app/actions/axios.ts | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/frontend/app/actions/axios.ts b/frontend/app/actions/axios.ts index 3b7368a2..8dd418c5 100644 --- a/frontend/app/actions/axios.ts +++ b/frontend/app/actions/axios.ts @@ -1,31 +1,41 @@ import axios, { AxiosResponse } from "axios"; -import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/app/utils/authOptions"; import { ServerActionResponse } from "@/app/actions/errors"; 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", }, }); +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; - return config; - } + if (process.env.BACKEND_URL) { + const cookieData = await require("next/headers").cookies(); + const token = cookieData.get("token"); + if (token) config.headers.Authorization = `Bearer ${token.value}`; - if (config.url && config.url.startsWith("user/email/")) { + config.baseURL = process.env.BACKEND_URL; return config; } - const session = await getServerSession(authOptions); - config.headers.userId = session?.user?.id || ""; + + config.baseURL = process.env.NEXT_PUBLIC_BACKEND_PUBLIC_URL; + config.headers.Authorization = getTokenFromCookieString( + document.cookie || "", + ); return config; }, (error) => { - // Handle request errors return Promise.reject(error); }, ); From 4c5797c26006bd28b304d544df37f0dd2f5a0f20 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sat, 1 Nov 2025 17:01:32 +0100 Subject: [PATCH 07/29] refactor GitHub login flow to redirect to backend authentication endpoint --- api/src/guards/GuardConstants.ts | 1 - frontend/components/github.tsx | 9 +++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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/frontend/components/github.tsx b/frontend/components/github.tsx index 1f31887e..7b9b8cfb 100644 --- a/frontend/components/github.tsx +++ b/frontend/components/github.tsx @@ -1,13 +1,14 @@ import { Button } from "@heroui/react"; import { GithubIcon } from "./icons"; -import { signIn } from "next-auth/react"; export default function GithubLoginButton() { - async function githubLogin() { + function githubLogin() { try { - await signIn("github"); + const base = + process.env.NEXT_PUBLIC_BACKEND_PUBLIC_URL || process.env.BACKEND_URL; + window.location.href = `${base?.replace(/\/$/, "")}/auth/github/callback`; } catch (error) { - console.log("error while logging in:", error); + console.log("error while redirecting to login:", error); } } From 4c6ef6334c44d5b46e816cc41ebd433d5699283d Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sat, 1 Nov 2025 17:01:40 +0100 Subject: [PATCH 08/29] update user type definition to replace optional image field with required profilePicture --- frontend/types/next-auth.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/types/next-auth.d.ts b/frontend/types/next-auth.d.ts index 360ad258..99f6c1e4 100644 --- a/frontend/types/next-auth.d.ts +++ b/frontend/types/next-auth.d.ts @@ -6,7 +6,7 @@ declare module "next-auth" { id: string; email: string; name: string; - image?: string; + profilePicture: string; }; } } From ca7f7448bb7a63e323189f33fc384ba100027e31 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sat, 1 Nov 2025 17:02:05 +0100 Subject: [PATCH 09/29] refactor authentication flow to utilize UserService for fetching user details and update SSO page for seamless sign-in experience --- api/src/auth/auth.controller.ts | 5 ++++- api/src/guards/TeamGuard.ts | 4 ++-- frontend/app/auth/sso/page.tsx | 35 +++++++++++++++++++++++++++++++++ frontend/app/events/page.tsx | 4 ++-- 4 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 frontend/app/auth/sso/page.tsx diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index 3fcc9c62..65f57562 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -13,12 +13,14 @@ import { Response, Request } from "express"; import { AuthService } from "./auth.service"; import { JwtAuthGuard } from "./jwt-auth.guard"; import { ConfigService } from "@nestjs/config"; +import { UserService } from "../user/user.service"; @Controller("auth") export class AuthController { constructor( private auth: AuthService, private configService: ConfigService, + private userService: UserService ) {} @UseGuards(JwtAuthGuard) @@ -54,6 +56,7 @@ export class AuthController { @Get("/me") @UseGuards(JwtAuthGuard) me(@Req() req: Request) { - return (req as any).user; + const user: any = (req as any).user; + return this.userService.getUserById(user.id); } } 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/frontend/app/auth/sso/page.tsx b/frontend/app/auth/sso/page.tsx new file mode 100644 index 00000000..d85773ba --- /dev/null +++ b/frontend/app/auth/sso/page.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useEffect } from "react"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; + +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/page.tsx b/frontend/app/events/page.tsx index 67db28ce..52a3e826 100644 --- a/frontend/app/events/page.tsx +++ b/frontend/app/events/page.tsx @@ -50,9 +50,9 @@ export default async function EventsPage() { Discover and join upcoming coding competitions

{canCreate && ( - + )}
From 104b27c4efa49adba3af6bb0fe126b7102177b35 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sat, 1 Nov 2025 17:02:36 +0100 Subject: [PATCH 10/29] prettier --- api/src/auth/auth.controller.ts | 4 ++-- api/src/auth/auth.service.ts | 6 +++++- api/src/auth/jwt-auth.guard.ts | 2 +- api/src/auth/jwt.strategy.ts | 5 ++++- api/src/guards/UserGuard.ts | 7 ++----- api/src/main.ts | 1 - 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index 65f57562..19ce67d8 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -20,7 +20,7 @@ export class AuthController { constructor( private auth: AuthService, private configService: ConfigService, - private userService: UserService + private userService: UserService, ) {} @UseGuards(JwtAuthGuard) @@ -46,7 +46,7 @@ export class AuthController { httpOnly: true, secure: true, sameSite: "none", - maxAge: 7 * 24 * 60 * 60 * 1000 + maxAge: 7 * 24 * 60 * 60 * 1000, }); return res.redirect(redirectUrl); } diff --git a/api/src/auth/auth.service.ts b/api/src/auth/auth.service.ts index 966d3f38..63097206 100644 --- a/api/src/auth/auth.service.ts +++ b/api/src/auth/auth.service.ts @@ -7,7 +7,11 @@ export class AuthService { constructor(private jwt: JwtService) {} signToken(user: UserEntity) { - const payload = { sub: user.id, email: user.email, username: user.username }; + const payload = { + sub: user.id, + email: user.email, + username: user.username, + }; return this.jwt.sign(payload); } } diff --git a/api/src/auth/jwt-auth.guard.ts b/api/src/auth/jwt-auth.guard.ts index 5a704e72..2e81dba6 100644 --- a/api/src/auth/jwt-auth.guard.ts +++ b/api/src/auth/jwt-auth.guard.ts @@ -2,4 +2,4 @@ import { Injectable } from "@nestjs/common"; import { AuthGuard } from "@nestjs/passport"; @Injectable() -export class JwtAuthGuard extends AuthGuard('jwt') {} +export class JwtAuthGuard extends AuthGuard("jwt") {} diff --git a/api/src/auth/jwt.strategy.ts b/api/src/auth/jwt.strategy.ts index c94b4704..250dd2ba 100644 --- a/api/src/auth/jwt.strategy.ts +++ b/api/src/auth/jwt.strategy.ts @@ -12,7 +12,10 @@ export interface JwtPayload { @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(private config: ConfigService, private users: UserService) { + constructor( + private config: ConfigService, + private users: UserService, + ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, diff --git a/api/src/guards/UserGuard.ts b/api/src/guards/UserGuard.ts index 25ba6cb8..00ea4d20 100644 --- a/api/src/guards/UserGuard.ts +++ b/api/src/guards/UserGuard.ts @@ -1,11 +1,8 @@ -import { - createParamDecorator, - ExecutionContext, -} from "@nestjs/common"; +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; export const UserId = createParamDecorator( (data: unknown, ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest(); return request.user?.id; }, -); \ No newline at end of file +); diff --git a/api/src/main.ts b/api/src/main.ts index bdacba07..99ec37e3 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -38,7 +38,6 @@ async function bootstrap() { "Content-Type, Accept, Authorization, X-Requested-With, X-HTTP-Method-Override, X-Auth-Token, X-Refresh-Token", }); - app.connectMicroservice( getRabbitmqConfig(configService, "game_results"), ); From 1d94eeb537eeba952f14fd182118265ed1590c8d Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sun, 16 Nov 2025 21:05:03 +0100 Subject: [PATCH 11/29] update exmaple .env files --- api/.env.example | 13 +++++++++++++ frontend/.env.example | 9 --------- 2 files changed, 13 insertions(+), 9 deletions(-) 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/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 From bc17b73d8b7cda71ea0487f9931c10ad5544a4ce Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sun, 16 Nov 2025 21:05:44 +0100 Subject: [PATCH 12/29] implement 42 OAuth authentication flow with callback handling --- api/src/auth/auth.controller.ts | 64 ++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index 19ce67d8..502edd6a 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -4,16 +4,21 @@ import { Controller, Get, Post, + Query, Req, Res, UseGuards, } from "@nestjs/common"; import { AuthGuard } from "@nestjs/passport"; -import { Response, Request } from "express"; +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 { @@ -21,6 +26,7 @@ export class AuthController { private auth: AuthService, private configService: ConfigService, private userService: UserService, + private socialAccountService: SocialAccountService, ) {} @UseGuards(JwtAuthGuard) @@ -53,6 +59,62 @@ export class AuthController { 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){ + console.log("Error in FortyTwo callback: ", e) + throw new BadRequestException("Invalid state parameter."); + } + } + @Get("/me") @UseGuards(JwtAuthGuard) me(@Req() req: Request) { From b2652ecb4869a7006ea626e8bbff62764f9495bf Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sun, 16 Nov 2025 21:05:53 +0100 Subject: [PATCH 13/29] implement 42 OAuth authentication flow with callback handling --- api/src/auth/auth.module.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/api/src/auth/auth.module.ts b/api/src/auth/auth.module.ts index 296fc574..14369eac 100644 --- a/api/src/auth/auth.module.ts +++ b/api/src/auth/auth.module.ts @@ -6,6 +6,7 @@ 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({ @@ -24,7 +25,12 @@ import { UserModule } from "../user/user.module"; }), }), ], - providers: [AuthService, JwtStrategy, GithubOAuthStrategy], + providers: [ + AuthService, + JwtStrategy, + GithubOAuthStrategy, + FortyTwoOAuthStrategy, + ], controllers: [AuthController], exports: [PassportModule, JwtModule], }) From 1ea1ae6443639d1874ff241c853520a621a4cfb2 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sun, 16 Nov 2025 21:06:13 +0100 Subject: [PATCH 14/29] refactor authentication flow to use axios for API calls --- frontend/app/utils/authOptions.ts | 63 ++++++++++++++----------------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/frontend/app/utils/authOptions.ts b/frontend/app/utils/authOptions.ts index 8e2df078..b8d2a5a9 100644 --- a/frontend/app/utils/authOptions.ts +++ b/frontend/app/utils/authOptions.ts @@ -1,5 +1,7 @@ import { NextAuthOptions } from "next-auth"; import CredentialsProvider from "next-auth/providers/credentials"; +import axiosInstance from "@/app/actions/axios"; +import { redirect } from "next/navigation"; const BACKEND_BASE_URL = process.env.BACKEND_URL || process.env.NEXT_PUBLIC_BACKEND_PUBLIC_URL; @@ -20,34 +22,19 @@ export const authOptions: NextAuthOptions = { return null; } - const cookieHeader = req.headers?.cookie || ""; - - // Try to extract the JWT set by the backend from cookies to also send as Bearer - const match = cookieHeader.match(/(?:^|; )token=([^;]+)/); - const token = match ? decodeURIComponent(match[1]) : undefined; - - const res = await fetch(`${BACKEND_BASE_URL}/auth/me`, { - method: "GET", - headers: { - // Forward cookies so backend can read its own cookie if configured for shared domain - cookie: cookieHeader, - // Also send as Bearer since JwtStrategy extracts from Authorization header - ...(token ? { Authorization: `Bearer ${token}` } : {}), - Accept: "application/json", - }, - }); - - if (!res.ok) return null; - - const user = await res.json(); - if (!user?.id) return null; + const res = await axiosInstance.get<{ + id: string; + username: string; + email: string; + profilePicture: string; + }>(`/auth/me`); return { - id: user.id, - name: user.name || user.username, - email: user.email, - image: user.profilePicture, - } as any; + id: res.data.id, + name: res.data.username, + email: res.data.email, + image: res.data.profilePicture, + }; } catch (e) { console.error("Authorize failed:", e); return null; @@ -64,18 +51,26 @@ export const authOptions: NextAuthOptions = { token.sub = (user as any).id || token.sub; token.name = user.name || token.name; token.email = user.email || token.email; - (token as any).profilePicture = (user as any).profilePicture; } return token; }, async session({ session, token }) { - if (!session.user) session.user = { id: "", email: "", name: "" } as any; - (session.user as any).id = - (token.sub as string) || (session.user as any).id; - if (token.email) session.user.email = token.email as string; - if (token.name) session.user.name = token.name as string; - if ((token as any).profilePicture) - session.user.profilePicture = (token as any).profilePicture as string; + try { + const res = await axiosInstance.get<{ + id: string; + username: string; + email: string; + profilePicture: string; + }>(`/auth/me`); + + 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; }, }, From 6758e7a556187990892ddd51abbc685b9a83a159 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sun, 16 Nov 2025 21:07:08 +0100 Subject: [PATCH 15/29] implement 42 OAuth strategy for user authentication --- api/package.json | 1 + api/src/auth/fortytwo.strategy.ts | 64 ++++++++++++++++++++++++++++++ api/src/types/passport-oauth2.d.ts | 16 ++++++++ 3 files changed, 81 insertions(+) create mode 100644 api/src/auth/fortytwo.strategy.ts create mode 100644 api/src/types/passport-oauth2.d.ts diff --git a/api/package.json b/api/package.json index 45f250f5..454f3dfa 100644 --- a/api/package.json +++ b/api/package.json @@ -47,6 +47,7 @@ "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/src/auth/fortytwo.strategy.ts b/api/src/auth/fortytwo.strategy.ts new file mode 100644 index 00000000..64fb5927 --- /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/types/passport-oauth2.d.ts b/api/src/types/passport-oauth2.d.ts new file mode 100644 index 00000000..015a2c9a --- /dev/null +++ b/api/src/types/passport-oauth2.d.ts @@ -0,0 +1,16 @@ +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); + } +} + From e20db1086faed85679699c6695c97f44b0e4e433 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sun, 16 Nov 2025 21:09:06 +0100 Subject: [PATCH 16/29] migrate 42 OAuth authentication to backend and refactor social account linking --- api/pnpm-lock.yaml | 3 + api/src/user/social-account.controller.ts | 22 ------- api/src/user/social-account.service.ts | 73 ++++++++--------------- frontend/app/actions/social-accounts.ts | 12 ++++ frontend/hooks/use42Linking.ts | 61 +++++++------------ 5 files changed, 61 insertions(+), 110 deletions(-) diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml index 474ad1ab..94d0bfc1 100644 --- a/api/pnpm-lock.yaml +++ b/api/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: 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 diff --git a/api/src/user/social-account.controller.ts b/api/src/user/social-account.controller.ts index 0384abc6..95bdbb35 100644 --- a/api/src/user/social-account.controller.ts +++ b/api/src/user/social-account.controller.ts @@ -30,28 +30,6 @@ class LinkSocialAccountDto { 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..c43c60ba 100644 --- a/api/src/user/social-account.service.ts +++ b/api/src/user/social-account.service.ts @@ -20,52 +20,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 +49,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); + } +} \ No newline at end of file diff --git a/frontend/app/actions/social-accounts.ts b/frontend/app/actions/social-accounts.ts index f86600e0..03d3d625 100644 --- a/frontend/app/actions/social-accounts.ts +++ b/frontend/app/actions/social-accounts.ts @@ -57,3 +57,15 @@ 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/hooks/use42Linking.ts b/frontend/hooks/use42Linking.ts index 8ff2534d..7c5df09d 100644 --- a/frontend/hooks/use42Linking.ts +++ b/frontend/hooks/use42Linking.ts @@ -1,10 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useSearchParams } from "next/navigation"; -import { - OAUTH_URLS, - OAUTH_CONFIG, - OAUTH_PROVIDERS, -} from "@/lib/constants/oauth"; +import { OAUTH_CONFIG, OAUTH_PROVIDERS } from "@/lib/constants/oauth"; +import { getFortyTwoAuthUrl } from "@/app/actions/social-accounts"; /** * Simplified hook for handling 42 School OAuth integration @@ -43,49 +40,31 @@ 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); + setTimeout(async () => { + try { + // Ask backend for the 42 auth URL (which includes the encrypted user id in state) + const authUrl = await getFortyTwoAuthUrl(); - 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); - - // 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); }, []); From ae749584016f948c05b727ea016cb60b2f88daa3 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sun, 16 Nov 2025 21:09:38 +0100 Subject: [PATCH 17/29] extend JWT expiration to 30 days --- api/src/auth/auth.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/auth/auth.module.ts b/api/src/auth/auth.module.ts index 14369eac..f3884efc 100644 --- a/api/src/auth/auth.module.ts +++ b/api/src/auth/auth.module.ts @@ -20,7 +20,7 @@ import { UserModule } from "../user/user.module"; useFactory: (config: ConfigService) => ({ secret: config.getOrThrow("JWT_SECRET") || "dev-secret", signOptions: { - expiresIn: "7d", + expiresIn: "30d", }, }), }), From 7747bbde02027ea7c7ca5b65526cf72caa1f5fd2 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sun, 16 Nov 2025 21:10:50 +0100 Subject: [PATCH 18/29] format code --- api/src/auth/auth.controller.ts | 16 ++++++++++------ api/src/auth/fortytwo.strategy.ts | 2 +- api/src/types/passport-oauth2.d.ts | 1 - api/src/user/social-account.service.ts | 3 +-- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index 502edd6a..42e3cdba 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -67,7 +67,8 @@ export class AuthController { this.configService.getOrThrow("API_SECRET_ENCRYPTION_KEY"), ).toString(); - const base64EncodedEncryptedUserId = Buffer.from(encryptedUserId).toString("base64"); + 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}`; } @@ -83,13 +84,16 @@ export class AuthController { username: string; email: string; }; - } + }; }, @Res() res: Response, @Query("state") encryptedUserId: string, ) { - try{ - const base64DecodedEncryptedUserId = Buffer.from(encryptedUserId, "base64").toString("utf-8"); + try { + const base64DecodedEncryptedUserId = Buffer.from( + encryptedUserId, + "base64", + ).toString("utf-8"); const userId = CryptoJS.AES.decrypt( base64DecodedEncryptedUserId, @@ -109,8 +113,8 @@ export class AuthController { ); return res.redirect(redirectUrl); - }catch(e){ - console.log("Error in FortyTwo callback: ", e) + } catch (e) { + console.log("Error in FortyTwo callback: ", e); throw new BadRequestException("Invalid state parameter."); } } diff --git a/api/src/auth/fortytwo.strategy.ts b/api/src/auth/fortytwo.strategy.ts index 64fb5927..76bbc58d 100644 --- a/api/src/auth/fortytwo.strategy.ts +++ b/api/src/auth/fortytwo.strategy.ts @@ -55,7 +55,7 @@ export class FortyTwoOAuthStrategy extends PassportStrategy(Strategy, "42") { platformUserId, username, email, - } + }, }); } catch (err) { done(err, undefined); diff --git a/api/src/types/passport-oauth2.d.ts b/api/src/types/passport-oauth2.d.ts index 015a2c9a..a1a5892f 100644 --- a/api/src/types/passport-oauth2.d.ts +++ b/api/src/types/passport-oauth2.d.ts @@ -13,4 +13,3 @@ declare module "passport-oauth2" { constructor(options: StrategyOptions, verify?: any); } } - diff --git a/api/src/user/social-account.service.ts b/api/src/user/social-account.service.ts index c43c60ba..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"; @@ -74,4 +73,4 @@ export class SocialAccountService { }); return this.socialAccountRepository.save(entity); } -} \ No newline at end of file +} From 767924cf5ae277ed01cbda84cb03cbee2e8fa53b Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sun, 16 Nov 2025 21:19:27 +0100 Subject: [PATCH 19/29] update readme --- api/README.md | 18 ++++++++++++++++++ frontend/README.md | 18 ------------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/api/README.md b/api/README.md index 55ba0d0e..38ac903d 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/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` From 00ccea1d284b5bb3eb6b38c894a9d1be277c65a2 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sun, 16 Nov 2025 21:20:08 +0100 Subject: [PATCH 20/29] remove unused endpoints --- api/src/user/user.controller.ts | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/api/src/user/user.controller.ts b/api/src/user/user.controller.ts index 3c33bab8..13ec89fd 100644 --- a/api/src/user/user.controller.ts +++ b/api/src/user/user.controller.ts @@ -1,15 +1,10 @@ import { - Body, Controller, Get, Param, - ParseUUIDPipe, - Post, - Put, UseGuards, } from "@nestjs/common"; import { UserService } from "./user.service"; -import { CreateUserDto } from "./dtos/user.dto"; import { UserId } from "../guards/UserGuard"; import { JwtAuthGuard } from "../auth/jwt-auth.guard"; @@ -17,34 +12,6 @@ import { JwtAuthGuard } from "../auth/jwt-auth.guard"; 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(JwtAuthGuard) @Get("canCreateEvent") async canCreateEvent(@UserId() id: string) { From 0d7e78f98234f0cefa70860215a8c148d1586efa Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sun, 16 Nov 2025 21:25:48 +0100 Subject: [PATCH 21/29] sync cookie time with jwt token expire time --- api/src/auth/auth.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/auth/auth.controller.ts b/api/src/auth/auth.controller.ts index 42e3cdba..ffcc5835 100644 --- a/api/src/auth/auth.controller.ts +++ b/api/src/auth/auth.controller.ts @@ -52,7 +52,7 @@ export class AuthController { httpOnly: true, secure: true, sameSite: "none", - maxAge: 7 * 24 * 60 * 60 * 1000, + maxAge: 30 * 24 * 60 * 60 * 1000, }); return res.redirect(redirectUrl); } From d9019106c41a68a48865584f7a5211e55c5d7883 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sun, 16 Nov 2025 21:27:12 +0100 Subject: [PATCH 22/29] remove unused code --- api/src/auth/auth.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/auth/auth.module.ts b/api/src/auth/auth.module.ts index f3884efc..9dc7effc 100644 --- a/api/src/auth/auth.module.ts +++ b/api/src/auth/auth.module.ts @@ -18,7 +18,7 @@ import { UserModule } from "../user/user.module"; imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ - secret: config.getOrThrow("JWT_SECRET") || "dev-secret", + secret: config.getOrThrow("JWT_SECRET"), signOptions: { expiresIn: "30d", }, From 59e7ff9685f7f92eeffbc15c9dc078ce32d93ffb Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sun, 16 Nov 2025 22:20:52 +0100 Subject: [PATCH 23/29] merge --- .githooks/pre-commit | 4 ---- frontend/package.json | 5 +++++ 2 files changed, 5 insertions(+), 4 deletions(-) delete mode 100755 .githooks/pre-commit 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/frontend/package.json b/frontend/package.json index e13bddb4..5e6d2732 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -83,5 +83,10 @@ "tailwindcss": "^4.1.17", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx,json,css,scss,md}": [ + "eslint --fix" + ] } } From bfab25f708558afab8814a408238fe28bfcbf4f0 Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sun, 16 Nov 2025 22:21:55 +0100 Subject: [PATCH 24/29] remove unused property --- frontend/app/utils/authOptions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/utils/authOptions.ts b/frontend/app/utils/authOptions.ts index 02634dab..33c2f215 100644 --- a/frontend/app/utils/authOptions.ts +++ b/frontend/app/utils/authOptions.ts @@ -54,7 +54,7 @@ export const authOptions: NextAuthOptions = { } return token; }, - async session({ session, _ }) { + async session({ session }) { try { const res = await axiosInstance.get<{ id: string; From ab0a7938b8f5e380365ca2d85075d92a3d1cadcb Mon Sep 17 00:00:00 2001 From: Emil Ebert Date: Sun, 16 Nov 2025 22:23:56 +0100 Subject: [PATCH 25/29] fix build --- frontend/app/profile/ProfileClient.tsx | 2 +- frontend/app/utils/authOptions.ts | 2 +- frontend/layouts/basic-navbar.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 33c2f215..f7339a7a 100644 --- a/frontend/app/utils/authOptions.ts +++ b/frontend/app/utils/authOptions.ts @@ -32,7 +32,7 @@ export const authOptions: NextAuthOptions = { id: res.data.id, name: res.data.username, email: res.data.email, - image: res.data.profilePicture, + profilePicture: res.data.profilePicture, }; } catch (e) { 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(