27 add omniauth strategies for user authentication#54
Conversation
YoungMame
commented
Nov 26, 2025
There was a problem hiding this comment.
Pull request overview
This PR adds OAuth authentication support for 42 and Facebook providers, refactors the test suite to use a shared app instance for improved performance, and updates the database schema to track authentication providers.
- Implements OAuth2 authentication flows for 42 and Facebook using
@fastify/oauth2 - Refactors test infrastructure to build the app once and share it across tests
- Adds provider tracking to the user model to differentiate between local and OAuth users
Reviewed changes
Copilot reviewed 35 out of 36 changed files in this pull request and generated 22 comments.
Show a summary per file
| File | Description |
|---|---|
| postgresql/init.sql | Adds provider_ column to users table to track authentication method |
| fastify/conf/entrypoint.sh | Adds development mode logging |
| fastify/assets/test/setup.ts | Creates shared test setup with single app instance initialization |
| fastify/assets/test/unit/app.test.ts | Refactored to use shared app instance from setup |
| fastify/assets/test/integration/**/*.test.ts | All integration tests refactored to use shared app instance |
| fastify/assets/srcs/utils/geoloc.ts | Adds rate limiting and test environment handling for geolocation API |
| fastify/assets/srcs/services/UserService.ts | Adds provider parameter support, new getUserByUsername method, and provider validation in login |
| fastify/assets/srcs/routes/private/ws/index.ts | Adds test environment handling for geolocation in WebSocket connections |
| fastify/assets/srcs/plugins/oAuth/facebook.ts | Implements Facebook OAuth2 authentication flow |
| fastify/assets/srcs/plugins/oAuth/42.ts | Implements 42 OAuth2 authentication flow |
| fastify/assets/srcs/models/User/index.ts | Updates insert and find methods to support provider field |
| fastify/assets/srcs/classes/User.ts | Adds provider property to User class |
| fastify/assets/srcs/app.ts | Registers OAuth plugins, adds health endpoint, enables trust proxy |
| fastify/assets/seed/seed.ts | Fixes typo in location insert query (longitude -> country) |
| fastify/assets/package.json | Adds @fastify/oauth2 dependency and updates dev script |
| fastify/assets/@types/fastify.d.ts | Adds type definitions for OAuth namespaces and updated service methods |
| fastify/Dockerfile | Adds curl for health checks |
| docker-compose.*.yml | Adds OAuth credentials environment variables and health checks |
| .env.example | Documents OAuth credential environment variables |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| while (isUsernameTaken) { | ||
| const suffix = Math.floor(Math.random() * 10000); | ||
| const tryUsername = `${baseUsername}${suffix}`; | ||
| const userByUsername = await this.userService.getUserByUsername(tryUsername); | ||
| if (!userByUsername) { | ||
| baseUsername = tryUsername; | ||
| isUsernameTaken = false; | ||
| } | ||
| } |
There was a problem hiding this comment.
Potential infinite loop risk. The username generation loop has no upper bound or timeout. If all possible username variations are taken (unlikely but possible), this could loop indefinitely. Consider adding a maximum retry counter.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
| while (isUsernameTaken) { | ||
| const suffix = Math.floor(Math.random() * 10000); | ||
| const tryUsername = `${baseUsername}${suffix}`; | ||
| const userByUsername = await this.userService.getUserByUsername(tryUsername); | ||
| if (!userByUsername) { | ||
| baseUsername = tryUsername; | ||
| isUsernameTaken = false; | ||
| } | ||
| } |
There was a problem hiding this comment.
Potential infinite loop risk. The username generation loop has no upper bound or timeout. If all possible username variations are taken (unlikely but possible), this could loop indefinitely. Consider adding a maximum retry counter.
| { | ||
| if (process.env.NODE_ENV === 'test') | ||
| return { city: 'Test City', country: 'Test Country' }; | ||
| return console.log('Google Maps API rate limit reached'), { city: '...', country: '...' }; |
There was a problem hiding this comment.
Incorrect use of comma operator. The return statement uses a comma operator: return console.log(...), { city: '...', country: '...' }. While this works, it's confusing and non-idiomatic. The console.log should be on a separate line before the return statement for clarity.
| return console.log('Google Maps API rate limit reached'), { city: '...', country: '...' }; | |
| console.log('Google Maps API rate limit reached'); | |
| return { city: '...', country: '...' }; |
| const app = Fastify({ | ||
| logger: (process.env.NODE_ENV == 'dev') | ||
| logger: (process.env.NODE_ENV == 'dev'), | ||
| trustProxy: (process.env.NODE_ENV != 'test') |
There was a problem hiding this comment.
Use strict inequality operator. Use !== instead of != for type-safe comparison.
| trustProxy: (process.env.NODE_ENV != 'test') | |
| trustProxy: (process.env.NODE_ENV !== 'test') |
| if (!user || user.provider !== 'local') | ||
| throw new UnauthorizedError(); |
There was a problem hiding this comment.
The login method now checks if the provider is 'local', but this breaks existing OAuth users who might try to login directly. The error message thrown by UnauthorizedError doesn't explain why the login failed (e.g., "OAuth users cannot login with password" vs "Invalid credentials"). This could be confusing for users.
| @@ -1,6 +1,15 @@ | |||
| export async function getCityAndCountryFromCoords(latitude: number, longitude: number): Promise<{ city: string, country: string }> { | |||
| const response = await fetch(`https://maps.googleapis.com/maps/api/geocode/json?latlng=${latitude},${longitude}&key=${process.env.GOOGLE_MAPS_API_KEY}`); | |||
| if (response.status == 429) | |||
There was a problem hiding this comment.
Use strict equality operator. Use === instead of == for type-safe comparison.
| if (response.status == 429) | |
| if (response.status === 429) |
| // after(async function () { | ||
| // this.timeout(10000); | ||
| // if (app) { | ||
| // await app.close(); | ||
| // } | ||
| // }); No newline at end of file |
There was a problem hiding this comment.
The commented-out cleanup code in the after hook could lead to resource leaks in tests. The app instance is never closed, which means database connections and other resources may not be properly cleaned up after test runs. This could cause issues in CI/CD environments or when running tests multiple times.
| export const buildApp = () => { | ||
| const app = Fastify({ | ||
| logger: (process.env.NODE_ENV == 'dev') | ||
| logger: (process.env.NODE_ENV == 'dev'), |
There was a problem hiding this comment.
Use strict equality operator. Use === instead of == for type-safe comparison.
| logger: (process.env.NODE_ENV == 'dev'), | |
| logger: (process.env.NODE_ENV === 'dev'), |
| if (rows.rows.length === 0) | ||
| { | ||
| await this.fastify.pg.query( | ||
| const result = await this.fastify.pg.query( |
There was a problem hiding this comment.
Unused variable result.
| const result = await this.fastify.pg.query( | |
| await this.fastify.pg.query( |
| @@ -0,0 +1,102 @@ | |||
| import fp from 'fastify-plugin' | |||
| import oauthPlugin, { } from '@fastify/oauth2' | |||
There was a problem hiding this comment.
Unused import oauthPlugin.
| import oauthPlugin, { } from '@fastify/oauth2' |
|
@YoungMame I've opened a new pull request, #55, to work on those changes. Once the pull request is ready, I'll request review from you. |
…finite loop Co-authored-by: YoungMame <134452452+YoungMame@users.noreply.github.com>
Co-authored-by: YoungMame <134452452+YoungMame@users.noreply.github.com>
Added environment variables for Facebook and FT clients.
Add retry limit to OAuth username generation loops
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 37 out of 38 changed files in this pull request and generated 12 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| throw new Error('Missing FT_CLIENT_ID or FT_CLIENT_SECRET environment variable'); | ||
|
|
||
| fastify.get('/auth/login/42', async function (request, reply) { | ||
| const url = `https://api.intra.42.fr/oauth/authorize?client_id=u-s4t2ud-164069d052052d412516d442f502a23c7662d19adb38400ed23e2f5a841eac37&redirect_uri=https%3A%2F%2Fmatcha.fr%2Fapi%2Fauth%2Flogin%2F42%2Fcallback%2F&response_type=code`; |
There was a problem hiding this comment.
Hardcoded client ID in the authorization URL instead of using the CLIENT_ID variable. This exposes the client ID in code and creates inconsistency. Replace with client_id=${CLIENT_ID} and use proper URL encoding for the redirect_uri parameter.
| const url = `https://api.intra.42.fr/oauth/authorize?client_id=u-s4t2ud-164069d052052d412516d442f502a23c7662d19adb38400ed23e2f5a841eac37&redirect_uri=https%3A%2F%2Fmatcha.fr%2Fapi%2Fauth%2Flogin%2F42%2Fcallback%2F&response_type=code`; | |
| const redirectUri = 'https://matcha.fr/api/auth/login/42/callback/'; | |
| const url = `https://api.intra.42.fr/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code`; |
| // The service provider redirect the user here after successful login | ||
| fastify.get('/auth/login/facebook/callback/', async function (request, reply) { | ||
| const { token } = await this.facebookOAuth2.getAccessTokenFromAuthorizationCodeFlow(request); | ||
| console.log('getNewAccessTokenUsingRefreshToken:', token.access_token); |
There was a problem hiding this comment.
Access token is being logged to console. This is a security risk as tokens should never be logged in production environments. Remove this console.log statement or conditionally log only in development mode.
| console.log('getNewAccessTokenUsingRefreshToken:', token.access_token); | |
| if (process.env.NODE_ENV === 'development') { | |
| console.log('getNewAccessTokenUsingRefreshToken:', token.access_token); | |
| } |
| return reply.status(400).send({ error: 'Invalid user info received from Facebook' }); | ||
| } | ||
|
|
||
| const existigUser = await this.userService.getUser(userInfos.email || ''); |
There was a problem hiding this comment.
Corrected spelling of 'existigUser' to 'existingUser'.
| const existigUser = await this.userService.getUser(userInfo.email || ''); | ||
| if (existigUser && existigUser.provider !== '42') { | ||
| return reply.status(400).send({ error: 'Email already used with another provider' }); | ||
| } else if (existigUser && existigUser.provider === '42') { | ||
| const jwt = await fastify.jwt.sign({ id: existigUser.id, email: existigUser.email, username: existigUser.username, isVerified: existigUser.isVerified, isCompleted: existigUser.isProfileCompleted }); |
There was a problem hiding this comment.
Corrected spelling of 'existigUser' to 'existingUser'.
| const existigUser = await this.userService.getUser(userInfo.email || ''); | |
| if (existigUser && existigUser.provider !== '42') { | |
| return reply.status(400).send({ error: 'Email already used with another provider' }); | |
| } else if (existigUser && existigUser.provider === '42') { | |
| const jwt = await fastify.jwt.sign({ id: existigUser.id, email: existigUser.email, username: existigUser.username, isVerified: existigUser.isVerified, isCompleted: existigUser.isProfileCompleted }); | |
| const existingUser = await this.userService.getUser(userInfo.email || ''); | |
| if (existingUser && existingUser.provider !== '42') { | |
| return reply.status(400).send({ error: 'Email already used with another provider' }); | |
| } else if (existingUser && existingUser.provider === '42') { | |
| const jwt = await fastify.jwt.sign({ id: existingUser.id, email: existingUser.email, username: existingUser.username, isVerified: existingUser.isVerified, isCompleted: existingUser.isProfileCompleted }); |
| ); | ||
|
|
||
| const existigUser = await this.userService.getUser(userInfos.email); | ||
| await this.userService.verifyEmail(Number(existigUser.id)!, undefined); |
There was a problem hiding this comment.
Potential null dereference. The getUser call could return null, but the code immediately accesses existigUser.id with the non-null assertion operator. Add a null check before accessing the user properties.
| await this.userService.verifyEmail(Number(existigUser.id)!, undefined); | |
| if (!existigUser) { | |
| return reply.status(500).send({ error: 'Failed to retrieve newly created user' }); | |
| } | |
| await this.userService.verifyEmail(Number(existigUser.id), undefined); |
| fastify.get('/auth/login/42/callback/', async function (request: FastifyRequest, reply: FastifyReply) { | ||
| const q = request.query as { code?: string }; | ||
| const code = q.code; | ||
|
|
There was a problem hiding this comment.
Missing validation for the authorization code. If the code parameter is missing or empty, the OAuth flow should fail gracefully with a clear error message instead of passing undefined to the token exchange endpoint.
| if (!code || typeof code !== 'string' || code.trim() === '') { | |
| return reply.status(400).send({ error: 'Missing or invalid authorization code' }); | |
| } |
| // register a fastify url to start the redirect flow to the service provider's OAuth2 login | ||
| startRedirectPath: '/auth/login/facebook', | ||
| // service provider redirects here after user login | ||
| callbackUri: req => `https://matcha.fr/api/auth/login/facebook/callback/`, |
There was a problem hiding this comment.
Hardcoded domain in callback URI. Consider using process.env.DOMAIN or a configurable base URL to support different environments (development, staging, production).
| // after(async function () { | ||
| // this.timeout(10000); | ||
| // if (app) { | ||
| // await app.close(); | ||
| // } | ||
| // }); No newline at end of file |
There was a problem hiding this comment.
Commented-out cleanup code. The app instance is never closed after tests complete, which could lead to resource leaks or port conflicts. Either uncomment this code or add a comment explaining why it's intentionally disabled.
| @@ -1,6 +1,15 @@ | |||
| export async function getCityAndCountryFromCoords(latitude: number, longitude: number): Promise<{ city: string, country: string }> { | |||
| const response = await fetch(`https://maps.googleapis.com/maps/api/geocode/json?latlng=${latitude},${longitude}&key=${process.env.GOOGLE_MAPS_API_KEY}`); | |||
| if (response.status == 429) | |||
There was a problem hiding this comment.
Using loose equality (==) instead of strict equality (===) for status code comparison. Use === for type-safe comparison.
| if (response.status == 429) | |
| if (response.status === 429) |
| { | ||
| if (process.env.NODE_ENV === 'test') | ||
| return { city: 'Test City', country: 'Test Country' }; | ||
| return console.log('Google Maps API rate limit reached'), { city: '...', country: '...' }; |
There was a problem hiding this comment.
Using comma operator to combine console.log and return statement. This is unclear and makes the code harder to read. Split into two separate statements for better clarity.
| return console.log('Google Maps API rate limit reached'), { city: '...', country: '...' }; | |
| console.log('Google Maps API rate limit reached'); | |
| return { city: '...', country: '...' }; |